lumen_ir/layout_ir.rs
1//! `LayoutIR` - Lumen markup as a tree of styled elements, decoupled from
2//! ECS spawning so multiple backends can consume it (runtime spawn, AOT
3//! codegen, typed Rust struct emission, FFI bytecode).
4
5use std::path::{Path, PathBuf};
6
7use thiserror::Error;
8
9// `From` impls below convert each IR spec type into its lumen_core
10// runtime counterpart. They live here (not in lumen-core) because the
11// orphan rule requires the IR types to be in the impl-defining crate;
12// keeping them here also means `.into()` Just Works in spawn.rs without
13// per-call match expressions. Each conversion is total - every spec
14// variant maps to exactly one core variant.
15
16/// Markup parse errors.
17#[derive(Debug, Error)]
18pub enum ParseError {
19 /// Malformed XML/HTML at the lexer level.
20 #[error("xml parse error: {0}")]
21 Xml(String),
22 /// Unknown tag name (not in the supported set).
23 #[error(
24 "unknown tag '{0}' at position {1}; a tag a module brings needs \
25 `tags = [\"{0}\"]` on that module's [dependencies] entry"
26 )]
27 UnknownTag(String, usize),
28 /// Attribute value didn't match its expected form.
29 #[error("bad attribute '{name}=\"{value}\"' on <{tag}>: {reason}")]
30 BadAttribute {
31 /// Attribute name.
32 name: String,
33 /// Raw value seen.
34 value: String,
35 /// Owning tag.
36 tag: String,
37 /// Why the parser rejected it.
38 reason: String,
39 },
40 /// A `<include>` or `@import` directive could not be resolved: a
41 /// missing file, a malformed directive, or a cycle. The message
42 /// carries the include-site position and, for cycles, the full chain.
43 #[error("{0}")]
44 Include(String),
45}
46
47/// serde adapter for [`Attributes::dir`]. `lumen-core` carries no serde
48/// dependency, so `LayoutDirection` cannot derive `Serialize`/`Deserialize`
49/// itself; this module maps `Option<LayoutDirection>` to a stable, compact
50/// `Option<u8>` (`0 = Auto`, `1 = Ltr`, `2 = Rtl`) for the AOT artifact.
51/// Keeping the mapping here (rather than adding serde to lumen-core) keeps
52/// the parser-independent runtime free of an extra core dependency.
53pub mod layout_direction_serde {
54 use lumen_core::components::LayoutDirection;
55 use serde::{Deserialize, Deserializer, Serialize, Serializer};
56
57 /// Serialize `Option<LayoutDirection>` as `Option<u8>`.
58 pub fn serialize<S: Serializer>(v: &Option<LayoutDirection>, s: S) -> Result<S::Ok, S::Error> {
59 let tag: Option<u8> = v.map(|d| match d {
60 LayoutDirection::Auto => 0,
61 LayoutDirection::Ltr => 1,
62 LayoutDirection::Rtl => 2,
63 });
64 tag.serialize(s)
65 }
66
67 /// Deserialize `Option<u8>` back into `Option<LayoutDirection>`.
68 /// Unknown tags degrade to [`LayoutDirection::Auto`], mirroring the
69 /// `From<&str>` parser fallback.
70 pub fn deserialize<'de, D: Deserializer<'de>>(
71 d: D,
72 ) -> Result<Option<LayoutDirection>, D::Error> {
73 let tag = Option::<u8>::deserialize(d)?;
74 Ok(tag.map(|t| match t {
75 1 => LayoutDirection::Ltr,
76 2 => LayoutDirection::Rtl,
77 _ => LayoutDirection::Auto,
78 }))
79 }
80}
81
82/// The `<script>` elements one markup source carries, read without building
83/// its tree.
84///
85/// The blocks a candela script writes become fragments, and markup names one
86/// of those by writing the function as a tag, so the scripts have to be known
87/// before the tree is built. Reading them is a walk of the `<script>`
88/// elements, which is all this carries; the tree build collects the same set
89/// into [`LayoutIR::script_source`] and [`LayoutIR::external_scripts`].
90#[derive(Debug, Clone, Default, PartialEq, Eq)]
91pub struct ScriptRefs {
92 /// Body text of every inline block, in source order, newline separated.
93 pub inline: String,
94 /// `src` of every external block, in source order.
95 pub external: Vec<String>,
96}
97
98/// Top of the IR. Single root element; multi-rooted markup is illegal.
99#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
100pub struct LayoutIR {
101 /// The single root element.
102 pub root: Element,
103 /// Concatenated body text of every inline `<script>` tag found in
104 /// the markup, in source order, separated by `\n`. Empty if none.
105 ///
106 /// Use `<script src="foo.cdl"/>` instead when the script contains
107 /// characters that confuse the XML parser (`<`, `<=`, `&`) - those
108 /// references land in [`external_scripts`].
109 pub script_source: String,
110 /// Where each inline block in [`Self::script_source`] was written: its
111 /// markup file and the line its text starts on, so an error in it names
112 /// that line.
113 pub script_map: crate::source_map::SourceMap,
114 /// Paths from every `<script src="..."/>` reference, in source
115 /// order. The runtime (`lumenc::run::load_ir`) resolves them
116 /// relative to the app directory and concatenates with
117 /// `script_source` before handing the combined source to the
118 /// scripting host.
119 pub external_scripts: Vec<String>,
120 /// Opt-in skin requested by the root element via `<root skin="...">`.
121 /// `None` means the bare framework - no default styling, fully
122 /// honouring the SDD "no opinionated default theme" principle. When
123 /// `Some("default")` (the only recognised value today), the runtime
124 /// prepends an embedded `lumen-skin-default` stylesheet before
125 /// parsing the user's `main.css`.
126 pub skin: Option<String>,
127 /// `<root frameless="true">` - suppress OS window chrome (title
128 /// bar, borders, close/min/max buttons). The window backend reads
129 /// this once at startup to set `WindowAttributes::decorations(false)`.
130 /// Custom title bars are the app's responsibility.
131 pub frameless: bool,
132 /// Native menu bar, collected from the top-level `<menubar>`
133 /// element (if any) and stripped from the layout tree. `None` =
134 /// no menu bar. Each entry maps to a `muda::Submenu` /
135 /// `MenuItem` / `PredefinedMenuItem::separator`.
136 pub menubar: Option<MenuBarSpec>,
137 /// Concatenated skin + user CSS, kept around so the for-block
138 /// reconciler can re-apply matching rules to virtualized template
139 /// instances *after* `{kind}`/`{level}` etc. placeholders have
140 /// been substituted into class names at runtime. Without this,
141 /// `<label class="md-h{level}">` never picks up `.md-h1` because
142 /// the parse-time apply pass only saw the literal placeholder.
143 pub combined_stylesheet: Option<crate::css::Stylesheet>,
144 /// Parse-time lint findings. Populated by the markup walker for
145 /// problems that don't invalidate the IR: `{name}` bare
146 /// interpolation, a boolean attribute with an off-list value, an
147 /// attribute nothing reads. The runtime / IR consumers ignore this;
148 /// every compile path (`lumenc check`, `run`, `build`) prints it to
149 /// stderr at the finding's own severity, and `lumenc lint --signals`
150 /// folds it into the finding stream.
151 pub lint_findings: Vec<LintFinding>,
152 /// Normalized paths of every `.lmn` file pulled in via
153 /// `<include src="..."/>` (transitively, in resolution order). Empty
154 /// when the markup used no includes or was parsed without a file
155 /// loader. The runtime (`lumenc::run`) adds these to the hot-reload
156 /// watch set so editing an included file re-triggers a reload, exactly
157 /// like [`external_scripts`].
158 pub included_files: Vec<PathBuf>,
159}
160
161/// Severity tier for a parse-time [`LintFinding`].
162///
163/// Mirrors the lint-CLI severity ladder. Kept here (rather than
164/// importing the CLI's enum) so the parser can populate findings
165/// without pulling the lint module into the IR's dependency graph.
166#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
167pub enum LintSeverity {
168 /// Definitive bug - would fail a build in strict mode.
169 Error,
170 /// Probably wrong - flagged in `--strict`, advisory otherwise.
171 Warn,
172 /// Stylistic / migration nudge.
173 Info,
174 /// Lowest priority - likely dead code.
175 Hint,
176}
177
178/// Category bucket for a parse-time [`LintFinding`]. New variants
179/// land here as the parser learns more rules.
180#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
181pub enum LintKind {
182 /// `{name}` interpolation without the explicit `$` prefix; the
183 /// preferred form is `{$name}` (global) or `{$self.field}` /
184 /// `{$parent.field}` (per-entity).
185 BareInterpolation,
186 /// A boolean attribute carrying a value outside the shared
187 /// truthiness set (`true` / `yes` / `1` / bare for true,
188 /// `false` / `no` / `0` for false). The attribute reads as false.
189 BooleanAttribute,
190 /// An attribute the markup vocabulary has no meaning for. It is
191 /// dropped: nothing reads it at spawn time, so a typo (`tect=` for
192 /// `text=`) or a web-only attribute silently does nothing.
193 UnknownAttribute,
194}
195
196impl From<LintKind> for &'static str {
197 fn from(k: LintKind) -> &'static str {
198 match k {
199 LintKind::BareInterpolation => "bare-interpolation",
200 LintKind::BooleanAttribute => "boolean-attribute",
201 LintKind::UnknownAttribute => "unknown-attribute",
202 }
203 }
204}
205
206impl From<LintSeverity> for &'static str {
207 fn from(s: LintSeverity) -> &'static str {
208 match s {
209 LintSeverity::Error => "error",
210 LintSeverity::Warn => "warn",
211 LintSeverity::Info => "info",
212 LintSeverity::Hint => "hint",
213 }
214 }
215}
216
217/// One parse-time finding. Carries a 1-based (line, col) anchor in
218/// the source so downstream tools can surface a diagnostic at the
219/// exact byte where the legacy shape lives.
220#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
221pub struct LintFinding {
222 /// Category bucket.
223 pub kind: LintKind,
224 /// Severity tier.
225 pub severity: LintSeverity,
226 /// Human-readable description.
227 pub message: String,
228 /// 1-based line number in the source.
229 pub line: usize,
230 /// 1-based column.
231 pub col: usize,
232 /// Suggested replacement text (`Some("{$count}")` for the bare
233 /// interpolation rule). `None` when no machine-applicable fix is
234 /// available.
235 pub suggest: Option<String>,
236}
237
238impl LintFinding {
239 /// Render the finding as the stderr diagnostic the compile paths
240 /// print: one severity-prefixed line anchored at `file:line:col`,
241 /// plus a `hint:` line when the rule carries a machine-applicable
242 /// fix. Every path that compiles markup from source calls this, so
243 /// `check`, `run` and `build` cannot drift apart on the wording.
244 pub fn render(&self, file: &Path) -> String {
245 let mut out = format!(
246 "{sev:<5} {file}:{line}:{col} [{kind}] {msg}",
247 sev = <&'static str>::from(self.severity),
248 file = file.display(),
249 line = self.line,
250 col = self.col,
251 kind = <&'static str>::from(self.kind),
252 msg = self.message,
253 );
254 if let Some(s) = &self.suggest {
255 out.push_str(&format!("\n hint: replace with `{s}`"));
256 }
257 out
258 }
259}
260
261/// Parsed `<menubar>` content. Top-level submenus + their items.
262#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
263pub struct MenuBarSpec {
264 /// Top-level submenus, in source order.
265 pub menus: Vec<MenuSpec>,
266}
267
268/// One `<menu label="File">...</menu>` submenu.
269#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
270pub struct MenuSpec {
271 /// Display label shown in the menu bar.
272 pub label: String,
273 /// Items inside the submenu, in source order.
274 pub items: Vec<MenuEntrySpec>,
275}
276
277/// One entry inside a submenu - either a clickable item or a
278/// separator line.
279#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
280pub enum MenuEntrySpec {
281 /// `<menuitem id="open" label="Open" accel="Cmd+O" />`.
282 Item {
283 /// `id="..."` - passed back to scripts as `on_menu(id)`.
284 id: String,
285 /// Display label.
286 label: String,
287 /// Optional accelerator string in muda format
288 /// (`"CommandOrControl+S"`, `"Alt+F4"`, ...).
289 accelerator: Option<String>,
290 },
291 /// `<separator />`.
292 Separator,
293}
294
295/// One node in the IR tree.
296#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
297pub struct Element {
298 /// Tag name (`root`, `column`, `tile`, `label`, ...).
299 pub tag: String,
300 /// Resolved attribute bag in normalized form.
301 pub attrs: Attributes,
302 /// Children, in source order.
303 pub children: Vec<Element>,
304 /// Round-8 wave-C: catalog of `{...}` placeholder slots that appear in
305 /// this element's string-valued attributes (text, id, classes, ...).
306 /// Populated by the parser so the spawner / for-block reconciler can
307 /// look up which scope to resolve each placeholder from instead of
308 /// falling back to substring replace against the global signal map.
309 ///
310 /// Order matches first-appearance source order across the scanned
311 /// attribute set; duplicates inside the same element collapse to one
312 /// entry. Empty for elements with no interpolation sites.
313 pub interpolations: Vec<InterpolationSlot>,
314 /// Set when this element instantiates a fragment rather than rendering
315 /// itself. `None` on every ordinary element, which is nearly all of
316 /// them; boxed so carrying the possibility costs one pointer on an
317 /// element the `<for>` reconciler deep-clones per row.
318 pub frag_use: Option<Box<FragmentUse>>,
319}
320
321/// A fragment instantiation, recorded on the element that stands in for the
322/// fragment's body until the tree is expanded.
323///
324/// The declaration side is [`crate::fragment::Fragment`], looked up by
325/// [`Self::key`] in the app's [`crate::fragment::FragmentTable`].
326#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
327pub struct FragmentUse {
328 /// Key of the fragment being instantiated.
329 pub key: String,
330 /// Arguments the use site passes, as `(parameter name, value)` pairs in
331 /// source order. A parameter absent here takes its declared default.
332 pub args: Vec<(String, String)>,
333 /// Whether the use site supplies children of its own for the fragment's
334 /// slot. The children themselves stay on [`Element::children`]; this
335 /// says they are slot content rather than the element's own subtree.
336 pub slot_children: bool,
337}
338
339impl Default for Element {
340 /// Empty element with no tag, default attrs, no children, no
341 /// interpolation slots, no fragment use. Synthetic-element
342 /// constructors in the parser (`<tabs>`, `<dropdown>`, ...) use
343 /// `..Default::default()` to fill in the trailing fields
344 /// automatically.
345 fn default() -> Self {
346 Self {
347 tag: String::new(),
348 attrs: Attributes::default(),
349 children: Vec::new(),
350 interpolations: Vec::new(),
351 frag_use: None,
352 }
353 }
354}
355
356/// Rewrite every `<image src>` under `el` that points inside `root` to a
357/// path relative to it, recording in `outside` the ones that point
358/// elsewhere.
359///
360/// The inverse of the compiler's asset resolution, which makes every `src`
361/// absolute against the app directory on the machine that compiles. That is
362/// the right answer for running the app in place and the wrong one for an
363/// artifact that travels, so anything shipping an IR elsewhere (a packaged
364/// folder, a web build) puts the paths back first.
365///
366/// Rewritten paths are joined with forward slashes whatever the compiling
367/// machine uses: every platform's loader accepts them, so an artifact built
368/// for another platform names its files in a way that platform can follow.
369pub fn relativize_asset_paths(el: &mut Element, root: &Path, outside: &mut Vec<String>) {
370 if el.tag == "image"
371 && let Some(path) = &el.attrs.src
372 {
373 let p = Path::new(path);
374 if p.is_absolute() {
375 match p.strip_prefix(root) {
376 Ok(rel) => {
377 let parts: Vec<String> = rel
378 .components()
379 .map(|c| c.as_os_str().to_string_lossy().into_owned())
380 .collect();
381 el.attrs.src = Some(parts.join("/"));
382 }
383 Err(_) => outside.push(path.clone()),
384 }
385 }
386 }
387 for child in &mut el.children {
388 relativize_asset_paths(child, root, outside);
389 }
390}
391
392/// Round-8 wave-C: classification of a `{...}` placeholder inside markup.
393/// Carries the *resolution scope* the spawner consults when materializing
394/// the slot into a concrete string.
395///
396/// - `Global("name")` - `{$name}` or the legacy bare `{name}`. Resolves
397/// against [`lumen_core::signals::Signals`] (the global signal map).
398/// - `Row("field")` - `{row.field}`, only meaningful inside a `<for>`
399/// body. Resolves against the current iteration's
400/// [`lumen_core::signals::ArrayItem`] record. Crucially does not fall
401/// through to globals when the row record is missing the field - that
402/// substitution emits empty string and a one-shot `tracing::warn!`.
403/// - `RowIndex` - `{$index}` (preferred) or the legacy `{idx}` alias.
404/// Resolves to the 0-based iteration index, stringified.
405/// - `SelfField("f")` / `ParentField("f")` - `{$self.f}` / `{$parent.f}`.
406/// Stub today; the per-entity consumer system lands in a follow-up
407/// wave. Substitutes empty string and emits a debug trace.
408/// - `Arg("name")` - a fragment parameter, resolved from the arguments the
409/// use site passed. Only a fragment body carries these: the classifier
410/// below cannot produce one, because telling a parameter from a global
411/// needs the enclosing fragment's parameter list, which it cannot see.
412/// The fragment builder rewrites `Global` to `Arg` for each declared
413/// parameter once that scope is known.
414#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
415pub enum InterpolationSlot {
416 /// `{$name}` or `{name}` legacy shorthand - global signal lookup.
417 Global(String),
418 /// `{row.field}` - iteration-scope field lookup. `<for>` body only.
419 Row(String),
420 /// `{$index}` or `{idx}` legacy alias - 0-based iteration index.
421 RowIndex,
422 /// `{$self.field}` - per-entity field lookup. Stubbed today.
423 SelfField(String),
424 /// `{$parent.field}` - parent-entity field lookup. Stubbed today.
425 ParentField(String),
426 /// A fragment parameter, resolved from the instantiating
427 /// [`FragmentUse::args`] or the parameter's declared default.
428 Arg(String),
429}
430
431impl From<&str> for InterpolationSlot {
432 /// Classify a placeholder's *trimmed inner body* (without the
433 /// surrounding `{...}`). The parser's interpolation walker uses this
434 /// to convert each placeholder it finds into a typed slot:
435 ///
436 /// - `"$index"` / `"idx"` -> [`InterpolationSlot::RowIndex`].
437 /// - `"$self.<f>"` -> [`InterpolationSlot::SelfField`].
438 /// - `"$parent.<f>"` -> [`InterpolationSlot::ParentField`].
439 /// - `"row.<f>"` -> [`InterpolationSlot::Row`].
440 /// - `"$name"` / `"name"` -> [`InterpolationSlot::Global`].
441 ///
442 /// [`InterpolationSlot::Arg`] is never produced here: a parameter
443 /// reference is spelled the same as a global, and only the enclosing
444 /// fragment's parameter list separates them.
445 fn from(inner: &str) -> Self {
446 let trimmed = inner.trim();
447 // `idx` is the legacy row-index alias; only meaningful inside
448 // a `<for>` body, but the From impl can't see that context so
449 // we always route it to [`RowIndex`]. The parser still emits
450 // a `BareInterpolation` finding nudging the author to write
451 // `{$index}`.
452 if trimmed == "$index" || trimmed == "idx" {
453 return InterpolationSlot::RowIndex;
454 }
455 if let Some(rest) = trimmed.strip_prefix("$self.") {
456 return InterpolationSlot::SelfField(rest.to_string());
457 }
458 if let Some(rest) = trimmed.strip_prefix("$parent.") {
459 return InterpolationSlot::ParentField(rest.to_string());
460 }
461 if let Some(rest) = trimmed.strip_prefix("row.") {
462 return InterpolationSlot::Row(rest.to_string());
463 }
464 if let Some(rest) = trimmed.strip_prefix('$') {
465 return InterpolationSlot::Global(rest.to_string());
466 }
467 InterpolationSlot::Global(trimmed.to_string())
468 }
469}
470
471/// Length specifier as it appears in markup.
472#[derive(Debug, Default, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
473pub enum LengthSpec {
474 /// `auto`.
475 #[default]
476 Auto,
477 /// Pixels.
478 Px(f32),
479 /// Percentage of parent.
480 Percent(f32),
481}
482
483/// CSS `line-height` value. Unlike [`LengthSpec`], the two forms carry
484/// different meanings rather than different units of the same quantity:
485/// a bare number (`line-height: 1.2`) scales with the element's own
486/// font size, while a `px` value (`line-height: 19px`) is a fixed line
487/// box height that does not track font-size changes. Kept as two
488/// variants instead of collapsing to a single px number so a consumer
489/// can tell which behavior the author asked for.
490#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
491pub enum LineHeightSpec {
492 /// Unitless multiplier of the element's font size (CSS `normal`-like
493 /// scaling behavior).
494 Multiplier(f32),
495 /// Fixed line height in px, independent of font size.
496 Px(f32),
497}
498
499/// Four edge values (in CSS order: left, right, top, bottom).
500///
501/// W5.5 adds CSS Logical Properties Level 1 fields (`inline_start`,
502/// `inline_end`, `block_start`, `block_end`). When set, they ride along
503/// to the lumen-core `Edges` whose `resolved(dir)` method maps them onto
504/// the physical sides at layout time per [`ResolvedDirection`].
505#[derive(Debug, Clone, Copy, Default, PartialEq, serde::Serialize, serde::Deserialize)]
506pub struct Edges {
507 /// Left edge in px.
508 pub left: f32,
509 /// Right edge in px.
510 pub right: f32,
511 /// Top edge in px.
512 pub top: f32,
513 /// Bottom edge in px.
514 pub bottom: f32,
515 /// `*-inline-start` in px - maps to `left` (LTR) / `right` (RTL).
516 pub inline_start: Option<f32>,
517 /// `*-inline-end` in px - maps to `right` (LTR) / `left` (RTL).
518 pub inline_end: Option<f32>,
519 /// `*-block-start` in px - alias for `top` (no vertical writing modes yet).
520 pub block_start: Option<f32>,
521 /// `*-block-end` in px - alias for `bottom`.
522 pub block_end: Option<f32>,
523 /// CSS percent unit for the left edge (`padding: 5%`). When `Some`
524 /// the px field for the side is ignored; the layout backend hands
525 /// taffy a `LengthPercentage::percent` which resolves against the
526 /// containing block per CSS.
527 pub pct_left: Option<f32>,
528 /// See [`Self::pct_left`].
529 pub pct_right: Option<f32>,
530 /// See [`Self::pct_left`].
531 pub pct_top: Option<f32>,
532 /// See [`Self::pct_left`].
533 pub pct_bottom: Option<f32>,
534}
535
536impl Edges {
537 /// Every side `auto` (`NaN`), the initial value of `inset`. A side an
538 /// `inset` longhand does not name stays `auto`.
539 pub const fn auto() -> Self {
540 Self::all(f32::NAN)
541 }
542
543 /// Uniform edges.
544 pub const fn all(v: f32) -> Self {
545 Self {
546 left: v,
547 right: v,
548 top: v,
549 bottom: v,
550 inline_start: None,
551 inline_end: None,
552 block_start: None,
553 block_end: None,
554 pct_left: None,
555 pct_right: None,
556 pct_top: None,
557 pct_bottom: None,
558 }
559 }
560}
561
562/// Parsed `<tooltip text="..." delay="...">` payload, propagated from
563/// the wrapper to its single child as
564/// [`Attributes::tooltip`](crate::layout_ir::Attributes::tooltip).
565#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
566pub struct TooltipSpec {
567 /// Body text shown in the popup.
568 pub text: String,
569 /// `translatable="<key>"` on the `<tooltip>` wrapper - the catalogue
570 /// key the popup body resolves through. The wrapper is the element
571 /// that authors the string, so it takes a plain key of its own rather
572 /// than an attribute on the trigger's; the trigger may not keep one,
573 /// since a `<checkbox>` moves its key onto the caption it synthesizes.
574 pub translatable: Option<String>,
575 /// Hover dwell before the popup appears, in milliseconds. `None` =
576 /// author didn't set `delay="..."` inline; the cascade fills it from
577 /// the `--lumen-tooltip-delay` skin token when declared, and the
578 /// spawn layer falls back to the runtime default (500) last.
579 pub delay_ms: Option<u32>,
580 /// Gap between the cursor hotspot and the popup's top-left corner,
581 /// in logical pixels. Same resolution chain as [`Self::delay_ms`]:
582 /// inline attr -> `--lumen-tooltip-offset` token -> runtime default.
583 pub offset: Option<f32>,
584}
585
586/// Synthesised `<dropdown>` header payload: the open signal plus the
587/// full option list mirrored from the `<option>` children (markup
588/// order), so the runtime header component can run keyboard
589/// interaction while the popup body is unmounted.
590#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
591pub struct DropdownButtonSpec {
592 /// Open-panel signal (`__dropdown_open:<bind-value>`).
593 pub open_signal: String,
594 /// Author-bound value signal (`bind-value=`).
595 pub value_signal: String,
596 /// `(value, label, disabled)` per `<option>`, in markup order.
597 pub options: Vec<(String, String, bool)>,
598}
599
600/// Parsed `bg=` value. Solid colors, gradients, and images share one
601/// attribute surface so authors don't pick between `bg=` and `bg-gradient=`.
602#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
603pub enum BgSpec {
604 /// Solid color.
605 Solid(Rgba),
606 /// CSS-style linear gradient.
607 Linear {
608 /// Angle in degrees (CSS convention: 0deg = bottom->top, 90 = left->right).
609 angle_deg: f32,
610 /// `(offset, color)` pairs in source order, after parse-time
611 /// normalization (sorted by offset ascending).
612 stops: Vec<(f32, Rgba)>,
613 },
614 /// CSS-style radial gradient centred on the rect.
615 Radial {
616 /// Normalized radius `0..=1` of half-min-dim.
617 radius: f32,
618 /// `(offset, color)` pairs ascending offset.
619 stops: Vec<(f32, Rgba)>,
620 },
621 /// CSS-style conic (sweep) gradient.
622 Conic {
623 /// Starting angle in degrees (CSS: 0 = north, 90 = east).
624 from_deg: f32,
625 /// `(offset, color)` pairs ascending offset.
626 stops: Vec<(f32, Rgba)>,
627 },
628 /// `url("path")`: an image painted behind the element's content, sized by
629 /// [`Attributes::bg_fit`]. The path is as authored; it resolves against
630 /// the app directory the way an `<image src>` does.
631 Image(String),
632}
633
634/// RGBA color in `[0,1]^4`.
635#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
636pub struct Rgba {
637 /// Red.
638 pub r: f32,
639 /// Green.
640 pub g: f32,
641 /// Blue.
642 pub b: f32,
643 /// Alpha.
644 pub a: f32,
645}
646
647/// Scroll axis declared in markup.
648#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
649pub enum ScrollAxisSpec {
650 /// Vertical scroll only.
651 Y,
652 /// Horizontal scroll only.
653 X,
654 /// Both axes.
655 Both,
656}
657
658/// Resolved attribute bag; one struct so the parser fills it in a single
659/// pass and the spawner / codegen don't have to re-parse strings.
660#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
661pub struct Attributes {
662 /// `width` attribute.
663 pub width: Option<LengthSpec>,
664 /// `height` attribute.
665 pub height: Option<LengthSpec>,
666 /// `flex` attribute (`row` / `column`).
667 pub flex: Option<FlexAxis>,
668 /// `bg` attribute (background color).
669 pub bg: Option<BgSpec>,
670 /// `radius` attribute in px (uniform).
671 pub radius: Option<f32>,
672 /// Per-corner radii `[top-left, top-right, bottom-right,
673 /// bottom-left]` in px - the CSS `border-radius` 2-4 value
674 /// shorthand and the `border-top-left-radius` ... longhands. When
675 /// set, wins over the uniform [`Self::radius`] for paint; the
676 /// uniform slot is kept in sync (max corner) for the runtime paths
677 /// that only understand one number (knob geometry, hit rings).
678 pub radius_corners: Option<[f32; 4]>,
679 /// `padding` attribute.
680 pub padding: Option<Edges>,
681 /// `margin` attribute.
682 pub margin: Option<Edges>,
683 /// `text` content (also accepts text child node).
684 pub text: Option<String>,
685 /// `text-color`.
686 pub text_color: Option<Rgba>,
687 /// `selection-color` - text selection highlight. Skins default it
688 /// via the `--lumen-selection` token; unset falls back to the
689 /// renderer default (text fill at 32 % alpha).
690 pub selection_color: Option<Rgba>,
691 /// `caret-color` - text-input caret tint (CSS). Lands on
692 /// [`lumen_core::components::TextInputPaint::caret_color`]; unset
693 /// falls back to the text fill (web default). Only meaningful on
694 /// `<input>` / `<textarea>`.
695 pub caret_color: Option<Rgba>,
696 /// `selection-text-color` - selected-glyph foreground (Qt
697 /// `QPalette::HighlightedText` / Slint `selection-foreground-color`).
698 /// Lands on
699 /// [`lumen_core::components::TextInputPaint::selection_foreground`];
700 /// unset => selected glyphs keep their normal fill. Only meaningful on
701 /// `<input>` / `<textarea>`.
702 pub selection_text_color: Option<Rgba>,
703 /// `scroll` axis.
704 pub scroll: Option<ScrollAxisSpec>,
705 /// `sensitivity` (scroll containers only).
706 pub sensitivity: Option<f32>,
707 /// `inertia` (scroll containers only).
708 pub inertia: Option<f32>,
709 /// `tab-index`.
710 pub tab_index: Option<i32>,
711 /// `id="..."` - emits a `LumenId` marker.
712 pub id: Option<String>,
713 /// `class="a b"` - emits a `LumenClasses` marker (split on whitespace).
714 pub classes: Vec<String>,
715 /// `hover-bg` - when set, the entity gains a `HoverTint` component
716 /// whose value the primitives crate swaps in on hover.
717 pub hover_bg: Option<Rgba>,
718 /// `draggable="true"` - entity gains a `Draggable` marker so drag
719 /// moves translate its Transform automatically.
720 pub draggable: bool,
721 /// `drag-payload="..."` - makes the entity an in-app DnD
722 /// [`DragSource`](lumen_os_dnd::DragSource) publishing this text
723 /// payload (interpolated per-row inside a `<for>`). An empty value
724 /// derives the payload from the element's `id`. Mirrors HTML5
725 /// `dataTransfer.setData` / Qt `QMimeData`.
726 pub drag_payload: Option<String>,
727 /// `accept="text/plain"` on a drop target - MIME filter
728 /// ([`DropAccept`](lumen_os_dnd::DropAccept)). Absent = accept any.
729 pub drop_accept: Option<String>,
730 /// `placeholder="..."` on `<input>` - shown when [`text`] is empty.
731 pub placeholder: Option<String>,
732 /// `drop="true"` - entity gains a `DropTarget` marker so OS file
733 /// drags landing on its bounds fire `on_file_dropped(id, path)`.
734 pub drop_target: bool,
735 /// `drag="true"` on `<title-bar>` - entity gains a
736 /// `TitleBarDraggable` marker. Pressing the bar requests a native
737 /// window drag via the platform backend. Only meaningful in
738 /// conjunction with `<root frameless="true">`.
739 pub title_bar_drag: bool,
740 /// `layout-boundary="true"` - entity gains a `RelayoutBoundary`
741 /// marker so layout-dirty propagation stops here. Auto-set for
742 /// `<scroll>` containers and entities with both fixed width and
743 /// height; the explicit attribute lets authors override.
744 pub layout_boundary: bool,
745 /// Tooltip spec carried by the single child of a `<tooltip
746 /// text="..." delay="...">` element after the parser flattens the
747 /// wrapper away. `None` = no tooltip. Spawns a `TooltipSource`
748 /// component at spawn time.
749 pub tooltip: Option<TooltipSpec>,
750 /// Synthetic tab-strip-button attachment generated by the
751 /// `<tabs>` parser pass: `(signal_name, tab_value)`. When set,
752 /// spawn attaches a `TabStripButton` component so clicking the
753 /// button writes `signal_name = tab_value`.
754 pub tab_strip: Option<(String, String)>,
755 /// Synthetic "seed this signal on first spawn if absent"
756 /// directive. `(signal_name, default_value)`. The `<tabs>` pass
757 /// authors it for the default-active tab, and the `<dropdown>`
758 /// pass for the open-panel flag plus (on the generated header
759 /// button) the first option's value.
760 pub signal_seed: Option<(String, String)>,
761 /// `src="path/to/file.png"` on `<image>` - kicks off an async
762 /// decode via `lumen-assets`. The entity gets a `LoadedImage`
763 /// once the bytes are ready.
764 pub src: Option<String>,
765 /// `alt="a cat asleep on a keyboard"` on `<image>` - what the image
766 /// shows, for a reader who is not looking at it. The web target writes
767 /// it out as the `alt` attribute; nothing on the desktop reads it yet.
768 /// An `alt=""` an author wrote deliberately is kept as an empty string,
769 /// which is how a decorative image is marked. A `translatable="<key>"`
770 /// on the same element resolves it through `<key>.alt`.
771 pub alt: Option<String>,
772 /// `href="settings"` on `<a>` - the target page path for file-based
773 /// navigation. The spawner attaches an `Anchor` component so a click
774 /// on the element navigates the active page. Resolved by longest
775 /// existing `.lmn` prefix at navigation time, never here.
776 pub href: Option<String>,
777 /// `font-size="N"` - pixel size for rendered text. Inherited
778 /// implicitly via the `TextStyle` component; absent = 16px.
779 pub font_size: Option<f32>,
780 /// CSS `font-family` - the raw family list as authored (commas
781 /// preserved, quotes stripped per-name at spawn). Inherited like
782 /// `font-size`. The shaper resolves the first available family in
783 /// the list against the system font database, honouring the CSS
784 /// generic keywords (`sans-serif`, `serif`, `monospace`, ...); an
785 /// unresolvable list falls back to the platform sans-serif.
786 pub font_family: Option<String>,
787 /// CSS `font-weight` - `100..=1000`, `normal` (400), `bold` (700).
788 /// Inherited. `None` = 400.
789 pub font_weight: Option<u16>,
790 /// `style="display-lg"` - Material 3-style typography role. The
791 /// parser resolves the role to a concrete pixel size + sets
792 /// [`font_size`] if it isn't already explicit.
793 pub style_role: Option<String>,
794 /// `gap="12"` - spacing inserted between every adjacent pair of
795 /// children inside a flex container. Mirrors CSS `gap` (px).
796 pub gap: Option<f32>,
797 /// `row-gap="12"` - per-axis row gap (W5.9).
798 pub gap_row: Option<f32>,
799 /// `column-gap="12"` - per-axis column gap (W5.9).
800 pub gap_column: Option<f32>,
801 /// CSS `gap: 5%` - percent gap on both axes. Wins over [`Self::gap`]
802 /// for the axis; resolves against the container's content box.
803 pub gap_pct: Option<f32>,
804 /// CSS `row-gap: 5%`.
805 pub gap_row_pct: Option<f32>,
806 /// CSS `column-gap: 5%`.
807 pub gap_column_pct: Option<f32>,
808 /// `display="grid|flex|none"` (W5.9). When `None` the spawner
809 /// defaults to `Display::Flex`.
810 pub display: Option<DisplaySpec>,
811 /// Parsed `grid-template-rows` + `grid-template-columns` (W5.9).
812 pub grid_template: Option<GridTemplateSpec>,
813 /// `grid-row="<start> <end>"` (W5.9). `(0, 0)` = auto-place.
814 pub grid_row: Option<(i16, i16)>,
815 /// `grid-column="<start> <end>"` (W5.9).
816 pub grid_column: Option<(i16, i16)>,
817 /// `align-self="..."` (W5.9 - per-item override of container align).
818 pub align_self: Option<FlexAlign>,
819 /// `justify-items="..."` (W5.9 - grid-only inline-axis alignment).
820 pub justify_items: Option<FlexAlign>,
821 /// `justify-self="..."` (W5.9 - per-item override of justify-items).
822 pub justify_self: Option<FlexAlign>,
823 /// `grow="1"` - flex-grow factor. Mirrors CSS `flex-grow`. Default
824 /// 0 (no grow). `<spacer />` sets this to 1 implicitly so it
825 /// pushes neighbouring elements to opposite edges.
826 pub grow: Option<f32>,
827 /// `align="center|start|end|stretch"` - cross-axis alignment of children
828 /// (CSS `align-items`).
829 pub align: Option<FlexAlign>,
830 /// `justify="center|start|end|between|around|evenly"` - main-axis
831 /// distribution of children (CSS `justify-content`).
832 pub justify: Option<FlexJustify>,
833 /// `text-align="start|center|end"` - horizontal alignment of rendered
834 /// text inside the element's content rectangle.
835 pub text_align: Option<TextAlignSpec>,
836 /// `press-bg="#color"` - color shown while the entity is pressed.
837 pub press_bg: Option<Rgba>,
838 /// `:hover { text-color }` - text color swapped in while hovered.
839 pub hover_text_color: Option<Rgba>,
840 /// `:active { text-color }` - text color while pressed.
841 pub active_text_color: Option<Rgba>,
842 /// `:focus { text-color }` - text color while focused.
843 pub focus_text_color: Option<Rgba>,
844 /// `:disabled { text-color }` - text color while disabled (applied
845 /// at spawn alongside the `Disabled` marker).
846 pub disabled_text_color: Option<Rgba>,
847 /// `:hover { opacity }`.
848 pub hover_opacity: Option<f32>,
849 /// `:active { opacity }`.
850 pub active_opacity: Option<f32>,
851 /// `:focus { opacity }`.
852 pub focus_opacity: Option<f32>,
853 /// `:disabled { opacity }` - e.g. adwaita's documented 50 %-opacity
854 /// disabled controls.
855 pub disabled_opacity: Option<f32>,
856 /// `:hover { box-shadow }` - shadow stack swapped in while hovered.
857 pub hover_shadows: Option<Vec<ShadowSpec>>,
858 /// `:active { box-shadow }`.
859 pub active_shadows: Option<Vec<ShadowSpec>>,
860 /// `:focus { box-shadow }` - e.g. the WinUI TextBox focus underline.
861 pub focus_shadows: Option<Vec<ShadowSpec>>,
862 /// `:disabled { box-shadow }` (applied at spawn).
863 pub disabled_shadows: Option<Vec<ShadowSpec>>,
864 /// `:focus-visible { text-color }` - keyboard-only focus text swap.
865 pub focus_visible_text_color: Option<Rgba>,
866 /// `:focus-visible { opacity }`.
867 pub focus_visible_opacity: Option<f32>,
868 /// `:focus-visible { box-shadow }` - e.g. the inner ring of the
869 /// Windows keyboard-only double focus ring.
870 pub focus_visible_shadows: Option<Vec<ShadowSpec>>,
871 /// `:drag-over { bg }` - fill swapped in while an acceptable in-app
872 /// drag hovers this drop target (HTML5 `dragover` parity). Gated by
873 /// the runtime `DropHovered` marker via `StateVisuals::drag_over`.
874 pub drag_over_bg: Option<Rgba>,
875 /// `:drag-over { text-color }`.
876 pub drag_over_text_color: Option<Rgba>,
877 /// `:drag-over { opacity }`.
878 pub drag_over_opacity: Option<f32>,
879 /// `:drag-over { box-shadow }` - e.g. an inset ring lighting up the
880 /// hovered drop zone.
881 pub drag_over_shadows: Option<Vec<ShadowSpec>>,
882 /// `focus-outline="<width> <#color>"` - stroke ring rendered while
883 /// the entity has focus.
884 pub focus_outline: Option<OutlineSpec>,
885 /// `:focus-visible { outline: ... }` - stroke ring rendered only when
886 /// focus arrived via the keyboard (Tab / Shift-Tab), mirroring the
887 /// CSS `:focus-visible` heuristic. Pointer-driven focus does not
888 /// paint it. When both this and [`Self::focus_outline`] are set the
889 /// keyboard-only ring wins while the `FocusVisible` marker is
890 /// present.
891 pub focus_visible_outline: Option<OutlineSpec>,
892 /// CSS `outline-offset` in px - gap between the border box edge and
893 /// the focus outline. Folded into the outline specs at spawn.
894 pub outline_offset: Option<f32>,
895 /// `knob-color="#color"` / CSS `knob-color:` - fill of the toggle
896 /// knob / slider thumb child. Lumen-native analog property (real
897 /// CSS has no reachable pseudo-element for it in our subset);
898 /// absent = the runtime `KNOB_FILL` fallback.
899 pub knob_color: Option<Rgba>,
900 /// CSS `border-width` (and the width part of the `border`
901 /// shorthand): per-side widths in px. Participates in layout per the
902 /// CSS box model and paints when [`Self::border_style`] is `solid`.
903 pub border_width: Option<Edges>,
904 /// CSS `border-color` (the uniform base color for all four sides).
905 pub border_color: Option<Rgba>,
906 /// CSS `border-top-color` - per-side override of [`Self::border_color`].
907 pub border_color_top: Option<Rgba>,
908 /// CSS `border-right-color`.
909 pub border_color_right: Option<Rgba>,
910 /// CSS `border-bottom-color` - the Windows elevation bottom edge.
911 pub border_color_bottom: Option<Rgba>,
912 /// CSS `border-left-color`.
913 pub border_color_left: Option<Rgba>,
914 /// CSS `border-style` (`none` | `solid`). Per CSS, no style => no
915 /// border: the computed border-width is zero and nothing paints.
916 /// The `border:` shorthand always sets this explicitly.
917 pub border_style: Option<BorderStyleSpec>,
918 /// CSS `box-sizing`. `None` = the Lumen UA default (`border-box`).
919 pub box_sizing: Option<BoxSizingSpec>,
920 /// `:hover { border: ... }` / `hover-border:` - border swapped in
921 /// while the pointer hovers the entity.
922 pub hover_border: Option<BorderPaintSpec>,
923 /// `:focus { border: ... }` / `focus-border:` - border swapped in
924 /// while the entity has keyboard focus. Wins over
925 /// [`Self::hover_border`] when both states are active.
926 pub focus_border: Option<BorderPaintSpec>,
927 /// CSS `flex-shrink`. `None` = the CSS initial value `1`.
928 pub shrink: Option<f32>,
929 /// CSS `flex-basis`. `None` = `auto`.
930 pub basis: Option<LengthSpec>,
931 /// CSS `flex-wrap`. `None` = `nowrap`.
932 pub flex_wrap: Option<FlexWrapSpec>,
933 /// CSS `align-content` - cross-axis distribution of wrapped flex
934 /// lines / grid tracks.
935 pub align_content: Option<AlignContentSpec>,
936 /// CSS `z-index` - sibling paint-order override. `None` = `auto`.
937 pub z_index: Option<i32>,
938 /// `checked="true|false"` on `<toggle>` - initial state.
939 pub checked: Option<bool>,
940 /// `autofocus="true"` - when the containing `<dialog>` opens, this
941 /// element receives initial focus (HTML `autofocus` / Qt
942 /// `setFocus` on show).
943 pub autofocus: bool,
944 /// `<button default="true">` - the dialog's DEFAULT button: Enter
945 /// anywhere in the dialog (except on another button / an
946 /// Enter-consuming input) activates it, and closing through it
947 /// fires the `accepted` (not `rejected`) hook. Also appends the
948 /// `default` class so skins can style `button.default`.
949 pub default_button: bool,
950 /// `<checkbox indeterminate="true">` - tri-state dash until the
951 /// first user toggle clears it (web `indeterminate` IDL attr / Qt
952 /// `PartiallyChecked`).
953 pub indeterminate: bool,
954 /// `<radio group="...">` - the PropertyStore global holding the
955 /// radio group's selected value.
956 pub radio_group: Option<String>,
957 /// `<radio value="...">` - this member's (string) value. The shared
958 /// `value` attribute parses as f32 for sliders/progress; radios
959 /// route it here instead.
960 pub radio_value: Option<String>,
961 /// `<progress duration="...">` / CSS `progress-duration` - the
962 /// indeterminate sweep period in ms (skin token
963 /// `--lumen-progress-period`).
964 pub progress_duration: Option<u32>,
965 /// Synthetic widget-part marker set by the parser desugars so the
966 /// spawn layer can attach the matching runtime marker component to
967 /// a child element (`.checkbox-box` / `.radio-dot` /
968 /// `.progress-fill`).
969 pub part: Option<WidgetPart>,
970 /// The authored widget this element came out of, for the desugars that
971 /// replace a widget tag with plain boxes ([`WidgetRole`]). A surface that
972 /// can express the widget directly reads this and writes the widget back;
973 /// the runtime spawner ignores it, because the boxes beside it are already
974 /// the widget it would build.
975 pub widget: Option<WidgetRole>,
976 /// `:checked { bg: ... }` CSS - track fill shown while a `<toggle>`
977 /// is checked. Absent = the built-in accent fill.
978 pub checked_bg: Option<Rgba>,
979 /// `:selected { bg: ... }` CSS - fill shown on a `<tabs>` strip button
980 /// while it carries the [`lumen_core::components::Selected`] marker.
981 /// Absent = the built-in accent fill (same default-skin fallback
982 /// pattern as [`Self::checked_bg`]).
983 pub selected_bg: Option<Rgba>,
984 /// `disabled="true"` - entity spawns with the `Disabled` marker;
985 /// input routing skips it and the default render dims it.
986 pub disabled: bool,
987 /// `:disabled { bg: ... }` CSS - fill shown while the entity is
988 /// disabled. Absent = reduced-opacity render of the normal fill.
989 pub disabled_bg: Option<Rgba>,
990 /// `value="0.5"` on `<slider>` - initial slider value.
991 pub value: Option<f32>,
992 /// `min="0"` on `<slider>` - lower bound.
993 pub min: Option<f32>,
994 /// `max="1"` on `<slider>` - upper bound.
995 pub max: Option<f32>,
996 /// `step="0.1"` on `<slider>` - keyboard / wheel increment.
997 /// Absent = `(max - min) / 100`.
998 pub step: Option<f32>,
999 /// `bind="text:signal_name"` - declarative reactive binding to a named `Signals` entry. Only the `text:` prefix is recognised.
1000 pub bind: Option<BindSpec>,
1001 /// `bind-disabled="signal"` - one-way binding driving the `Disabled`
1002 /// marker from a boolean signal so scripts can enable / disable the
1003 /// widget live. Stored apart from [`Self::bind`] so it can coexist
1004 /// with `bind-checked` / `bind-value` / `bind-text` on one element.
1005 pub bind_disabled: Option<String>,
1006 /// `bind-scroll="signal"` on a scroll container - two-way binding
1007 /// between an f32 signal (logical px) and the container's vertical
1008 /// scroll offset (W6 T6). Stored apart from [`Self::bind`] (same
1009 /// coexistence rule as `bind_disabled`).
1010 pub bind_scroll: Option<String>,
1011 /// `each="rows"` on `<for>` - name of the `ArraySignals` entry to
1012 /// iterate. The element's inline children are the per-item body
1013 /// template; `{field}` placeholders inside their attrs / text get
1014 /// replaced at reconcile time with the matching record field.
1015 pub each: Option<String>,
1016 /// `key="id"` on `<for>` - record field used as the stable reconciliation key. Without it the reconciler keys rows by item index.
1017 pub key: Option<String>,
1018 /// `<for virtualized="true">` - spawn only rows in the visible scroll window. Requires a `<scroll>` ancestor for the reconciler to compute the visible band.
1019 pub virtualized: bool,
1020 /// `<for row-height="32">` - pixel height per virtualized row.
1021 /// Required when [`Self::virtualized`] is true; defaults to 32 px
1022 /// at spawn when the attr is absent.
1023 pub row_height: Option<f32>,
1024 /// `signal="loaded"` on `<if>` - name of the `Signals` entry whose
1025 /// truthiness gates the subtree. Truthy = non-empty AND not literal
1026 /// `"false"` / `"0"`. Toggle spawns / despawns body on next tick.
1027 pub if_signal: Option<String>,
1028 /// `eq="value"` on `<if>` - when set, the body mounts iff
1029 /// `Signals[if_signal] == eq`. Used by `<tabs>` to switch the
1030 /// active tab body; `None` falls back to truthiness.
1031 pub if_eq: Option<String>,
1032 /// `mode="render|hide"` on `<if>` - pick the reconciler policy.
1033 /// `render` (default) despawns/respawns the body each toggle;
1034 /// `hide` mounts once and flips a `Visible` flag, preserving focus
1035 /// / scroll / per-row signals across show-hide cycles.
1036 pub if_mode: IfModeSpec,
1037 /// `wrap="none|word|glyph"` - text wrap policy. Default is none
1038 /// (overflow clips horizontally).
1039 pub text_wrap: Option<TextWrapSpec>,
1040 /// `max-lines="N"` - hard cap on rendered line count for wrapped text.
1041 pub max_lines: Option<u32>,
1042 /// CSS `text-overflow: ellipsis | clip` (or `wrap="ellipsis"` in
1043 /// markup). `Ellipsis` elides overflowing single-line text with a
1044 /// trailing `...`; the spawn layer lowers it onto the runtime
1045 /// `TextStyle` as glyph-wrap + `max_lines = 1` unless the author
1046 /// supplied an explicit wrap / max-lines pair (multi-line clamp,
1047 /// which the shaper already ellipsizes). Not inherited (CSS
1048 /// `text-overflow` doesn't inherit either).
1049 pub text_overflow: Option<TextOverflowSpec>,
1050 /// `position="relative|absolute"` - CSS-style positioning mode.
1051 pub position: Option<PositionSpec>,
1052 /// `inset="t r b l"` (or 1/2/3-value shorthand) - offsets from each
1053 /// edge for `position="absolute"`.
1054 pub inset: Option<Edges>,
1055 /// `min-width="..."`.
1056 pub min_width: Option<LengthSpec>,
1057 /// `min-height="..."`.
1058 pub min_height: Option<LengthSpec>,
1059 /// `max-width="..."`.
1060 pub max_width: Option<LengthSpec>,
1061 /// `max-height="..."`.
1062 pub max_height: Option<LengthSpec>,
1063 /// `aspect-ratio="1.5"` - width / height constraint.
1064 pub aspect_ratio: Option<f32>,
1065 /// `overflow="visible|hidden|scroll"` - sets both axes. Use
1066 /// `overflow-x` / `overflow-y` to set per axis.
1067 pub overflow: Option<OverflowSpec>,
1068 /// `overflow-x`.
1069 pub overflow_x: Option<OverflowSpec>,
1070 /// `overflow-y`.
1071 pub overflow_y: Option<OverflowSpec>,
1072 /// `fit="fill|cover|contain|none|scale-down"` on `<image>` -
1073 /// CSS `object-fit`.
1074 pub image_fit: Option<ImageFitSpec>,
1075 /// `bg-fit` - how a `bg: url(...)` image fills the element's box. Takes
1076 /// the [`Self::image_fit`] values; `None` means cover.
1077 pub bg_fit: Option<ImageFitSpec>,
1078 /// CSS `scrollbar-color: <thumb> [<track>]` (Scrollbars Styling
1079 /// Level 1) - overlay-bar thumb + optional track fills for `<scroll>`
1080 /// containers. `None` = the runtime's `ScrollbarStyle` default.
1081 pub scrollbar_color: Option<(Rgba, Option<Rgba>)>,
1082 /// CSS `scrollbar-width: auto | thin | none` (Scrollbars Styling
1083 /// Level 1).
1084 pub scrollbar_width: Option<ScrollbarWidthSpec>,
1085 /// `shadow="<x> <y> <blur> <#color>"` - drop shadow.
1086 pub shadows: Vec<ShadowSpec>,
1087 /// `opacity="0.5"` - alpha multiplier in `[0, 1]`. Absent = 1.0.
1088 pub opacity: Option<f32>,
1089 /// CSS `transition: <property> <duration> [<easing>] [, ...]` declarations. Each entry tweens the property instead of snapping when the value changes.
1090 /// Animatable in v1: `opacity`, `background-color`/`bg`,
1091 /// `color`/`text-color`, `border-color`. Layout properties (width,
1092 /// height, ...) are rejected with a warn - see [`TransitionPropertyIr`].
1093 pub transitions: Vec<TransitionIr>,
1094 /// CSS `transition-property` longhand - comma list of property
1095 /// names. Combined with the duration / timing longhands by
1096 /// [`Attributes::effective_transitions`].
1097 pub transition_property: Option<Vec<TransitionPropertyIr>>,
1098 /// CSS `transition-duration` longhand - comma list of durations in
1099 /// ms, cycled over the property list per the CSS repeat rule.
1100 pub transition_duration: Option<Vec<u32>>,
1101 /// CSS `transition-timing-function` longhand - comma list of easing
1102 /// curves, cycled over the property list.
1103 pub transition_timing: Option<Vec<EasingIr>>,
1104 /// `required="true"` - form-field validity gate.
1105 pub required: bool,
1106 /// `pattern="<substring>"` - content must contain this literal
1107 /// substring to be valid. Values prefixed `shape:` are reserved for
1108 /// the built-in structural checks the parser attaches to
1109 /// `<date-picker>` (`shape:date`) and `<time-picker>`
1110 /// (`shape:time`); see `lumen_primitives::validation`.
1111 pub pattern: Option<String>,
1112 /// `multiline="true"` - text input accepts newlines. `<textarea>` defaults to true; `<input>` defaults to false.
1113 pub multiline: Option<bool>,
1114 /// Synthesised by the `<dropdown>` parser pass on the header
1115 /// button. The runtime attaches a [`lumen_primitives::DropdownButton`]
1116 /// whose click flips the open signal and whose option metadata
1117 /// drives *closed*-combobox keyboard interaction (Up/Down value
1118 /// stepping, Alt+Down open, type-ahead) before the lazily-mounted
1119 /// panel body exists.
1120 pub dropdown_button: Option<DropdownButtonSpec>,
1121 /// Synthesised per `<option>` inside `<dropdown>`: `(value_signal, value, open_signal)`. The runtime attaches a [`lumen_primitives::DropdownOptionButton`] whose click writes the value and closes the panel.
1122 pub dropdown_option: Option<(String, String, String)>,
1123 /// Synthesised per `<menuitem>` inside `<menu>`: `(open_signal, item_id)`. The runtime attaches a [`lumen_primitives::MenuItemButton`] whose click emits `MenuClicked { id: item_id }` and closes the menu.
1124 pub menu_item: Option<(String, String)>,
1125 /// Synthesised on the floating panel of a `<dropdown>` / `<menu>`:
1126 /// the open-state signal (`__dropdown_open:*` / `__menu_open:*`).
1127 /// The runtime attaches a [`lumen_primitives::PopupPanel`] so the
1128 /// outside-click dismissal and viewport edge-flip systems can find
1129 /// the panel and its bound open signal.
1130 pub popup_panel: Option<String>,
1131 /// `dir="ltr|rtl|auto"` - CSS Logical Properties writing direction
1132 /// (W5.4). When set, the spawn layer installs a
1133 /// [`lumen_core::components::LayoutDirection`] component; the
1134 /// `resolve_layout_direction` system then stamps a
1135 /// [`lumen_core::components::ResolvedDirection`] on every descendant.
1136 ///
1137 /// Serialized through [`layout_direction_serde`] because
1138 /// [`lumen_core::components::LayoutDirection`] lives in a crate that
1139 /// does not depend on serde; the proxy maps the three variants onto a
1140 /// stable `Option<u8>` so the AOT artifact stays parser-independent.
1141 #[serde(with = "layout_direction_serde")]
1142 pub dir: Option<lumen_core::components::LayoutDirection>,
1143 /// `translatable="<key>"` - names the catalogue message every string
1144 /// this element shows comes from. The text is the message value,
1145 /// falling back to the authored `text` and then to the key itself;
1146 /// [`Self::placeholder`] and [`Self::alt`] come from the Fluent
1147 /// attributes `<key>.placeholder` and `<key>.alt`, and only when the
1148 /// markup authored them. The key stops standing in for a missing text
1149 /// once the element names one of those, so an `<input>` marked for its
1150 /// placeholder does not spawn with the key as its value.
1151 /// [`crate::translate::translate`] is the rule, shared by the
1152 /// spawn layer and the web emitter; `lumenc i18n extract` collects the
1153 /// keys into `locale/<lang>.ftl`.
1154 pub translatable: Option<String>,
1155 /// `format="<spec>"` - formats the element's text for the app's
1156 /// locale. The spec is one of `number`, `currency:<code>`, `date`,
1157 /// `time`, `datetime`, `relative`; the text it applies to is
1158 /// whatever the element ended up with, whether that is the authored
1159 /// `text`, a translated string, or the value a `bind-text` signal
1160 /// holds. A spec the formatter does not know, or text it cannot
1161 /// read as the spec's kind, leaves the text alone.
1162 pub format: Option<String>,
1163 /// `lang="<bcp47>"` - BCP-47 language tag (W5.4). When set, the
1164 /// spawn layer installs a [`lumen_core::components::Lang`] component
1165 /// consumed by text shaping (cosmic-text) and AccessKit. Inherited
1166 /// at runtime from the nearest ancestor when absent. `format` reads
1167 /// the app's locale, not this tag.
1168 pub lang: Option<String>,
1169 /// `bind-text="$self.<field>"` - per-entity text binding. The
1170 /// field name (without the `$self.` prefix) lands here; the
1171 /// spawn layer installs a [`lumen_core::components::BindSelfText`]
1172 /// marker. Mutually exclusive with [`Self::bind`] for the same
1173 /// kind - the dollar-prefixed forms desugar to this field instead
1174 /// of the named-signal [`BindSpec`]. (W-signal-design step 1.)
1175 pub bind_self_text: Option<String>,
1176 /// `bind-value="$self.<field>"` - per-entity slider-value binding.
1177 /// See [`Self::bind_self_text`].
1178 pub bind_self_value: Option<String>,
1179 /// `bind-checked="$self.<field>"` - per-entity toggle binding.
1180 /// See [`Self::bind_self_text`].
1181 pub bind_self_checked: Option<String>,
1182 /// `bind-text="$parent.<field>"` - parent-entity text binding.
1183 /// The field name (without the `$parent.` prefix) lands here; the
1184 /// spawn layer installs a [`lumen_core::components::BindParentText`]
1185 /// marker. See [`Self::bind_self_text`].
1186 pub bind_parent_text: Option<String>,
1187 /// `bind-value="$parent.<field>"` - parent-entity slider-value binding.
1188 /// See [`Self::bind_parent_text`].
1189 pub bind_parent_value: Option<String>,
1190 /// `bind-checked="$parent.<field>"` - parent-entity toggle binding.
1191 /// See [`Self::bind_parent_text`].
1192 pub bind_parent_checked: Option<String>,
1193 /// `knob-inset="2"` / CSS `knob-inset` - gap in px between the knob
1194 /// child's edge and its track parent's edge on `<toggle>` /
1195 /// `<switch>`. Lumen-native analog property (mirrors [`Self::knob_color`]
1196 /// in having no real CSS pseudo-element target); absent = the
1197 /// runtime's own inset constant.
1198 pub knob_inset: Option<f32>,
1199 /// `thumb-size="20"` / CSS `thumb-size` - diameter in px of the
1200 /// `<slider>` thumb. Lumen-native analog property; absent = the
1201 /// runtime's own thumb-size constant.
1202 pub thumb_size: Option<f32>,
1203 /// `popup-gap="4"` / CSS `popup-gap` - offset in px between a
1204 /// `<dropdown>` / `<menu>` trigger and its floating panel. Absent =
1205 /// the runtime's own gap constant.
1206 pub popup_gap: Option<f32>,
1207 /// CSS `progress-chunk` - fraction of the track width (`0.0` to
1208 /// `1.0`) covered by the moving chunk of an indeterminate
1209 /// `<progress>` sweep. Absent = the runtime's own chunk-width
1210 /// constant.
1211 pub progress_chunk: Option<f32>,
1212 /// CSS `disabled-opacity` - alpha multiplier `[0, 1]` applied to a
1213 /// disabled entity when neither [`Self::disabled_bg`] nor an
1214 /// explicit `:disabled { opacity }` ([`Self::disabled_opacity`]) was
1215 /// authored. Distinct from `disabled_opacity`: that field is the
1216 /// per-element state-pseudo override, this field is the CSS-authored
1217 /// replacement for the runtime's own generic dimming fallback.
1218 pub disabled_opacity_default: Option<f32>,
1219 /// `caret-width="2"` / CSS `caret-width` - stroke width in px of the
1220 /// text-input caret. Absent = the runtime's own caret-width constant.
1221 /// Only meaningful on `<input>` / `<textarea>`.
1222 pub caret_width: Option<f32>,
1223 /// CSS `caret-blink` - full on/off blink period of the text-input
1224 /// caret, in milliseconds. Accepts `Nms` or `Ns`. Absent = the
1225 /// runtime's own blink-period constant. Only meaningful on
1226 /// `<input>` / `<textarea>`.
1227 pub caret_blink_ms: Option<u32>,
1228 /// CSS `password-character` - the glyph substituted for every
1229 /// character of a masked (`type="password"`-equivalent) text input.
1230 /// A single Unicode scalar value; absent = the runtime's own default
1231 /// mask glyph (typically `*` or a bullet).
1232 pub password_character: Option<char>,
1233 /// CSS `line-height` - see [`LineHeightSpec`] for why the unitless
1234 /// and px forms are kept distinct rather than folded into one number.
1235 /// Inherited like [`Self::font_size`]. Absent = the runtime's own
1236 /// line-height ratio.
1237 pub line_height: Option<LineHeightSpec>,
1238 /// CSS `scrollbar-thickness` - width in px of the overlay scrollbar
1239 /// track/thumb on `<scroll>` containers at `scrollbar-width: auto`.
1240 /// Absent = the runtime's own thickness constant.
1241 pub scrollbar_thickness: Option<f32>,
1242 /// CSS `scrollbar-thickness-thin` - width in px of the overlay
1243 /// scrollbar at `scrollbar-width: thin`. Absent = the runtime's own
1244 /// thin-thickness constant.
1245 pub scrollbar_thickness_thin: Option<f32>,
1246 /// CSS `scrollbar-margin` - gap in px between the scrollbar and the
1247 /// container's content edge. Absent = the runtime's own margin
1248 /// constant.
1249 pub scrollbar_margin: Option<f32>,
1250 /// CSS `scrollbar-min-thumb` - minimum thumb length in px, so a very
1251 /// long scrollable area still gets a grabbable thumb. Absent = the
1252 /// runtime's own minimum constant.
1253 pub scrollbar_min_thumb: Option<f32>,
1254 /// CSS `scrollbar-track-hover` - track fill shown while the pointer
1255 /// hovers the scrollbar. Lumen-native named property (mirrors
1256 /// [`Self::hover_bg`] in being a distinct property rather than a
1257 /// `:hover { ... }` pseudo rule, since the hover target is the
1258 /// scrollbar part, not the whole element). Absent = no track tint on
1259 /// hover.
1260 pub scrollbar_track_hover: Option<Rgba>,
1261 /// CSS `scrollbar-hover-boost` - multiplier applied to the thumb
1262 /// fill's brightness while hovered, paired with
1263 /// [`Self::scrollbar_track_hover`]. Absent = the runtime's own boost
1264 /// constant.
1265 pub scrollbar_hover_boost: Option<f32>,
1266 /// CSS `scrollbar-fade-delay` - idle time before an overlay
1267 /// scrollbar starts fading out, in milliseconds. Accepts `Nms` or
1268 /// `Ns`. Absent = the runtime's own delay constant.
1269 pub scrollbar_fade_delay_ms: Option<u32>,
1270 /// CSS `scrollbar-fade-duration` - length of the fade-out animation
1271 /// itself, in milliseconds. Accepts `Nms` or `Ns`. Absent = the
1272 /// runtime's own duration constant.
1273 pub scrollbar_fade_duration_ms: Option<u32>,
1274 /// Every styling attribute the element was given in markup, as
1275 /// `(property, value)` in the order they were written. The property is
1276 /// the one spelling the cascade files it under
1277 /// ([`crate::css::canonical_style_property`]); the value is the text the
1278 /// author wrote, unparsed.
1279 ///
1280 /// The fields above hold the *result* of the cascade and say nothing
1281 /// about where a value came from, so this is the record of which surface
1282 /// set what. It exists because the two surfaces do not rank the same way
1283 /// everywhere: in Lumen a styling attribute outranks any rule that
1284 /// targets the element, while on the web a rule outranks nothing but an
1285 /// inline declaration. A target with the web's ranking replays these as
1286 /// inline declarations so the author's markup keeps winning.
1287 pub markup_styles: Vec<(String, String)>,
1288 /// `name` on a `<slot>` inside a fragment body: which of the use site's
1289 /// children lands here. Absent on a `<slot>` names the default slot
1290 /// ([`crate::fragment::DEFAULT_SLOT`]), and absent on anything else
1291 /// means the element is not a slot at all.
1292 pub slot_name: Option<String>,
1293 /// Attributes whose value carries a fragment parameter, held back until
1294 /// a use site supplies the arguments.
1295 ///
1296 /// Most attribute values parse into typed layout data, which a
1297 /// `{parameter}` marker is not, so a fragment body keeps the authored
1298 /// text here instead and the compiler applies it once per instantiation.
1299 /// Empty on every element outside a fragment body, and emptied on the
1300 /// copy that reaches the tree.
1301 pub deferred: Vec<DeferredAttr>,
1302 /// Every attribute an element with a registered custom tag was written
1303 /// with, as `(name, value)` in the order they were written; `None` on an
1304 /// element whose tag is built in.
1305 ///
1306 /// The markup vocabulary cannot say what a custom widget's attributes
1307 /// mean, so the parser keeps them as authored and the spawner hands them
1308 /// to the widget registered for the tag. `Some` with an empty list is a
1309 /// custom element written without attributes, which still gets its
1310 /// widget.
1311 pub widget_attributes: Option<Vec<(String, String)>>,
1312}
1313
1314/// One attribute of a fragment body element, kept as authored.
1315///
1316/// See [`Attributes::deferred`]. The position is the value's own place in
1317/// the file that declared the fragment, so a diagnostic raised while
1318/// instantiating still points at where the value was written.
1319#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1320pub struct DeferredAttr {
1321 /// Attribute name as authored.
1322 pub name: String,
1323 /// Attribute value as authored, parameter markers included.
1324 pub value: String,
1325 /// 1-based line of the value in the declaring file.
1326 pub line: usize,
1327 /// 1-based column of the value in the declaring file.
1328 pub col: usize,
1329}
1330
1331impl Attributes {
1332 /// Resolve the accumulated `border-*` longhands into the effective
1333 /// CSS border, following the real cascade rules:
1334 ///
1335 /// * no `border-style` (or `border-style: none`) => no border - the
1336 /// computed width of a styleless side is `0` (CSS Backgrounds &
1337 /// Borders section 3.2), so `border-width`/`border-color` alone paint
1338 /// nothing and consume no space;
1339 /// * `border-style: solid` => widths default to `3px` (CSS `medium`)
1340 /// when `border-width` is absent, and the color falls back to
1341 /// `currentColor` - approximated by the element's `text-color`
1342 /// when authored, else opaque black (the CSS initial `color`).
1343 ///
1344 /// Returns `(widths, color)` for the solid case; `None` otherwise.
1345 /// The color is the uniform base - per-side overrides
1346 /// (`border-top-color` ...) are resolved by
1347 /// [`Self::effective_border_colors`].
1348 pub fn effective_border(&self) -> Option<(Edges, Rgba)> {
1349 match self.border_style {
1350 Some(BorderStyleSpec::Solid) => {
1351 let widths = self.border_width.unwrap_or(Edges::all(3.0));
1352 let color = self.border_color.or(self.text_color).unwrap_or(Rgba {
1353 r: 0.0,
1354 g: 0.0,
1355 b: 0.0,
1356 a: 1.0,
1357 });
1358 Some((widths, color))
1359 }
1360 Some(BorderStyleSpec::None) | None => None,
1361 }
1362 }
1363
1364 /// Per-side border colors `[top, right, bottom, left]`: each side
1365 /// takes its longhand (`border-top-color` ...) when authored, else
1366 /// the uniform `base`. Returns `None` when no side differs from the
1367 /// base, so the uniform fast path stays intact downstream.
1368 pub fn effective_border_colors(&self, base: Rgba) -> Option<[Rgba; 4]> {
1369 if self.border_color_top.is_none()
1370 && self.border_color_right.is_none()
1371 && self.border_color_bottom.is_none()
1372 && self.border_color_left.is_none()
1373 {
1374 return None;
1375 }
1376 Some([
1377 self.border_color_top.unwrap_or(base),
1378 self.border_color_right.unwrap_or(base),
1379 self.border_color_bottom.unwrap_or(base),
1380 self.border_color_left.unwrap_or(base),
1381 ])
1382 }
1383
1384 /// Resolve the transition shorthand + longhands into the effective
1385 /// per-property list.
1386 ///
1387 /// - The `transition:` shorthand, when authored, wins outright (it
1388 /// resets the longhands per CSS shorthand semantics).
1389 /// - Otherwise `transition-property` defines the entry list, with
1390 /// `transition-duration` / `transition-timing-function` values
1391 /// cycled over it (the CSS list-repeat rule). A duration list
1392 /// without `transition-property` produces nothing: there is no
1393 /// default property list, so name the properties or write `all`.
1394 pub fn effective_transitions(&self) -> Vec<TransitionIr> {
1395 if !self.transitions.is_empty() {
1396 return self.transitions.clone();
1397 }
1398 let Some(props) = &self.transition_property else {
1399 return Vec::new();
1400 };
1401 // An *empty* list (reachable via `transition-duration: ,`) must be
1402 // treated like an absent one - otherwise `durations[0]` below indexes
1403 // an empty slice and panics.
1404 let durations: &[u32] = match self.transition_duration.as_deref() {
1405 Some(d) if !d.is_empty() => d,
1406 _ => &[0],
1407 };
1408 // CSS `ease`, the initial `transition-timing-function`, same
1409 // default the `transition` shorthand applies.
1410 let default_timing = [EasingIr::CubicBezier(0.25, 0.1, 0.25, 1.0)];
1411 let timings: &[EasingIr] = match self.transition_timing.as_deref() {
1412 Some(t) if !t.is_empty() => t,
1413 _ => &default_timing,
1414 };
1415 props
1416 .iter()
1417 .enumerate()
1418 .map(|(i, p)| TransitionIr {
1419 property: *p,
1420 duration_ms: durations[i % durations.len().max(1)],
1421 easing: timings[i % timings.len().max(1)],
1422 })
1423 .collect()
1424 }
1425}
1426
1427/// IR-side parsed `transition` shorthand entry. `From<&TransitionIr> for
1428/// lumen_primitives::TransitionSpec` converts to the runtime form at
1429/// spawn time.
1430#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
1431pub struct TransitionIr {
1432 /// Property name as authored (`"opacity"` ...). The spawn layer maps
1433 /// to `lumen_primitives::TransitionProperty`.
1434 pub property: TransitionPropertyIr,
1435 /// Duration in milliseconds.
1436 pub duration_ms: u32,
1437 /// Easing curve as authored.
1438 pub easing: EasingIr,
1439}
1440
1441/// IR-side mirror of `lumen_primitives::TransitionProperty`. Kept local
1442/// to layout_ir so the parser doesn't need to depend on primitives.
1443///
1444/// v1 animatable set: colors + opacity only - geometry-free visual
1445/// props. Layout properties (`width`, `height`, padding, margins, ...)
1446/// are deliberately not transitionable in v1: animating them would
1447/// re-run layout every frame, and the parser warns + drops them.
1448#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
1449pub enum TransitionPropertyIr {
1450 /// CSS `opacity`.
1451 Opacity,
1452 /// CSS `background-color` (accepted spellings: `background-color`,
1453 /// `background`, `bg`).
1454 BackgroundColor,
1455 /// CSS `color` (accepted spellings: `color`, `text-color`).
1456 TextColor,
1457 /// CSS `border-color`.
1458 BorderColor,
1459}
1460
1461impl TransitionPropertyIr {
1462 /// Parse a CSS property name into a transitionable property.
1463 /// Returns `None` for unknown / non-animatable names (the caller
1464 /// warns and drops per CSS "ignore unanimatable" behavior).
1465 pub fn from_css_name(name: &str) -> Option<Self> {
1466 match name {
1467 "opacity" => Some(Self::Opacity),
1468 "background-color" | "background" | "bg" => Some(Self::BackgroundColor),
1469 "color" | "text-color" => Some(Self::TextColor),
1470 "border-color" => Some(Self::BorderColor),
1471 _ => None,
1472 }
1473 }
1474}
1475
1476/// IR-side mirror of `lumen_primitives::Easing`. Cubic-bezier carries
1477/// its four control points; named curves are zero-sized.
1478#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
1479pub enum EasingIr {
1480 /// `linear`.
1481 Linear,
1482 /// `ease-in`.
1483 EaseIn,
1484 /// `ease-out`.
1485 EaseOut,
1486 /// `ease-in-out`.
1487 EaseInOut,
1488 /// `cubic-bezier(p1x, p1y, p2x, p2y)`.
1489 CubicBezier(f32, f32, f32, f32),
1490}
1491
1492/// Parsed `shadow=` / `box-shadow=` value. Authors may stack multiple
1493/// shadows via comma separation in CSS; markup accepts a single entry.
1494/// `inner` flips drop -> inset.
1495#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
1496pub struct ShadowSpec {
1497 /// Horizontal offset in px.
1498 pub offset_x: f32,
1499 /// Vertical offset in px.
1500 pub offset_y: f32,
1501 /// Gaussian blur radius (std-dev) in px.
1502 pub blur: f32,
1503 /// CSS `box-shadow` spread radius in px - grows (positive) or
1504 /// shrinks (negative) the shadow rect before blurring. Enables the
1505 /// hard double-ring focus idiom (`box-shadow: 0 0 0 2px <color>`).
1506 pub spread: f32,
1507 /// Shadow color (alpha controls softness).
1508 pub color: Rgba,
1509 /// `true` = inset (inner) shadow. Default `false`.
1510 pub inner: bool,
1511}
1512
1513/// CSS `object-fit` analogue.
1514#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1515pub enum ImageFitSpec {
1516 /// Stretch to fill.
1517 Fill,
1518 /// Aspect-preserved cover (overflow clipped).
1519 Cover,
1520 /// Aspect-preserved fit (may leave space).
1521 Contain,
1522 /// Native pixel size, top-left aligned.
1523 None,
1524 /// Smaller of None / Contain.
1525 ScaleDown,
1526}
1527
1528/// CSS-style position mode.
1529#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1530pub enum PositionSpec {
1531 /// In-flow positioning.
1532 Relative,
1533 /// Out-of-flow, offset by `inset`.
1534 Absolute,
1535}
1536
1537/// CSS-style overflow control.
1538#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1539pub enum OverflowSpec {
1540 /// Children paint outside (default).
1541 Visible,
1542 /// Children clipped at the box edge.
1543 Hidden,
1544 /// Clipped + scrollable.
1545 Scroll,
1546}
1547
1548/// CSS `scrollbar-width` keyword (Scrollbars Styling Level 1).
1549#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1550pub enum ScrollbarWidthSpec {
1551 /// Platform-default overlay thickness.
1552 Auto,
1553 /// Narrow rail.
1554 Thin,
1555 /// Bars hidden entirely (content still scrolls).
1556 None,
1557}
1558
1559impl From<ScrollbarWidthSpec> for lumen_core::input::ScrollbarWidthMode {
1560 fn from(s: ScrollbarWidthSpec) -> Self {
1561 match s {
1562 ScrollbarWidthSpec::Auto => Self::Auto,
1563 ScrollbarWidthSpec::Thin => Self::Thin,
1564 ScrollbarWidthSpec::None => Self::None,
1565 }
1566 }
1567}
1568
1569/// Text wrap policy attribute.
1570#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1571pub enum TextWrapSpec {
1572 /// No automatic wrap.
1573 None,
1574 /// Word-break wrap.
1575 Word,
1576 /// Glyph-level wrap.
1577 Glyph,
1578}
1579
1580/// Synthetic widget-part roles attached by the parser desugars - see
1581/// [`Attributes::part`].
1582#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1583pub enum WidgetPart {
1584 /// The `.checkbox-box` tile inside `<checkbox>`.
1585 CheckboxBox,
1586 /// The `.radio-dot` tile inside `<radio>`.
1587 RadioDot,
1588 /// The `.progress-fill` tile inside `<progress>`.
1589 ProgressFill,
1590}
1591
1592impl WidgetPart {
1593 /// The class the part carries, which is what a skin styles it by.
1594 ///
1595 /// The desugar writes it onto the synthesized child and the web target
1596 /// writes it onto the element the browser draws for the same part, so
1597 /// one skin rule reaches both.
1598 pub const fn class(self) -> &'static str {
1599 match self {
1600 Self::CheckboxBox => "checkbox-box",
1601 Self::RadioDot => "radio-dot",
1602 Self::ProgressFill => "progress-fill",
1603 }
1604 }
1605}
1606
1607/// A widget tag that the parser replaces with plain boxes - see
1608/// [`Attributes::widget`].
1609///
1610/// These seven tags never reach the IR under their own name: the parser
1611/// expands each into the rows, buttons and gates that draw it, because the
1612/// runtime has no widget layer below the box layer. The expansion is lossy in
1613/// one direction only, and this is what records the loss, so a surface with a
1614/// widget of its own to offer can rebuild the authored one instead of drawing
1615/// the boxes.
1616#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1617pub enum WidgetRole {
1618 /// `<tabs>` - a strip of buttons over one panel per tab.
1619 Tabs,
1620 /// `<dropdown>` - a header button over a panel of options.
1621 Dropdown,
1622 /// `<menu>` - a panel of items shown while its signal is set.
1623 Menu,
1624 /// `<date-picker>` - a text entry holding a date.
1625 DatePicker,
1626 /// `<time-picker>` - a text entry holding a time of day.
1627 TimePicker,
1628 /// `<tooltip>` - text shown beside whatever it wraps. The marker lands
1629 /// on the wrapped element, which is what survives the expansion.
1630 Tooltip,
1631}
1632
1633/// CSS `text-overflow` - what to do with text that overflows its box.
1634#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1635pub enum TextOverflowSpec {
1636 /// Hard clip at the box edge (CSS default).
1637 Clip,
1638 /// Elide with a trailing `...` (Qt `elideMode`, CSS `ellipsis`).
1639 Ellipsis,
1640}
1641
1642/// `<if mode="render|hide">` policy.
1643#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1644pub enum IfModeSpec {
1645 /// Despawn the body subtree on falsy -> truthy transitions (default).
1646 #[default]
1647 Render,
1648 /// Mount the body once and toggle `Visible(bool)` for show / hide.
1649 Hide,
1650}
1651
1652impl From<EasingIr> for lumen_primitives::Easing {
1653 fn from(e: EasingIr) -> Self {
1654 use lumen_primitives::Easing as E;
1655 match e {
1656 EasingIr::Linear => E::Linear,
1657 EasingIr::EaseIn => E::EaseIn,
1658 EasingIr::EaseOut => E::EaseOut,
1659 EasingIr::EaseInOut => E::EaseInOut,
1660 EasingIr::CubicBezier(a, b, c, d) => E::CubicBezier(a, b, c, d),
1661 }
1662 }
1663}
1664
1665impl From<TransitionPropertyIr> for lumen_primitives::TransitionProperty {
1666 fn from(p: TransitionPropertyIr) -> Self {
1667 use lumen_primitives::TransitionProperty as P;
1668 match p {
1669 TransitionPropertyIr::Opacity => P::Opacity,
1670 TransitionPropertyIr::BackgroundColor => P::BackgroundColor,
1671 TransitionPropertyIr::TextColor => P::TextColor,
1672 TransitionPropertyIr::BorderColor => P::BorderColor,
1673 }
1674 }
1675}
1676
1677impl From<&TransitionIr> for lumen_primitives::TransitionSpec {
1678 fn from(ir: &TransitionIr) -> Self {
1679 lumen_primitives::TransitionSpec {
1680 property: ir.property.into(),
1681 duration: std::time::Duration::from_millis(ir.duration_ms as u64),
1682 easing: ir.easing.into(),
1683 }
1684 }
1685}
1686
1687/// Parsed `bind="<kind>:<name>"`.
1688#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1689pub struct BindSpec {
1690 /// Which component to drive.
1691 pub kind: BindKind,
1692 /// Signal name to read from.
1693 pub name: String,
1694}
1695
1696/// What `bind=` targets.
1697#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1698pub enum BindKind {
1699 /// Drive `TextContent` from the signal value (stringified).
1700 Text,
1701 /// Drive `Toggleable.checked` from the signal (truthy = on).
1702 Checked,
1703 /// Drive `SliderValue.value` from the signal (parsed as f32).
1704 Value,
1705}
1706
1707/// Parsed `focus-outline="<width> <#color>"` (plus the CSS
1708/// `outline-offset` folded in at spawn).
1709#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
1710pub struct OutlineSpec {
1711 /// Stroke width in pixels.
1712 pub width: f32,
1713 /// Stroke color.
1714 pub color: Rgba,
1715 /// Gap between the border box edge and the inner edge of the ring
1716 /// (CSS `outline-offset`). Default 0.
1717 pub offset: f32,
1718}
1719
1720/// Horizontal text alignment.
1721#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1722pub enum TextAlignSpec {
1723 /// Left.
1724 Start,
1725 /// Centered.
1726 Center,
1727 /// Right.
1728 End,
1729}
1730
1731/// Cross-axis alignment (CSS `align-items`).
1732#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1733pub enum FlexAlign {
1734 /// Flex-start.
1735 Start,
1736 /// Flex-end.
1737 End,
1738 /// Centered.
1739 Center,
1740 /// Stretch to fill cross size.
1741 Stretch,
1742 /// Baseline alignment (W5.9).
1743 Baseline,
1744}
1745
1746/// CSS `display` value (W5.9). Mirrors lumen-core's `Display`.
1747#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1748pub enum DisplaySpec {
1749 /// Flexbox container (default).
1750 Flex,
1751 /// CSS Grid container.
1752 Grid,
1753 /// Hidden.
1754 None,
1755}
1756
1757/// One CSS Grid track-size term (W5.9). IR-side mirror of
1758/// [`lumen_core::components::TrackSize`].
1759#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
1760pub enum TrackSizeSpec {
1761 /// `<N>px`.
1762 Fixed(f32),
1763 /// `auto`.
1764 Auto,
1765 /// `<N>fr`.
1766 Fr(f32),
1767 /// `min-content`.
1768 MinContent,
1769 /// `max-content`.
1770 MaxContent,
1771 /// `minmax(<min>, <max>)`.
1772 MinMax(Box<TrackSizeSpec>, Box<TrackSizeSpec>),
1773}
1774
1775/// Parsed `grid-template-rows` + `grid-template-columns` lists (W5.9).
1776#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
1777pub struct GridTemplateSpec {
1778 /// `grid-template-rows`.
1779 pub rows: Vec<TrackSizeSpec>,
1780 /// `grid-template-columns`.
1781 pub columns: Vec<TrackSizeSpec>,
1782}
1783
1784/// Main-axis distribution (CSS `justify-content`).
1785#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1786pub enum FlexJustify {
1787 /// Pack at the start.
1788 Start,
1789 /// Pack at the end.
1790 End,
1791 /// Pack at the center.
1792 Center,
1793 /// Space between siblings, no edge padding.
1794 SpaceBetween,
1795 /// Space around siblings, half-step at edges.
1796 SpaceAround,
1797 /// Even spacing including edges.
1798 SpaceEvenly,
1799}
1800
1801/// Flex direction.
1802#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1803pub enum FlexAxis {
1804 /// Horizontal.
1805 Row,
1806 /// Vertical.
1807 Column,
1808 /// Horizontal, reversed (CSS `row-reverse`).
1809 RowReverse,
1810 /// Vertical, reversed (CSS `column-reverse`).
1811 ColumnReverse,
1812}
1813
1814/// CSS `flex-wrap` values (IR-side mirror of
1815/// [`lumen_core::components::FlexWrap`]).
1816#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1817pub enum FlexWrapSpec {
1818 /// Single line (CSS initial value).
1819 NoWrap,
1820 /// Wrap onto additional lines.
1821 Wrap,
1822 /// Wrap with reversed line order.
1823 WrapReverse,
1824}
1825
1826/// CSS `align-content` values (IR-side mirror of
1827/// [`lumen_core::components::AlignContent`]).
1828#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1829pub enum AlignContentSpec {
1830 /// Pack lines at the start.
1831 Start,
1832 /// Pack lines at the end.
1833 End,
1834 /// Pack lines at the center.
1835 Center,
1836 /// Stretch lines (CSS initial value).
1837 Stretch,
1838 /// Even gaps between lines.
1839 SpaceBetween,
1840 /// Half gaps at the edges.
1841 SpaceAround,
1842 /// Equal gaps everywhere.
1843 SpaceEvenly,
1844}
1845
1846/// CSS `border-style` subset. v1 recognises `none` and `solid`; other
1847/// keywords are rejected at parse time with a per-declaration warning.
1848#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1849pub enum BorderStyleSpec {
1850 /// No border - computed border-width is zero on every side (CSS).
1851 None,
1852 /// Solid stroke.
1853 Solid,
1854}
1855
1856/// CSS `box-sizing` values (IR-side mirror of
1857/// [`lumen_core::components::BoxSizing`]).
1858#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1859pub enum BoxSizingSpec {
1860 /// Sizes include padding + border (Lumen UA default).
1861 BorderBox,
1862 /// Sizes cover the content box only.
1863 ContentBox,
1864}
1865
1866/// Resolved border paint for a state override (`:hover { border: ... }`,
1867/// `hover-border:` / `focus-border:`). Unlike the base border - which
1868/// accumulates from the `border-width` / `border-color` / `border-style`
1869/// longhands - a state border is authored via the shorthand only, so it
1870/// resolves to concrete widths + color at parse time.
1871#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
1872pub struct BorderPaintSpec {
1873 /// Per-side widths in px.
1874 pub widths: Edges,
1875 /// Border color.
1876 pub color: Rgba,
1877}
1878
1879// -- IR -> core type conversions. spawn.rs uses `.into()` instead of
1880// inline `match` expressions for every attribute. ---------------------
1881
1882impl From<LengthSpec> for lumen_core::components::Length {
1883 fn from(l: LengthSpec) -> Self {
1884 match l {
1885 LengthSpec::Auto => lumen_core::components::Length::Auto,
1886 LengthSpec::Px(v) => lumen_core::components::Length::Px(v),
1887 LengthSpec::Percent(v) => lumen_core::components::Length::Percent(v),
1888 }
1889 }
1890}
1891
1892impl From<FlexAxis> for lumen_core::components::FlexDirection {
1893 fn from(a: FlexAxis) -> Self {
1894 match a {
1895 FlexAxis::Row => lumen_core::components::FlexDirection::Row,
1896 FlexAxis::Column => lumen_core::components::FlexDirection::Column,
1897 FlexAxis::RowReverse => lumen_core::components::FlexDirection::RowReverse,
1898 FlexAxis::ColumnReverse => lumen_core::components::FlexDirection::ColumnReverse,
1899 }
1900 }
1901}
1902
1903impl From<FlexWrapSpec> for lumen_core::components::FlexWrap {
1904 fn from(w: FlexWrapSpec) -> Self {
1905 match w {
1906 FlexWrapSpec::NoWrap => lumen_core::components::FlexWrap::NoWrap,
1907 FlexWrapSpec::Wrap => lumen_core::components::FlexWrap::Wrap,
1908 FlexWrapSpec::WrapReverse => lumen_core::components::FlexWrap::WrapReverse,
1909 }
1910 }
1911}
1912
1913impl From<AlignContentSpec> for lumen_core::components::AlignContent {
1914 fn from(a: AlignContentSpec) -> Self {
1915 use lumen_core::components::AlignContent as Core;
1916 match a {
1917 AlignContentSpec::Start => Core::Start,
1918 AlignContentSpec::End => Core::End,
1919 AlignContentSpec::Center => Core::Center,
1920 AlignContentSpec::Stretch => Core::Stretch,
1921 AlignContentSpec::SpaceBetween => Core::SpaceBetween,
1922 AlignContentSpec::SpaceAround => Core::SpaceAround,
1923 AlignContentSpec::SpaceEvenly => Core::SpaceEvenly,
1924 }
1925 }
1926}
1927
1928impl From<BoxSizingSpec> for lumen_core::components::BoxSizing {
1929 fn from(b: BoxSizingSpec) -> Self {
1930 match b {
1931 BoxSizingSpec::BorderBox => lumen_core::components::BoxSizing::BorderBox,
1932 BoxSizingSpec::ContentBox => lumen_core::components::BoxSizing::ContentBox,
1933 }
1934 }
1935}
1936
1937impl From<BorderPaintSpec> for lumen_core::components::Border {
1938 fn from(b: BorderPaintSpec) -> Self {
1939 lumen_core::components::Border {
1940 widths: b.widths.into(),
1941 color: b.color.into(),
1942 side_colors: None,
1943 }
1944 }
1945}
1946
1947impl From<FlexAlign> for lumen_core::components::FlexAlign {
1948 fn from(a: FlexAlign) -> Self {
1949 match a {
1950 FlexAlign::Start => lumen_core::components::FlexAlign::Start,
1951 FlexAlign::End => lumen_core::components::FlexAlign::End,
1952 FlexAlign::Center => lumen_core::components::FlexAlign::Center,
1953 FlexAlign::Stretch => lumen_core::components::FlexAlign::Stretch,
1954 FlexAlign::Baseline => lumen_core::components::FlexAlign::Baseline,
1955 }
1956 }
1957}
1958
1959impl From<DisplaySpec> for lumen_core::components::Display {
1960 fn from(d: DisplaySpec) -> Self {
1961 match d {
1962 DisplaySpec::Flex => lumen_core::components::Display::Flex,
1963 DisplaySpec::Grid => lumen_core::components::Display::Grid,
1964 DisplaySpec::None => lumen_core::components::Display::None,
1965 }
1966 }
1967}
1968
1969impl From<&TrackSizeSpec> for lumen_core::components::TrackSize {
1970 fn from(t: &TrackSizeSpec) -> Self {
1971 use lumen_core::components::TrackSize as Core;
1972 match t {
1973 TrackSizeSpec::Fixed(v) => Core::Fixed(*v),
1974 TrackSizeSpec::Auto => Core::Auto,
1975 TrackSizeSpec::Fr(f) => Core::Fr(*f),
1976 TrackSizeSpec::MinContent => Core::MinContent,
1977 TrackSizeSpec::MaxContent => Core::MaxContent,
1978 TrackSizeSpec::MinMax(min, max) => Core::MinMax(
1979 Box::new(Core::from(min.as_ref())),
1980 Box::new(Core::from(max.as_ref())),
1981 ),
1982 }
1983 }
1984}
1985
1986impl From<&GridTemplateSpec> for lumen_core::components::GridTemplate {
1987 fn from(g: &GridTemplateSpec) -> Self {
1988 lumen_core::components::GridTemplate {
1989 rows: g.rows.iter().map(Into::into).collect(),
1990 columns: g.columns.iter().map(Into::into).collect(),
1991 }
1992 }
1993}
1994
1995impl From<FlexJustify> for lumen_core::components::FlexJustify {
1996 fn from(j: FlexJustify) -> Self {
1997 match j {
1998 FlexJustify::Start => lumen_core::components::FlexJustify::Start,
1999 FlexJustify::End => lumen_core::components::FlexJustify::End,
2000 FlexJustify::Center => lumen_core::components::FlexJustify::Center,
2001 FlexJustify::SpaceBetween => lumen_core::components::FlexJustify::SpaceBetween,
2002 FlexJustify::SpaceAround => lumen_core::components::FlexJustify::SpaceAround,
2003 FlexJustify::SpaceEvenly => lumen_core::components::FlexJustify::SpaceEvenly,
2004 }
2005 }
2006}
2007
2008impl From<TextAlignSpec> for lumen_core::components::TextAlign {
2009 fn from(a: TextAlignSpec) -> Self {
2010 match a {
2011 TextAlignSpec::Start => lumen_core::components::TextAlign::Start,
2012 TextAlignSpec::Center => lumen_core::components::TextAlign::Center,
2013 TextAlignSpec::End => lumen_core::components::TextAlign::End,
2014 }
2015 }
2016}
2017
2018impl From<PositionSpec> for lumen_core::components::Position {
2019 fn from(p: PositionSpec) -> Self {
2020 match p {
2021 PositionSpec::Relative => lumen_core::components::Position::Relative,
2022 PositionSpec::Absolute => lumen_core::components::Position::Absolute,
2023 }
2024 }
2025}
2026
2027impl From<ImageFitSpec> for lumen_core::components::ImageFit {
2028 fn from(f: ImageFitSpec) -> Self {
2029 match f {
2030 ImageFitSpec::Fill => lumen_core::components::ImageFit::Fill,
2031 ImageFitSpec::Cover => lumen_core::components::ImageFit::Cover,
2032 ImageFitSpec::Contain => lumen_core::components::ImageFit::Contain,
2033 ImageFitSpec::None => lumen_core::components::ImageFit::None,
2034 ImageFitSpec::ScaleDown => lumen_core::components::ImageFit::ScaleDown,
2035 }
2036 }
2037}
2038
2039impl From<OverflowSpec> for lumen_core::components::Overflow {
2040 fn from(o: OverflowSpec) -> Self {
2041 match o {
2042 OverflowSpec::Visible => lumen_core::components::Overflow::Visible,
2043 OverflowSpec::Hidden => lumen_core::components::Overflow::Hidden,
2044 OverflowSpec::Scroll => lumen_core::components::Overflow::Scroll,
2045 }
2046 }
2047}
2048
2049impl From<TextWrapSpec> for lumen_core::components::TextWrap {
2050 fn from(w: TextWrapSpec) -> Self {
2051 match w {
2052 TextWrapSpec::None => lumen_core::components::TextWrap::None,
2053 TextWrapSpec::Word => lumen_core::components::TextWrap::Word,
2054 TextWrapSpec::Glyph => lumen_core::components::TextWrap::Glyph,
2055 }
2056 }
2057}
2058
2059impl From<ScrollAxisSpec> for lumen_core::input::ScrollAxis {
2060 fn from(a: ScrollAxisSpec) -> Self {
2061 match a {
2062 ScrollAxisSpec::Y => lumen_core::input::ScrollAxis::Y,
2063 ScrollAxisSpec::X => lumen_core::input::ScrollAxis::X,
2064 ScrollAxisSpec::Both => lumen_core::input::ScrollAxis::Both,
2065 }
2066 }
2067}
2068
2069impl From<Edges> for lumen_core::components::Edges {
2070 fn from(e: Edges) -> Self {
2071 // W5.5: physical sides forward 1:1; the four logical-edge
2072 // overrides ride along so the layout backend can resolve them
2073 // against `ResolvedDirection` per CSS Logical Properties L1.
2074 // Percent units (CSS `padding: 5%`) ride along the same way.
2075 lumen_core::components::Edges {
2076 left: e.left,
2077 right: e.right,
2078 top: e.top,
2079 bottom: e.bottom,
2080 inline_start: e.inline_start,
2081 inline_end: e.inline_end,
2082 block_start: e.block_start,
2083 block_end: e.block_end,
2084 pct_left: e.pct_left,
2085 pct_right: e.pct_right,
2086 pct_top: e.pct_top,
2087 pct_bottom: e.pct_bottom,
2088 }
2089 }
2090}
2091
2092impl From<Rgba> for lumen_core::components::Color {
2093 fn from(c: Rgba) -> Self {
2094 lumen_core::components::Color::rgba(c.r, c.g, c.b, c.a)
2095 }
2096}
2097
2098#[cfg(test)]
2099mod bughunt_tests {
2100 use super::*;
2101
2102 #[test]
2103 fn effective_transitions_empty_duration_list_does_not_panic() {
2104 // A `Some(vec![])` (reachable via `transition-duration: ,`) used to
2105 // index an empty slice at `durations[0]` and panic. It must now be
2106 // treated like an absent list (default 0ms).
2107 let attrs = Attributes {
2108 transition_property: Some(vec![TransitionPropertyIr::Opacity]),
2109 transition_duration: Some(Vec::new()),
2110 transition_timing: Some(Vec::new()),
2111 ..Default::default()
2112 };
2113 let out = attrs.effective_transitions();
2114 assert_eq!(out.len(), 1);
2115 assert_eq!(out[0].duration_ms, 0);
2116 assert_eq!(out[0].easing, EasingIr::CubicBezier(0.25, 0.1, 0.25, 1.0));
2117 }
2118}
2119
2120#[cfg(test)]
2121mod tests {
2122 use super::*;
2123
2124 /// Asset paths come out of the compiler resolved against the machine
2125 /// that compiled and have to leave relative to the app, or a copied
2126 /// package looks for its images in a directory that only exists there.
2127 #[test]
2128 fn asset_paths_leave_relative_to_the_app() {
2129 // Absolute on every platform, and with each family's own separators:
2130 // a literal POSIX path is relative on Windows, where the rewrite would
2131 // have nothing to do and the test would pass without testing anything.
2132 let app = std::env::temp_dir().join("notes");
2133 let inside = app.join("icons").join("save.png");
2134 let elsewhere = std::env::temp_dir().join("pixmaps").join("other.png");
2135
2136 let image = |path: &Path| Element {
2137 tag: "image".to_string(),
2138 attrs: Attributes {
2139 src: Some(path.to_string_lossy().into_owned()),
2140 ..Default::default()
2141 },
2142 ..Default::default()
2143 };
2144 let mut root = Element {
2145 tag: "root".to_string(),
2146 children: vec![image(&inside), image(&elsewhere)],
2147 ..Default::default()
2148 };
2149
2150 let mut outside = Vec::new();
2151 relativize_asset_paths(&mut root, &app, &mut outside);
2152
2153 // Compared as a path, not as text: the separator between `icons` and
2154 // the file name is the platform's own.
2155 let rewritten = root.children[0]
2156 .attrs
2157 .src
2158 .as_deref()
2159 .expect("the asset keeps a path");
2160 assert_eq!(Path::new(rewritten), Path::new("icons").join("save.png"));
2161 assert!(
2162 Path::new(rewritten).is_relative(),
2163 "a packaged asset path must be relative to the app: {rewritten}"
2164 );
2165 assert_eq!(
2166 outside,
2167 vec![elsewhere.to_string_lossy().into_owned()],
2168 "a file outside the app directory keeps the path it had"
2169 );
2170 }
2171
2172 /// A placeholder's spelling alone decides its scope, and adding the
2173 /// fragment-parameter scope does not change that: `{$name}` and the bare
2174 /// `{name}` stay global lookups, because nothing in the text says which
2175 /// of the two a name is. Only a fragment's declared parameter list can
2176 /// turn one into [`InterpolationSlot::Arg`].
2177 #[test]
2178 fn placeholder_classification_is_by_spelling() {
2179 let cases = [
2180 ("$name", InterpolationSlot::Global("name".to_string())),
2181 ("name", InterpolationSlot::Global("name".to_string())),
2182 (" $name ", InterpolationSlot::Global("name".to_string())),
2183 ("$index", InterpolationSlot::RowIndex),
2184 ("idx", InterpolationSlot::RowIndex),
2185 ("row.title", InterpolationSlot::Row("title".to_string())),
2186 ("$self.x", InterpolationSlot::SelfField("x".to_string())),
2187 ("$parent.y", InterpolationSlot::ParentField("y".to_string())),
2188 ];
2189 for (text, expected) in cases {
2190 assert_eq!(InterpolationSlot::from(text), expected, "`{text}`");
2191 }
2192 }
2193
2194 /// The slot list is part of the artifact, so every variant has to make
2195 /// the round trip, the new one included.
2196 #[test]
2197 fn placeholder_slots_round_trip() {
2198 let slots = vec![
2199 InterpolationSlot::Global("count".to_string()),
2200 InterpolationSlot::Row("title".to_string()),
2201 InterpolationSlot::RowIndex,
2202 InterpolationSlot::SelfField("x".to_string()),
2203 InterpolationSlot::ParentField("y".to_string()),
2204 InterpolationSlot::Arg("tone".to_string()),
2205 ];
2206 let bytes = bincode::serialize(&slots).expect("slots encode");
2207 let back: Vec<InterpolationSlot> = bincode::deserialize(&bytes).expect("slots decode");
2208 assert_eq!(back, slots);
2209 }
2210}