Skip to main content

lumen_core/
components.rs

1//! ECS component primitives.
2//!
3//! Hierarchy components [`ChildOf`] and [`Children`] are re-exported from `bevy_ecs::hierarchy` via [`crate::prelude`].
4
5use bevy_ecs::prelude::*;
6use glam::Vec2;
7use std::sync::Arc;
8
9/// Layout-resolved absolute position and size in logical pixels.
10///
11/// - Written by the `LayoutSync` stage and read by `Render`.
12/// - Mutate via [`Style`]; the layout engine recomputes `Transform`.
13#[derive(Component, Clone, Copy, Debug, Default, PartialEq)]
14pub struct Transform {
15    /// Absolute origin (top-left) in window coordinates.
16    pub absolute: Vec2,
17    /// Computed size.
18    pub size: Vec2,
19    /// First-line text baseline y-offset from `absolute.y` (W5.9).
20    /// `Some(y)` for text leaves that report a baseline from the
21    /// shaper / measure function. `None` for non-text leaves and for
22    /// text that hasn't been measured yet.
23    ///
24    /// Consumed by [`FlexAlign::Baseline`] sibling alignment and by
25    /// AccessKit's text-position reporting. The taffy backend reads
26    /// this to wire `taffy::compute_layout_with_measure`'s baseline
27    /// callback; the renderer uses it to align mixed-size inline
28    /// runs.
29    pub baseline_y: Option<f32>,
30}
31
32impl Transform {
33    /// Construct a [`Transform`] with `baseline_y = None`. Most spawn
34    /// sites use this; text leaves overwrite `baseline_y` later from
35    /// the measure-fn output.
36    pub fn new(absolute: Vec2, size: Vec2) -> Self {
37        Self {
38            absolute,
39            size,
40            baseline_y: None,
41        }
42    }
43}
44
45impl From<(Vec2, Vec2)> for Transform {
46    fn from((absolute, size): (Vec2, Vec2)) -> Self {
47        Self::new(absolute, size)
48    }
49}
50
51/// Framework-internal style record. Smaller and renderer-agnostic compared with `taffy::Style`; the layout impl crate translates it into its backend type.
52/// New fields require a corresponding `dirty_mask` bit allocation in `lumen/src/style_mask.rs`.
53///
54/// W5.9 made this `Clone` (no longer `Copy`) because [`GridTemplate`]
55/// owns track-list vectors. Hot paths that previously took `Style` by
56/// value should switch to `&Style` - the layout backend already does.
57#[derive(Component, Clone, Debug, PartialEq)]
58pub struct Style {
59    /// CSS `display` mode. Selects the layout algorithm used for the
60    /// element's children (W5.9). [`Display::Flex`] is the default
61    /// flexbox container; [`Display::Grid`] enables CSS Grid layout;
62    /// [`Display::None`] hides the element + its subtree.
63    pub display: Display,
64    /// Width along the cross axis.
65    pub width: Length,
66    /// Height along the main axis.
67    pub height: Length,
68    /// Flex direction.
69    pub flex_direction: FlexDirection,
70    /// Padding on each edge.
71    pub padding: Edges,
72    /// Margin on each edge.
73    pub margin: Edges,
74    /// Spacing inserted between adjacent rows / columns of a flex or
75    /// grid container. CSS `gap` / `row-gap` / `column-gap`. W5.9
76    /// split the previous single-scalar `gap: f32` into a per-axis
77    /// `Gap { row, column }`; existing call sites use the
78    /// `Gap::from(f32)` shorthand to keep the same number on both
79    /// axes.
80    pub gap: Gap,
81    /// Flex-grow factor. Mirrors CSS `flex-grow`. 0 = don't grow.
82    pub grow: f32,
83    /// Cross-axis alignment (CSS `align-items`).
84    pub align: FlexAlign,
85    /// Main-axis distribution (CSS `justify-content`).
86    pub justify: FlexJustify,
87    /// Per-item override of the container's [`Self::align`]. `None`
88    /// inherits. Mirrors CSS `align-self`. W5.9: includes
89    /// [`FlexAlign::Baseline`] for mixed-size inline runs.
90    pub align_self: Option<FlexAlign>,
91    /// Grid-only: alignment of items along the inline axis. Mirrors
92    /// CSS `justify-items`. `None` = `Stretch`. Ignored under
93    /// [`Display::Flex`].
94    pub justify_items: Option<FlexAlign>,
95    /// Grid-only: per-item override of the parent's `justify_items`.
96    /// Mirrors CSS `justify-self`.
97    pub justify_self: Option<FlexAlign>,
98    /// Grid template (rows / columns). `None` under
99    /// [`Display::Flex`]; required under [`Display::Grid`]. The
100    /// taffy backend lowers this into `grid_template_rows` /
101    /// `grid_template_columns`.
102    pub grid_template: Option<GridTemplate>,
103    /// Grid-item: row line range `(start, end)` - CSS `grid-row`.
104    /// Each is a 1-based positive integer line number; `0` =
105    /// auto-placement.
106    pub grid_row: (i16, i16),
107    /// Grid-item: column line range. See [`Self::grid_row`].
108    pub grid_column: (i16, i16),
109    /// Positioning mode. `Relative` (default) participates in flex flow;
110    /// `Absolute` lifts the entity out of the flow and offsets it by
111    /// [`Self::inset`] against the nearest positioned ancestor.
112    pub position: Position,
113    /// Distance from each edge when [`Self::position`] is `Absolute`.
114    pub inset: Edges,
115    /// Minimum width (after content). `Auto` = unconstrained.
116    pub min_width: Length,
117    /// Minimum height.
118    pub min_height: Length,
119    /// Maximum width. `Auto` = unbounded.
120    pub max_width: Length,
121    /// Maximum height.
122    pub max_height: Length,
123    /// `width / height` ratio constraint. `None` = none.
124    pub aspect_ratio: Option<f32>,
125    /// Per-axis overflow control. `Visible` clips nothing; `Hidden` clips
126    /// children to the box; `Scroll` clips + the entity becomes
127    /// scrollable.
128    pub overflow_x: Overflow,
129    /// See [`Self::overflow_x`].
130    pub overflow_y: Overflow,
131    /// Flex-shrink factor. Mirrors CSS `flex-shrink`; default `1.0`
132    /// (items shrink to fit their line, per CSS).
133    pub shrink: f32,
134    /// Flex-basis - the main-axis size before free space distribution.
135    /// Mirrors CSS `flex-basis`; default [`Length::Auto`].
136    pub basis: Length,
137    /// Line-wrapping mode for flex containers. Mirrors CSS `flex-wrap`;
138    /// default [`FlexWrap::NoWrap`].
139    pub flex_wrap: FlexWrap,
140    /// Cross-axis distribution of *lines* in a multi-line flex container
141    /// (or of tracks in grid). Mirrors CSS `align-content`. `None` =
142    /// backend default (`stretch`-like). Only observable when
143    /// [`Self::flex_wrap`] allows multiple lines.
144    pub align_content: Option<AlignContent>,
145    /// Border widths per edge in logical pixels. Mirrors CSS
146    /// `border-width` with the resolved `border-style` folded in: a side
147    /// whose style is `none` carries width `0` here (per CSS, the
148    /// computed border-width of a `none` side is zero). Consumes space
149    /// per the CSS box model; paint lives in [`Visuals::border`].
150    pub border: Edges,
151    /// CSS `box-sizing`. Default [`BoxSizing::BorderBox`] (Lumen's UA
152    /// default - explicit sizes include padding + border, matching what
153    /// virtually every real-world stylesheet opts into).
154    pub box_sizing: BoxSizing,
155}
156
157impl Default for Style {
158    fn default() -> Self {
159        Self {
160            display: Display::default(),
161            width: Length::Auto,
162            height: Length::Auto,
163            flex_direction: FlexDirection::default(),
164            padding: Edges::default(),
165            margin: Edges::default(),
166            gap: Gap::default(),
167            grow: 0.0,
168            align: FlexAlign::default(),
169            justify: FlexJustify::default(),
170            align_self: None,
171            justify_items: None,
172            justify_self: None,
173            grid_template: None,
174            grid_row: (0, 0),
175            grid_column: (0, 0),
176            position: Position::default(),
177            inset: Edges::default(),
178            min_width: Length::Auto,
179            min_height: Length::Auto,
180            max_width: Length::Auto,
181            max_height: Length::Auto,
182            aspect_ratio: None,
183            overflow_x: Overflow::default(),
184            overflow_y: Overflow::default(),
185            // CSS initial value: items shrink by default.
186            shrink: 1.0,
187            basis: Length::Auto,
188            flex_wrap: FlexWrap::default(),
189            align_content: None,
190            border: Edges::default(),
191            box_sizing: BoxSizing::default(),
192        }
193    }
194}
195
196/// CSS `flex-wrap` values.
197#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
198pub enum FlexWrap {
199    /// Single line (default).
200    #[default]
201    NoWrap,
202    /// Wrap onto additional lines along the cross axis.
203    Wrap,
204    /// Wrap with reversed cross-axis line order.
205    WrapReverse,
206}
207
208/// CSS `align-content` values - distribution of flex lines / grid
209/// tracks along the cross axis.
210#[derive(Clone, Copy, Debug, PartialEq, Eq)]
211pub enum AlignContent {
212    /// Pack lines at the start.
213    Start,
214    /// Pack lines at the end.
215    End,
216    /// Pack lines at the center.
217    Center,
218    /// Stretch lines to fill the cross axis (CSS initial value).
219    Stretch,
220    /// Even gaps between lines, none at the edges.
221    SpaceBetween,
222    /// Half-size gaps at the edges.
223    SpaceAround,
224    /// Equal gaps everywhere including edges.
225    SpaceEvenly,
226}
227
228/// CSS `box-sizing` values.
229#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
230pub enum BoxSizing {
231    /// `width` / `height` include padding and border (Lumen UA default;
232    /// also taffy's default).
233    #[default]
234    BorderBox,
235    /// `width` / `height` size the content box only (the CSS-spec
236    /// initial value; opt back in with `box-sizing: content-box`).
237    ContentBox,
238}
239
240/// CSS `z-index` - paint-order override among siblings. Higher values
241/// paint later (on top). Missing component = `auto` (`0`, document
242/// order). Consumed by `render_world::build_parent_map`, which
243/// stable-sorts each entity's child list by `(z_index, document order)`
244/// before assigning pre-order paint ranks - so an element with a higher
245/// `z-index` (and its whole subtree) paints above its siblings, matching
246/// CSS stacking behaviour within one parent stacking context.
247#[derive(Component, Clone, Copy, Debug, Default, PartialEq, Eq)]
248pub struct ZIndex(pub i32);
249
250/// CSS `display` value. Selects the layout algorithm for the
251/// element's children (W5.9).
252#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
253pub enum Display {
254    /// Flexbox layout (default).
255    #[default]
256    Flex,
257    /// CSS Grid layout. Pairs with [`Style::grid_template`] /
258    /// [`Style::grid_row`] / [`Style::grid_column`].
259    Grid,
260    /// Hidden - element + its subtree generate no boxes. Distinct
261    /// from `Visible(false)` which keeps layout slots; `Display::None`
262    /// collapses the box entirely.
263    None,
264}
265
266/// CSS `gap` / `row-gap` / `column-gap` - per-axis spacing between
267/// adjacent rows / columns of a flex or grid container (W5.9). The
268/// previous single-scalar `gap: f32` is reachable via
269/// `Gap::from(value)` for back-compat with existing call sites.
270#[derive(Clone, Copy, Debug, Default, PartialEq)]
271pub struct Gap {
272    /// Vertical spacing between adjacent rows (CSS `row-gap`).
273    pub row: f32,
274    /// Horizontal spacing between adjacent columns (CSS `column-gap`).
275    pub column: f32,
276    /// CSS percent unit for the row gap. When `Some(pct)` the row gap
277    /// resolves as `pct%` of the container's content-box height (taffy
278    /// receives `LengthPercentage::percent`); `row` is ignored.
279    pub row_pct: Option<f32>,
280    /// See [`Self::row_pct`]; resolves against content-box width.
281    pub column_pct: Option<f32>,
282}
283
284impl From<f32> for Gap {
285    /// CSS shorthand: `gap: <v>` sets both axes.
286    fn from(v: f32) -> Self {
287        Self::all(v)
288    }
289}
290
291impl Gap {
292    /// Uniform gap on both axes.
293    pub const fn all(v: f32) -> Self {
294        Self {
295            row: v,
296            column: v,
297            row_pct: None,
298            column_pct: None,
299        }
300    }
301}
302
303/// One track in a grid template - CSS Grid L1 subset.
304///
305/// Authored values lower to taffy's `MinMax<MinTrackSizingFunction,
306/// MaxTrackSizingFunction>` at the layout boundary. The recursive
307/// [`Self::MinMax`] arm boxes its inner pair so the enum's size stays
308/// bounded.
309#[derive(Clone, Debug, PartialEq, Default)]
310pub enum TrackSize {
311    /// Fixed length in logical pixels (`<N>px`).
312    Fixed(f32),
313    /// CSS `auto` - sized by intrinsic content.
314    #[default]
315    Auto,
316    /// Flex factor (`<N>fr`) - proportional share of free space.
317    Fr(f32),
318    /// `min-content` - narrowest non-overflowing size.
319    MinContent,
320    /// `max-content` - widest fitting all content on one line.
321    MaxContent,
322    /// `minmax(min, max)` - independent min / max sizing functions.
323    MinMax(Box<TrackSize>, Box<TrackSize>),
324}
325
326/// CSS Grid template - explicit `grid-template-rows` + `-columns`
327/// track lists (W5.9). Implicit-grid sizing is taffy's default
328/// behaviour for cells placed past the explicit grid.
329#[derive(Clone, Debug, Default, PartialEq)]
330pub struct GridTemplate {
331    /// `grid-template-rows`.
332    pub rows: Vec<TrackSize>,
333    /// `grid-template-columns`.
334    pub columns: Vec<TrackSize>,
335}
336
337/// CSS `position` values.
338#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
339pub enum Position {
340    /// In-flow positioning (default).
341    #[default]
342    Relative,
343    /// Out-of-flow; offset by `inset` against the nearest positioned
344    /// ancestor (or the viewport if none).
345    Absolute,
346}
347
348/// CSS `overflow` values.
349#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
350pub enum Overflow {
351    /// Children paint outside the box (default).
352    #[default]
353    Visible,
354    /// Children clipped at the box edge.
355    Hidden,
356    /// Clipped + scrollable (paired with the `<scroll>` interaction
357    /// primitive for now; declarative scroll-on-overflow lands later).
358    Scroll,
359}
360
361/// Cross-axis alignment.
362///
363/// Authored via `align="..."` / `align-items` / `justify-items` /
364/// `align-self` / `justify-self`. W5.9 added [`Self::Baseline`] for
365/// CSS Grid + mixed-size inline flex runs (items' first text
366/// baselines align across the cross axis).
367#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
368pub enum FlexAlign {
369    /// Flex-start.
370    Start,
371    /// Flex-end.
372    End,
373    /// Centered.
374    Center,
375    /// Stretch - default.
376    #[default]
377    Stretch,
378    /// Baseline alignment (W5.9). Items' first-line text baselines
379    /// are aligned along the cross axis (flex) or the block axis
380    /// (grid). Falls back to `Start` when none of the items expose a
381    /// baseline.
382    Baseline,
383}
384
385/// Main-axis distribution.
386#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
387pub enum FlexJustify {
388    /// Pack at the start - default.
389    #[default]
390    Start,
391    /// Pack at the end.
392    End,
393    /// Pack at the center.
394    Center,
395    /// Space between siblings, no edge padding.
396    SpaceBetween,
397    /// Space around siblings, half-step at edges.
398    SpaceAround,
399    /// Even spacing, edges and gaps equal.
400    SpaceEvenly,
401}
402
403/// One-dimensional length specifier.
404#[derive(Clone, Copy, Debug, Default, PartialEq)]
405pub enum Length {
406    /// Computed by the layout engine.
407    #[default]
408    Auto,
409    /// Fixed pixel length.
410    Px(f32),
411    /// Percentage of parent's resolved dimension.
412    Percent(f32),
413}
414
415/// Flexbox main-axis direction. Includes the logical *Reverse variants so
416/// the layout backend can flip the inline axis when [`ResolvedDirection`]
417/// is [`LayoutDirection::Rtl`] (W5.5).
418#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
419pub enum FlexDirection {
420    /// Children flow along the inline axis (LTR: left->right).
421    #[default]
422    Row,
423    /// Children flow along the block axis (top->bottom).
424    Column,
425    /// Children flow along the inline axis in reverse (LTR: right->left).
426    /// W5.5: a plain `Row` under RTL resolves into this at the
427    /// layout-backend boundary so authors don't have to think about
428    /// mirroring.
429    RowReverse,
430    /// Children flow along the block axis in reverse (bottom->top).
431    ColumnReverse,
432}
433
434impl FlexDirection {
435    /// Resolve the logical flex direction under a concrete writing
436    /// direction. Authors keep writing `Row`; the backend calls this so
437    /// `Row` flips to [`Self::RowReverse`] under
438    /// [`LayoutDirection::Rtl`] (mirrors Qt / Web flex semantics).
439    /// `Column` / `*Reverse` pass through unchanged because the block
440    /// axis is not affected by writing direction.
441    pub const fn resolved(self, dir: LayoutDirection) -> Self {
442        match (self, dir) {
443            (Self::Row, LayoutDirection::Rtl) => Self::RowReverse,
444            (Self::RowReverse, LayoutDirection::Rtl) => Self::Row,
445            // Auto resolves at the cascade resolver before this is
446            // called; treat it like Ltr as a defensive fallback.
447            _ => self,
448        }
449    }
450}
451
452/// Per-edge length values (padding, margin, border). Physical edges
453/// (`left` / `right` / `top` / `bottom`) carry the authored values; the
454/// optional `*_inline_*` / `*_block_*` fields override them per writing
455/// direction (W5.5 - CSS Logical Properties Level 1 subset).
456///
457/// `Edges::resolved(dir)` collapses the logical fields onto the
458/// physical ones for the layout backend. When a logical override is
459/// `None` the physical field wins (back-compat for every existing
460/// callsite).
461///
462/// `PartialEq` is hand-written with NaN-tolerant semantics: `NaN` is the
463/// canonical "unset / auto" sentinel for inset edges (`edges_to_lpa`
464/// NaN-checks), and IEEE `NaN != NaN` made any two identical auto-inset
465/// styles compare unequal - which defeated every "did the Style actually
466/// change?" gate downstream (the taffy style cache re-pushed all
467/// virtualized rows on every dirty tick; equality-gated `Style` inserts
468/// re-fired forever).
469#[derive(Clone, Copy, Debug, Default)]
470pub struct Edges {
471    /// Left edge.
472    pub left: f32,
473    /// Right edge.
474    pub right: f32,
475    /// Top edge.
476    pub top: f32,
477    /// Bottom edge.
478    pub bottom: f32,
479    /// `*-inline-start` - maps to `left` under LTR, `right` under RTL.
480    pub inline_start: Option<f32>,
481    /// `*-inline-end` - maps to `right` under LTR, `left` under RTL.
482    pub inline_end: Option<f32>,
483    /// `*-block-start` - alias for `top` (no vertical writing modes yet).
484    pub block_start: Option<f32>,
485    /// `*-block-end` - alias for `bottom`.
486    pub block_end: Option<f32>,
487    /// CSS percent unit for the left edge. When `Some(pct)` the side
488    /// resolves as `pct%` per CSS (padding/margin percentages resolve
489    /// against the containing block's *width*; the layout backend hands
490    /// taffy a `LengthPercentage::percent`) and the px field for the
491    /// side is ignored. `None` = the px field is authoritative (fast
492    /// path, unchanged behaviour).
493    pub pct_left: Option<f32>,
494    /// See [`Self::pct_left`].
495    pub pct_right: Option<f32>,
496    /// See [`Self::pct_left`].
497    pub pct_top: Option<f32>,
498    /// See [`Self::pct_left`].
499    pub pct_bottom: Option<f32>,
500}
501
502/// NaN-tolerant float equality: two NaNs (the "auto" sentinel) are equal.
503fn eq_nan(a: f32, b: f32) -> bool {
504    a == b || (a.is_nan() && b.is_nan())
505}
506
507/// See [`eq_nan`]; `None == None`, `Some(a) == Some(b)` iff `eq_nan`.
508fn eq_nan_opt(a: Option<f32>, b: Option<f32>) -> bool {
509    match (a, b) {
510        (None, None) => true,
511        (Some(a), Some(b)) => eq_nan(a, b),
512        _ => false,
513    }
514}
515
516impl PartialEq for Edges {
517    fn eq(&self, other: &Self) -> bool {
518        eq_nan(self.left, other.left)
519            && eq_nan(self.right, other.right)
520            && eq_nan(self.top, other.top)
521            && eq_nan(self.bottom, other.bottom)
522            && eq_nan_opt(self.inline_start, other.inline_start)
523            && eq_nan_opt(self.inline_end, other.inline_end)
524            && eq_nan_opt(self.block_start, other.block_start)
525            && eq_nan_opt(self.block_end, other.block_end)
526            && eq_nan_opt(self.pct_left, other.pct_left)
527            && eq_nan_opt(self.pct_right, other.pct_right)
528            && eq_nan_opt(self.pct_top, other.pct_top)
529            && eq_nan_opt(self.pct_bottom, other.pct_bottom)
530    }
531}
532
533impl Edges {
534    /// Uniform physical edges.
535    pub const fn all(v: f32) -> Self {
536        Self {
537            left: v,
538            right: v,
539            top: v,
540            bottom: v,
541            inline_start: None,
542            inline_end: None,
543            block_start: None,
544            block_end: None,
545            pct_left: None,
546            pct_right: None,
547            pct_top: None,
548            pct_bottom: None,
549        }
550    }
551
552    /// Resolve logical overrides onto the physical sides under `dir`.
553    /// Returns a fresh [`Edges`] whose `left` / `right` / `top` /
554    /// `bottom` are the values the layout backend should use; the
555    /// `Option` fields are cleared so a second call is idempotent.
556    ///
557    /// - `inline_start` writes `left` (LTR) or `right` (RTL).
558    /// - `inline_end`   writes `right` (LTR) or `left` (RTL).
559    /// - `block_start` writes `top`; `block_end` writes `bottom`.
560    /// - [`LayoutDirection::Auto`] is treated as LTR (the cascade
561    ///   resolver should have stamped a concrete direction before this
562    ///   is reached; the fallback keeps this fn total).
563    pub fn resolved(&self, dir: LayoutDirection) -> Self {
564        let rtl = matches!(dir, LayoutDirection::Rtl);
565        let mut out = *self;
566        if let Some(v) = self.inline_start {
567            if rtl {
568                out.right = v;
569            } else {
570                out.left = v;
571            }
572        }
573        if let Some(v) = self.inline_end {
574            if rtl {
575                out.left = v;
576            } else {
577                out.right = v;
578            }
579        }
580        if let Some(v) = self.block_start {
581            out.top = v;
582        }
583        if let Some(v) = self.block_end {
584            out.bottom = v;
585        }
586        out.inline_start = None;
587        out.inline_end = None;
588        out.block_start = None;
589        out.block_end = None;
590        out
591    }
592}
593
594/// Marker: this entity's layout (or one of its ancestors') has changed.
595///
596/// - Set on the dirty entity and propagated upward until the nearest [`RelayoutBoundary`] ancestor.
597/// - Cleared by `LayoutSync` after recomputation.
598#[derive(Component, Clone, Copy, Debug, Default)]
599pub struct DirtyLayout;
600
601/// Per-cache memory caps in megabytes, honoured by per-tick LRU eviction across the image, shape, scene-fragment, and GPU-texture caches.
602/// Each cache exposes `bytes_used` and `evict_until(target_bytes)`; a shared system reduces the live total below the cap.
603/// Defaults target desktop-class machines; override via `lumen.toml [perf]`.
604#[derive(Resource, Clone, Copy, Debug)]
605pub struct MemoryBudget {
606    /// Decoded image cache cap in MB (CPU-side RGBA8 bytes).
607    pub images_mb: u32,
608    /// Text shape-result cache cap measured in entries.
609    pub shape_entries: u32,
610    /// Vello scene-fragment cache cap measured in entries.
611    pub scene_fragments: u32,
612}
613
614impl Default for MemoryBudget {
615    fn default() -> Self {
616        Self {
617            images_mb: 64,
618            shape_entries: 512,
619            scene_fragments: 256,
620        }
621    }
622}
623
624/// Marker indicating that the entity's size is fully determined by parent-imposed constraints (a `<scroll>` clip box, fixed `width`/`height`, or explicit `layout-boundary` attribute).
625///
626/// - `propagate_dirty_layout` halts at the nearest such ancestor.
627/// - `sync_layout` recomputes within the subtree rooted at the boundary instead of from the absolute root.
628#[derive(Component, Clone, Copy, Debug, Default)]
629pub struct RelayoutBoundary;
630
631/// Tab-navigation boundary. While the carrier is visible (no [`Visible`] component or [`Visible(true)`]), Tab / Shift-Tab cycling stays within its descendants.
632/// Applied by `<dialog>` to trap focus; cycling tolerates nested visible boundaries by keeping focus inside the active one.
633#[derive(Component, Clone, Copy, Debug, Default)]
634pub struct FocusBoundary;
635
636/// Render gate. When present and set to `false`, every extract fn skips the entity (no rect, text, image, outline, or shadow), while layout still allocates space for it.
637///
638/// - The absent component is equivalent to [`Visible(true)`].
639/// - Used by `<if mode="hide">` to keep descendant state (focus, scroll, per-row signals) across a hide/show flip.
640#[derive(Component, Clone, Copy, Debug, PartialEq, Eq)]
641pub struct Visible(pub bool);
642
643impl Default for Visible {
644    fn default() -> Self {
645        Self(true)
646    }
647}
648
649/// Marker: this entity's accessibility-relevant state changed.
650#[derive(Component, Clone, Copy, Debug, Default)]
651pub struct DirtyA11y;
652
653/// Text payload for text-bearing entities. Stored as its own component so high-frequency keystroke mutation does not bump change detection on the cold [`TextStyle`] fields.
654#[derive(Component, Clone, Debug, Default)]
655pub struct TextContent(pub String);
656
657// Reference [`crate::traits::Bindable`] impl shipped as the foundation reference port (see plan section 2 acceptance bar).
658// Wave 1 wires the auto-register call and migrates `apply_text_bindings` onto `PropertyStore::drain_dirty`.
659impl crate::traits::Bindable for TextContent {
660    const NAME: &'static str = "text";
661    type Value = std::sync::Arc<str>;
662    fn read(&self) -> Self::Value {
663        std::sync::Arc::<str>::from(self.0.as_str())
664    }
665    fn write(&mut self, v: Self::Value) {
666        self.0 = v.to_string();
667    }
668}
669
670/// Tab navigation order. Lower values focus first. Negative = not in tab chain.
671#[derive(Component, Clone, Copy, Debug, Default)]
672pub struct TabIndex(pub i32);
673
674/// In-progress IME composition state.
675#[derive(Component, Clone, Debug, Default)]
676pub struct ImeState {
677    /// The current preedit string (composition buffer).
678    pub preedit: String,
679    /// Caret position within `preedit`, in bytes.
680    pub cursor: usize,
681}
682
683/// Marker: this entity is an editable text input.
684///
685/// - Spawned by the `<input>` markup tag.
686/// - Gates [`lumen_input::type_into_focused`]; entities without this marker do not receive typing input even when focused.
687/// - `placeholder` is shown verbatim while [`TextContent`] is empty.
688/// - `cursor` indexes [`TextContent`] in bytes; ArrowLeft / ArrowRight move on Unicode boundaries.
689#[derive(Component, Clone, Debug, Default)]
690pub struct TextInput {
691    /// Hint text shown when the input is empty.
692    pub placeholder: String,
693    /// Caret byte offset within the entity's [`TextContent`]; clamped to `0..=text.len()` by the input router.
694    pub cursor: usize,
695    /// Selection anchor.
696    ///
697    /// - `None`: no selection; the cursor alone marks the insertion point.
698    /// - `Some(a)`: `min(a, cursor)..max(a, cursor)` is selected and highlighted.
699    /// - Populated by Shift+Arrow / Shift+Home / Shift+End / Ctrl+A; collapsed to `None` on any non-shifted cursor move.
700    pub selection_anchor: Option<usize>,
701    /// Whether bare Enter inserts `\n`.
702    ///
703    /// - `true` (e.g. `<textarea>`): Enter inserts; Shift+Enter still commits.
704    /// - `false` (single-line `<input>`): Enter commits via [`crate::input::TextInputCommitted`].
705    pub multiline: bool,
706}
707
708/// How a text input *renders* its content - Qt's
709/// [`QLineEdit::EchoMode`](https://doc.qt.io/qt-6/qlineedit.html#EchoMode-enum).
710///
711/// The mode is a **display + clipboard** policy only: the underlying
712/// [`TextContent`] / [`crate::text_model::TextBuffer`] always holds the
713/// real plaintext so editing, caret motion, undo, and IME keep operating
714/// on the true value. `extract_text` substitutes the display glyphs;
715/// `lumen_input::type_into_focused` gates the clipboard.
716///
717/// - Spawned by `<input type="password">` markup (wired by the
718///   reconciler onto the same entity that carries [`TextInput`]).
719/// - Absent component => [`EchoMode::Normal`].
720#[derive(Component, Clone, Copy, Debug, Default, PartialEq, Eq)]
721pub enum EchoMode {
722    /// Show the text verbatim (default). Copy / cut are allowed.
723    #[default]
724    Normal,
725    /// Render every scalar as [`PASSWORD_MASK_CHAR`]; the plaintext stays
726    /// in the buffer for editing. Copy / cut are **blocked** (Qt disables
727    /// them for non-`Normal` echo modes so a password can't be lifted off
728    /// the clipboard); paste and select-all still work.
729    Password,
730    /// Render nothing at all (not even length). Same clipboard block as
731    /// [`EchoMode::Password`].
732    NoEcho,
733}
734
735impl EchoMode {
736    /// `true` when the mode conceals the content: copy must be suppressed
737    /// and the glyphs masked.
738    pub fn is_concealed(self) -> bool {
739        matches!(self, EchoMode::Password | EchoMode::NoEcho)
740    }
741
742    /// The run actually drawn for the plaintext `plain`.
743    ///
744    /// Measuring, hit-testing, and drawing must all agree on one string, so
745    /// this is what the shaping producer shapes for a concealed field.
746    pub fn display_string(self, plain: &str) -> std::borrow::Cow<'_, str> {
747        match self {
748            EchoMode::Normal => std::borrow::Cow::Borrowed(plain),
749            EchoMode::NoEcho => std::borrow::Cow::Borrowed(""),
750            EchoMode::Password => std::borrow::Cow::Owned(
751                PASSWORD_MASK_CHAR.to_string().repeat(plain.chars().count()),
752            ),
753        }
754    }
755
756    /// Byte offset into [`Self::display_string`] for a plaintext byte
757    /// offset. Snaps `plain_byte` down to a code point boundary first.
758    pub fn display_offset(self, plain: &str, plain_byte: usize) -> usize {
759        match self {
760            EchoMode::Normal => plain_byte,
761            EchoMode::NoEcho => 0,
762            EchoMode::Password => {
763                let mut b = plain_byte.min(plain.len());
764                while b > 0 && !plain.is_char_boundary(b) {
765                    b -= 1;
766                }
767                plain[..b].chars().count() * PASSWORD_MASK_CHAR.len_utf8()
768            }
769        }
770    }
771
772    /// Inverse of [`Self::display_offset`]: plaintext byte offset for a byte
773    /// offset into the displayed run.
774    pub fn plain_offset(self, plain: &str, display_byte: usize) -> usize {
775        match self {
776            EchoMode::Normal => display_byte,
777            EchoMode::NoEcho => 0,
778            EchoMode::Password => {
779                let scalars = display_byte / PASSWORD_MASK_CHAR.len_utf8();
780                plain
781                    .char_indices()
782                    .nth(scalars)
783                    .map(|(i, _)| i)
784                    .unwrap_or(plain.len())
785            }
786        }
787    }
788}
789
790/// Default glyph substituted for each scalar under [`EchoMode::Password`]:
791/// U+2022 BULLET, the platform password convention Qt and the web use.
792/// This is the single Rust fallback, used when no [`PasswordCharacter`]
793/// override is present; the CSS `password-character` property authors
794/// that override per skin.
795pub const PASSWORD_MASK_CHAR: char = '\u{2022}';
796
797/// Per-entity override for [`PASSWORD_MASK_CHAR`] (`password-character`
798/// CSS property). Split off as its own tiny component - rather than a
799/// field on [`TextInputPaint`] - so adding it never touches that
800/// component's existing struct literals elsewhere in the tree (same
801/// reasoning as `TextInputPaint`'s own doc comment). Absent =>
802/// [`PASSWORD_MASK_CHAR`]. Only meaningful on `<input>` / `<textarea>`.
803#[derive(Component, Clone, Copy, Debug, PartialEq)]
804pub struct PasswordCharacter(pub char);
805
806impl Default for PasswordCharacter {
807    fn default() -> Self {
808        Self(PASSWORD_MASK_CHAR)
809    }
810}
811
812/// Default text-input caret stroke width, in logical pixels (`caret-width`
813/// CSS property). The single Rust fallback, used when no [`CaretWidth`]
814/// override is present; render paths scale this by the active DPR
815/// themselves.
816pub const CARET_WIDTH_PX: f32 = 2.0;
817
818/// Per-entity override for [`CARET_WIDTH_PX`] (`caret-width` CSS
819/// property). Split off as its own tiny component for the same reason as
820/// [`PasswordCharacter`]. Absent => [`CARET_WIDTH_PX`]. Only meaningful on
821/// `<input>` / `<textarea>`.
822#[derive(Component, Clone, Copy, Debug, PartialEq)]
823pub struct CaretWidth(pub f32);
824
825impl Default for CaretWidth {
826    fn default() -> Self {
827        Self(CARET_WIDTH_PX)
828    }
829}
830
831/// Default CSS `line-height: normal` multiplier - the single Rust
832/// fallback used wherever no `line-height` value reaches the layout /
833/// shaping / paint path. Common browsers use ~1.2; Lumen matches that.
834/// This is the sole line-height ratio in the codebase; [`text_block_top`]
835/// and [`text_baseline_in_line`] take the resolved line height (see
836/// [`resolve_line_height`]) rather than re-deriving it from `size_px` and
837/// a hardcoded factor, so an authored CSS `line-height` moves the text
838/// block and baseline the same way it moves everything else.
839pub const DEFAULT_LINE_HEIGHT_MULTIPLIER: f32 = 1.2;
840
841/// Resolved CSS `line-height`: either a multiplier of the element's font
842/// size (unitless, e.g. `line-height: 1.5`) or an absolute value in
843/// logical pixels (`line-height: 24px`).
844///
845/// Mirrors `lumen_ir::layout_ir::LineHeightSpec` field-for-field;
846/// duplicated here because `lumen-core` cannot depend on `lumen-ir`
847/// (`lumen-ir` already depends on `lumen-core`, so the reverse edge would
848/// cycle). The runtime converts between the two 1:1 at the IR/ECS
849/// boundary (spawn, restyle).
850#[derive(Clone, Copy, Debug, PartialEq)]
851pub enum LineHeightSpec {
852    /// Multiple of the resolved font size.
853    Multiplier(f32),
854    /// Absolute value in logical pixels.
855    Px(f32),
856}
857
858impl LineHeightSpec {
859    /// Resolve against a font size in logical pixels.
860    pub fn resolve(self, size_px: f32) -> f32 {
861        match self {
862            LineHeightSpec::Multiplier(m) => size_px * m,
863            LineHeightSpec::Px(px) => px,
864        }
865    }
866}
867
868/// Resolve a possibly-absent CSS `line-height` against `size_px`, falling
869/// back to [`DEFAULT_LINE_HEIGHT_MULTIPLIER`] (`line-height: normal`) when
870/// no value was authored. The single fallback-consumption point every
871/// line-height-aware call site outside this module should route through,
872/// rather than re-deriving `size_px * 1.2` locally.
873pub fn resolve_line_height(spec: Option<LineHeightSpec>, size_px: f32) -> f32 {
874    spec.map(|s| s.resolve(size_px))
875        .unwrap_or(size_px * DEFAULT_LINE_HEIGHT_MULTIPLIER)
876}
877
878/// Cap height as a multiple of the font size, used to optically center a
879/// line inside its line box. A font-metric approximation, not a CSS
880/// `line-height` quantity, so it stays a fixed ratio rather than routing
881/// through [`resolve_line_height`].
882const TEXT_CAP_HEIGHT_FACTOR: f32 = 0.72;
883
884/// Offset from the inner content box top to the top of the FIRST line box,
885/// in logical pixels. `line_height` is the resolved CSS line height (see
886/// [`resolve_line_height`]) - the caller passes
887/// `resolve_line_height(style.line_height, size_px)` so an authored
888/// `line-height` moves the block origin the same way it moves the line
889/// box.
890///
891/// A lone line centers in the inner box, which is what `QLineEdit` does
892/// with its `lineRect`. A stacked block starts at the top, as every
893/// multi-line editor does, so line `i` occupies
894/// `[top + i * line_height, top + (i + 1) * line_height)`; that is the
895/// band `TextGeometry::x_to_byte` resolves a pointer y against.
896///
897/// `stacked` is true for a text area (which stays top-aligned however
898/// little it holds, so the first newline does not make the content jump)
899/// and for any run that already occupies more than one line.
900///
901/// This is the single origin the drawn baseline and the hit test share; the
902/// layout producer evaluates it against the SHAPED (soft-wrap aware) line
903/// count and publishes the result as [`TextBlockOrigin`].
904pub fn text_block_top(inner_h: f32, line_height: f32, stacked: bool) -> f32 {
905    if stacked {
906        0.0
907    } else {
908        (inner_h - line_height) / 2.0
909    }
910}
911
912/// Baseline offset of a line from the top of its own line box, in logical
913/// pixels. Centers the cap height (a `size_px`-derived font metric) inside
914/// the resolved `line_height` (see [`resolve_line_height`]).
915pub fn text_baseline_in_line(size_px: f32, line_height: f32) -> f32 {
916    (line_height + size_px * TEXT_CAP_HEIGHT_FACTOR) / 2.0
917}
918
919/// Published vertical origin of an entity's text block (see
920/// [`text_block_top`]).
921///
922/// Written by the layout crate's shaping producer next to `ShapedText`, so
923/// it reflects the soft-wrapped line count rather than the `\n` count. Read
924/// by `extract_text` for the drawn baseline and by `lumen-input` for the
925/// pointer hit test; both fall back to [`text_block_top`] over the logical
926/// line count when the producer has not run.
927#[derive(Component, Clone, Copy, Debug, Default, PartialEq)]
928pub struct TextBlockOrigin {
929    /// Offset from the inner content box top to the first line box top.
930    pub top: f32,
931}
932
933/// Per-input content scroll offset that keeps the caret visible inside
934/// the field box (W2 text-editing core).
935///
936/// - Written by the runtime's caret-keep-visible system (lumenc) from
937///   the measured caret position; absent => text draws from the field
938///   origin (legacy behavior).
939/// - `offset.x` shifts the text run left by that many logical pixels;
940///   `offset.y` does the same vertically for multiline inputs.
941/// - Consumed by `extract_text`, which subtracts it from the emitted
942///   run origin so caret / selection / glyphs all shift together.
943#[derive(Component, Clone, Copy, Debug, Default, PartialEq)]
944pub struct TextInputScroll {
945    /// Content offset in logical pixels (positive = content scrolled
946    /// left/up so later text is visible).
947    pub offset: Vec2,
948}
949
950/// Caret blink phase, shared main-world resource (W2 text-editing core).
951///
952/// - Toggled by `lumen_text_edit::caret_blink` on a [`Self::period`]
953///   cadence while a [`TextInput`] holds focus; reset to visible on any
954///   edit or caret move.
955/// - Read by `extract_text`: when `visible` is `false` the caret byte is
956///   withheld from the extracted run, so the renderer paints no bar.
957/// - Absent resource => caret always visible (headless / embedder path).
958#[derive(bevy_ecs::resource::Resource, Clone, Copy, Debug)]
959pub struct CaretBlink {
960    /// Whether the caret is currently in the visible half of the phase.
961    pub visible: bool,
962    /// Start of the current blink phase; elapsed time against
963    /// [`Self::period`] selects the half-cycle.
964    pub phase: std::time::Instant,
965    /// Half-cycle duration (visible for one period, hidden for the
966    /// next). Qt's default is ~530 ms; this default is the single Rust
967    /// fallback for the CSS `caret-blink` property, which overwrites this
968    /// field directly (there is no per-entity blink state to route a
969    /// per-element override through).
970    pub period: std::time::Duration,
971}
972
973impl Default for CaretBlink {
974    fn default() -> Self {
975        Self {
976            visible: true,
977            phase: std::time::Instant::now(),
978            period: std::time::Duration::from_millis(530),
979        }
980    }
981}
982
983impl CaretBlink {
984    /// Restart the phase at "visible" (called on focus change and on
985    /// every edit / caret move so the caret never blinks mid-keystroke).
986    pub fn reset(&mut self) {
987        self.visible = true;
988        self.phase = std::time::Instant::now();
989    }
990}
991
992/// Marker: this entity accepts file drops.
993#[derive(Component, Clone, Copy, Debug, Default)]
994pub struct DropTarget;
995
996/// Marker: an in-app drag is currently hovering this [`DropTarget`] and
997/// its payload is acceptable. Maintained each tick by
998/// `lumen-os-dnd`'s drag-gesture tracker while a drag is active, removed
999/// the moment the pointer leaves or the drag ends. Drives the
1000/// `:drag-over` pseudo-class (HTML5 DnD `dragover` parity) so the hovered
1001/// drop zone can light up via design tokens.
1002#[derive(Component, Clone, Copy, Debug, Default)]
1003pub struct DropHovered;
1004
1005/// Marker: user input is rejected on this entity.
1006///
1007/// - Authored via `disabled="true"` on `<button>` / `<input>` /
1008///   `<toggle>` / `<slider>`.
1009/// - `lumen-input` skips Disabled entities in click dispatch and the
1010///   Tab focus cycle; CSS `:disabled` rules route their `bg` to the
1011///   entity's disabled fill at parse time.
1012#[derive(Component, Clone, Copy, Debug, Default)]
1013pub struct Disabled;
1014
1015/// Marker: this entity is the currently-selected member of a
1016/// single-selection group (active tab button today; dropdown
1017/// current-value button later). Maintained by the owning primitive's
1018/// sync system - inserted on the active member, removed from siblings.
1019#[derive(Component, Clone, Copy, Debug, Default)]
1020pub struct Selected;
1021
1022/// Spawn-order tiebreak for focus cycling. `bevy_ecs` 0.19's `Entity: Ord`
1023/// is a niche-optimized row-index comparison, not a spawn-order one - for
1024/// entities recycled through a freed ECS row, a later-spawned entity can
1025/// sort *before* an earlier one. `lumenc::spawn` assigns this from a
1026/// monotonic per-document counter as it walks the parsed tree in markup
1027/// order, so entities with equal [`TabIndex`] cycle in the order they
1028/// appear in the source, not in whatever order their table rows landed.
1029///
1030/// Absent on entities not spawned through `lumenc` (hand-built ECS test
1031/// fixtures, primarily) - consumers should treat a missing value as "no
1032/// preference" and fall back to `Entity` ordering for those.
1033#[derive(Component, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
1034pub struct DocumentOrder(pub u32);
1035
1036/// Marker: presses on this entity (and hit-bubbled descendants) trigger a native window drag.
1037/// Authored by the `<title-bar drag>` region; the window backend sets [`WindowDragRequest`] and calls `winit::Window::drag_window()`.
1038#[derive(Component, Clone, Copy, Debug, Default)]
1039pub struct TitleBarDraggable;
1040
1041/// Window-backend request to begin a native window drag on the next tick.
1042/// Populated by the input layer on a press over a [`TitleBarDraggable`] entity; consumed and cleared by `lumen-window-winit`.
1043#[derive(bevy_ecs::resource::Resource, Default, Debug)]
1044pub struct WindowDragRequest(pub bool);
1045
1046/// App-side intent for color-scheme resolution; mirrors libadwaita's
1047/// [`AdwColorScheme`](https://gnome.pages.gitlab.gnome.org/libadwaita/doc/main/enum.ColorScheme.html).
1048///
1049/// Resolution rules (see [`StyleManager`]):
1050/// - [`ColorScheme::ForceLight`] -> always light, ignore system.
1051/// - [`ColorScheme::ForceDark`] -> always dark, ignore system.
1052/// - [`ColorScheme::PreferLight`] -> follow system; default light when unknown.
1053/// - [`ColorScheme::PreferDark`] -> follow system; default dark when unknown.
1054/// - [`ColorScheme::Default`] -> follow system; default light when unknown.
1055#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
1056pub enum ColorScheme {
1057    /// Follow the OS-reported preference; fall back to light when no
1058    /// preference has been detected yet.
1059    #[default]
1060    Default,
1061    /// Force light regardless of OS preference.
1062    ForceLight,
1063    /// Force dark regardless of OS preference.
1064    ForceDark,
1065    /// Prefer light but follow OS overrides when reported.
1066    PreferLight,
1067    /// Prefer dark but follow OS overrides when reported.
1068    PreferDark,
1069}
1070
1071impl From<bool> for ColorScheme {
1072    /// Legacy bridge: `true` -> [`ColorScheme::ForceDark`], `false` ->
1073    /// [`ColorScheme::ForceLight`]. Mirrors the pre-W4.6 `OsTheme.is_dark`
1074    /// bool, but lets old code lean on `Into<ColorScheme>` without a
1075    /// bespoke `convert_*` helper.
1076    fn from(is_dark: bool) -> Self {
1077        if is_dark {
1078            Self::ForceDark
1079        } else {
1080            Self::ForceLight
1081        }
1082    }
1083}
1084
1085impl ColorScheme {
1086    /// Parse the Rhai / FFI / `@media` flavoured names: `"default"`,
1087    /// `"auto"`, `"force-light"`, `"force-dark"`, `"prefer-light"`,
1088    /// `"prefer-dark"`, `"light"`, `"dark"`. Case-insensitive. Returns
1089    /// `None` on unknown.
1090    pub fn from_name(s: &str) -> Option<Self> {
1091        match s.to_ascii_lowercase().as_str() {
1092            "default" | "auto" | "follow" => Some(Self::Default),
1093            "force-light" | "light" => Some(Self::ForceLight),
1094            "force-dark" | "dark" => Some(Self::ForceDark),
1095            "prefer-light" => Some(Self::PreferLight),
1096            "prefer-dark" => Some(Self::PreferDark),
1097            _ => None,
1098        }
1099    }
1100}
1101
1102/// Color-scheme arbiter mirroring `AdwStyleManager`. Combines the app's
1103/// stated intent (`scheme`) with the last-seen OS preference
1104/// (`system_dark`) to produce a single boolean (`effective_dark`) used
1105/// by the rest of the pipeline.
1106///
1107/// - Populated by the window backend from `winit::Theme` (`resumed` and `WindowEvent::ThemeChanged`).
1108/// - On Linux, an XDG desktop-portal `org.freedesktop.portal.Settings`
1109///   subscription pushes `set_system_dark` updates as the desktop's
1110///   color-scheme preference changes (best-effort; falls back to winit
1111///   when the portal is unavailable).
1112/// - [`crate::signals::style_manager_to_signal`] (W1.6) mirrors
1113///   `effective_dark` into `Signals["__theme__"]` as `"dark"` / `"light"`.
1114/// - [`crate::signals::apply_theme_signal_to_root_classes`] then writes
1115///   `theme-dark` / `theme-light` onto the root entity's
1116///   [`LumenClasses`].
1117#[derive(bevy_ecs::resource::Resource, Clone, Copy, Debug, PartialEq, Eq)]
1118pub struct StyleManager {
1119    /// Application-side intent. Default = follow OS.
1120    pub scheme: ColorScheme,
1121    /// Last-seen OS preference. Defaults to `false` (light) until the
1122    /// backend or portal listener writes through.
1123    pub system_dark: bool,
1124    /// Computed result of `scheme + system_dark`. Read by every theme
1125    /// consumer; written by [`Self::recompute`] from inside the setters.
1126    pub effective_dark: bool,
1127}
1128
1129impl Default for StyleManager {
1130    fn default() -> Self {
1131        Self {
1132            scheme: ColorScheme::Default,
1133            system_dark: false,
1134            effective_dark: false,
1135        }
1136    }
1137}
1138
1139impl StyleManager {
1140    /// Construct with a stated intent, recomputed against a fresh
1141    /// (light) system default. Useful at backend startup before any
1142    /// OS hint has arrived.
1143    pub fn with_scheme(scheme: ColorScheme) -> Self {
1144        let mut s = Self {
1145            scheme,
1146            system_dark: false,
1147            effective_dark: false,
1148        };
1149        s.recompute();
1150        s
1151    }
1152
1153    /// Update the app's intent and recompute [`Self::effective_dark`].
1154    pub fn set_scheme(&mut self, scheme: ColorScheme) {
1155        self.scheme = scheme;
1156        self.recompute();
1157    }
1158
1159    /// Update the last-seen OS preference and recompute
1160    /// [`Self::effective_dark`].
1161    pub fn set_system_dark(&mut self, system_dark: bool) {
1162        self.system_dark = system_dark;
1163        self.recompute();
1164    }
1165
1166    /// Resolve `scheme + system_dark -> effective_dark` per the
1167    /// AdwColorScheme table.
1168    ///
1169    /// With `system_dark` modelled as a plain bool (no "unknown"
1170    /// state), `Default` / `PreferLight` / `PreferDark` all follow the
1171    /// reported system preference. The three variants still differ in
1172    /// the hint the app advertises back to the OS / desktop portal -
1173    /// the runtime layer relays the active variant via
1174    /// `WindowEvent::AppearanceRequested` (W4.x follow-up) so the
1175    /// system can switch its default.
1176    fn recompute(&mut self) {
1177        self.effective_dark = match self.scheme {
1178            ColorScheme::ForceLight => false,
1179            ColorScheme::ForceDark => true,
1180            ColorScheme::PreferLight | ColorScheme::PreferDark | ColorScheme::Default => {
1181                self.system_dark
1182            }
1183        };
1184    }
1185}
1186
1187/// Backwards-compatible alias for the pre-W4.6 `OsTheme` resource.
1188/// New code should use [`StyleManager`] directly; the alias keeps
1189/// existing call sites that still read or write `is_dark` compiling
1190/// through the [`Deref`](std::ops::Deref) / [`DerefMut`](std::ops::DerefMut)
1191/// shim on the legacy wrapper.
1192#[deprecated(
1193    since = "0.0.1",
1194    note = "OsTheme was renamed to StyleManager (W4.6). Read `style_manager.effective_dark` in place of `os_theme.is_dark`."
1195)]
1196pub type OsTheme = StyleManager;
1197
1198// ---------------------------------------------------------------------------
1199// W5.4 - LayoutDirection cascade + Lang
1200// ---------------------------------------------------------------------------
1201
1202/// Per-entity layout direction (CSS `direction`). Tri-state:
1203///
1204/// - [`Self::Auto`] (default) inherits from the parent. The
1205///   `resolve_layout_direction` system walks the hierarchy and stamps
1206///   a concrete [`ResolvedDirection`] on every entity.
1207/// - [`Self::Ltr`] / [`Self::Rtl`] are explicit overrides.
1208///
1209/// Authored via `dir="ltr"|"rtl"|"auto"` on any markup element. Read
1210/// downstream by the layout backend (logical [`Edges`] resolver +
1211/// [`FlexDirection::resolved`]) and by AccessKit / text shaping.
1212#[derive(Component, Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
1213pub enum LayoutDirection {
1214    /// Inherit from the parent (root falls back to
1215    /// [`DefaultLayoutDirection`]).
1216    #[default]
1217    Auto,
1218    /// Left-to-right writing direction.
1219    Ltr,
1220    /// Right-to-left writing direction.
1221    Rtl,
1222}
1223
1224impl From<&str> for LayoutDirection {
1225    /// Parse the markup / CSS spellings. Unknown values map to
1226    /// [`Self::Auto`] so the caller can detect "no opinion" - the
1227    /// parser layer separately rejects malformed `dir=` attributes.
1228    fn from(s: &str) -> Self {
1229        match s.trim().to_ascii_lowercase().as_str() {
1230            "ltr" => Self::Ltr,
1231            "rtl" => Self::Rtl,
1232            "auto" | "inherit" | "" => Self::Auto,
1233            _ => Self::Auto,
1234        }
1235    }
1236}
1237
1238/// BCP-47 language tag (e.g. `"en-US"`, `"ar-EG"`). Drives text
1239/// shaping (`cosmic_text::Attrs::language`), AccessKit
1240/// (`Node::set_language`), and locale-aware formatters.
1241///
1242/// Authored via `lang="ar-EG"` on any element. Inherited from the
1243/// nearest ancestor when absent.
1244#[derive(Component, Clone, Debug, PartialEq, Eq, Hash)]
1245pub struct Lang(pub Arc<str>);
1246
1247impl From<&str> for Lang {
1248    fn from(s: &str) -> Self {
1249        Self(Arc::<str>::from(s.trim()))
1250    }
1251}
1252
1253impl From<String> for Lang {
1254    fn from(s: String) -> Self {
1255        Self(Arc::<str>::from(s.as_str()))
1256    }
1257}
1258
1259impl Default for Lang {
1260    fn default() -> Self {
1261        Self(Arc::<str>::from(""))
1262    }
1263}
1264
1265/// Cascade output written by [`resolve_layout_direction`]. Either
1266/// [`LayoutDirection::Ltr`] or [`LayoutDirection::Rtl`] - never
1267/// [`LayoutDirection::Auto`] (the resolver folded the inheritance
1268/// chain). Downstream consumers (layout backend, text shaper,
1269/// AccessKit) read this instead of walking the hierarchy themselves.
1270#[derive(Component, Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
1271pub struct ResolvedDirection(pub LayoutDirection);
1272
1273impl ResolvedDirection {
1274    /// Resolved direction, guaranteed to be [`LayoutDirection::Ltr`] or
1275    /// [`LayoutDirection::Rtl`] - the resolver substitutes
1276    /// [`LayoutDirection::Auto`] with [`LayoutDirection::Ltr`] before
1277    /// stamping.
1278    pub const fn direction(self) -> LayoutDirection {
1279        self.0
1280    }
1281
1282    /// Convenience: true when the resolved direction is RTL.
1283    pub const fn is_rtl(self) -> bool {
1284        matches!(self.0, LayoutDirection::Rtl)
1285    }
1286}
1287
1288/// Default writing direction for the application root. The
1289/// [`resolve_layout_direction`] system uses this when the root entity
1290/// has no explicit [`LayoutDirection`]. It defaults to
1291/// [`LayoutDirection::Ltr`] and nothing sets it from the locale today,
1292/// so a right-to-left app still needs `dir="rtl"` in its markup.
1293#[derive(bevy_ecs::resource::Resource, Clone, Copy, Debug, PartialEq, Eq, Hash)]
1294pub struct DefaultLayoutDirection(pub LayoutDirection);
1295
1296impl Default for DefaultLayoutDirection {
1297    fn default() -> Self {
1298        Self(LayoutDirection::Ltr)
1299    }
1300}
1301
1302/// Resolve every entity's [`LayoutDirection`] (defaulting to
1303/// [`LayoutDirection::Auto`] when the component is absent) against its
1304/// ancestor chain and stamp the answer into [`ResolvedDirection`].
1305///
1306/// Roots whose direction is `Auto` fall back to the
1307/// [`DefaultLayoutDirection`] resource. Runs in
1308/// [`crate::tick::TickStage::LayoutSync`] before the layout backend
1309/// reads `ResolvedDirection`.
1310///
1311/// Algorithm: one pass over every entity. For each entity, walk
1312/// `ChildOf` to the nearest ancestor that either (a) has an explicit
1313/// `Ltr` / `Rtl` direction, or (b) is the root. Honour the override or
1314/// the resource default. Time is `O(depth)` per entity; with shallow
1315/// UI trees (<= 16 levels) this is cheap and avoids any allocation.
1316///
1317/// D9: the pass is gated on its actual inputs - an explicit
1318/// [`LayoutDirection`] changing / appearing / disappearing, a hierarchy
1319/// edit (`ChildOf` changed or removed), the [`DefaultLayoutDirection`]
1320/// resource changing, or entities that have never been stamped. Steady
1321/// ticks cost a handful of empty-query checks. When the pass does run,
1322/// [`ResolvedDirection`] is only (re)inserted when the value actually
1323/// differs, so `Changed<ResolvedDirection>` downstream (the layout
1324/// backend's D8 hook) fires exclusively on real flips.
1325#[allow(clippy::too_many_arguments, clippy::type_complexity)]
1326pub fn resolve_layout_direction(
1327    mut commands: Commands,
1328    default_dir: Option<Res<DefaultLayoutDirection>>,
1329    dirs: Query<&LayoutDirection>,
1330    parents: Query<&bevy_ecs::hierarchy::ChildOf>,
1331    // `Without<IsResource>` keeps the sweep on real UI entities. Resources
1332    // live on their own entities, so an unfiltered `Query<Entity>` would
1333    // stamp `ResolvedDirection` onto every resource in the world.
1334    all: Query<Entity, Without<bevy_ecs::resource::IsResource>>,
1335    resolved_q: Query<&ResolvedDirection>,
1336    changed_inputs: Query<
1337        (),
1338        bevy_ecs::prelude::Or<(
1339            bevy_ecs::prelude::Changed<LayoutDirection>,
1340            bevy_ecs::prelude::Changed<bevy_ecs::hierarchy::ChildOf>,
1341        )>,
1342    >,
1343    unstamped: Query<(), Without<ResolvedDirection>>,
1344    mut removed_dirs: RemovedComponents<LayoutDirection>,
1345    mut removed_parents: RemovedComponents<bevy_ecs::hierarchy::ChildOf>,
1346) {
1347    // Drain both removal readers unconditionally so their bounded ring
1348    // buffers can't accumulate stale entries across gated ticks.
1349    let removed_dir_any = removed_dirs.read().next().is_some();
1350    let removed_parent_any = removed_parents.read().next().is_some();
1351    let default_changed = default_dir.as_ref().is_some_and(|r| r.is_changed());
1352    if !default_changed
1353        && !removed_dir_any
1354        && !removed_parent_any
1355        && changed_inputs.is_empty()
1356        && unstamped.is_empty()
1357    {
1358        return;
1359    }
1360
1361    let fallback = default_dir.map(|r| r.0).unwrap_or(LayoutDirection::Ltr);
1362    let fallback = match fallback {
1363        LayoutDirection::Auto => LayoutDirection::Ltr,
1364        other => other,
1365    };
1366
1367    for entity in &all {
1368        let resolved = resolve_one(entity, &dirs, &parents, fallback);
1369        // Insert only on a real change - a per-entity insert every tick
1370        // spams change detection and forces archetype churn (D9).
1371        if resolved_q.get(entity).map(|r| r.0) == Ok(resolved) {
1372            continue;
1373        }
1374        commands.entity(entity).insert(ResolvedDirection(resolved));
1375    }
1376}
1377
1378fn resolve_one(
1379    entity: Entity,
1380    dirs: &Query<&LayoutDirection>,
1381    parents: &Query<&bevy_ecs::hierarchy::ChildOf>,
1382    fallback: LayoutDirection,
1383) -> LayoutDirection {
1384    let mut cur = entity;
1385    // Cap the walk so a pathological cycle (shouldn't happen - bevy_ecs
1386    // hierarchy guards against it) doesn't spin forever.
1387    for _ in 0..256 {
1388        match dirs.get(cur) {
1389            Ok(LayoutDirection::Ltr) => return LayoutDirection::Ltr,
1390            Ok(LayoutDirection::Rtl) => return LayoutDirection::Rtl,
1391            // Auto (or no component) -> continue up.
1392            _ => {}
1393        }
1394        match parents.get(cur) {
1395            Ok(p) => cur = p.parent(),
1396            Err(_) => return fallback,
1397        }
1398    }
1399    fallback
1400}
1401
1402/// Shared hidden-check for every path that must honour visibility (spec
1403/// section 17.4: one visibility story). True when `entity` or any ancestor is
1404/// hidden by either mechanism:
1405///
1406/// * [`Visible(false)`](Visible): render-gate hide (keep-space variant,
1407///   and the flag `<if mode="hide">` stamps on its subtree root), or
1408/// * [`Style::display`] `== `[`Display::None`]: space-releasing hide.
1409///
1410/// The `Style` query is generic over a [`QueryFilter`](bevy_ecs::query::QueryFilter)
1411/// `F` so callers that already hold a conflicting `Style` view (e.g. the
1412/// `Without<ProgressFill>` split in `lumen_primitives`'s progress sync,
1413/// or the unfiltered pointer/keyboard paths in `lumen_input`) can pass a
1414/// disjoint query without a second archetype conflict.
1415pub fn hidden_via_ancestors<F: bevy_ecs::query::QueryFilter>(
1416    entity: Entity,
1417    parents: &Query<&bevy_ecs::hierarchy::ChildOf>,
1418    visibles: &Query<&Visible>,
1419    styles: &Query<&Style, F>,
1420) -> bool {
1421    let mut cur = entity;
1422    loop {
1423        if visibles.get(cur).is_ok_and(|v| !v.0) {
1424            return true;
1425        }
1426        if styles
1427            .get(cur)
1428            .is_ok_and(|s| matches!(s.display, Display::None))
1429        {
1430            return true;
1431        }
1432        match parents.get(cur) {
1433            Ok(co) => cur = co.parent(),
1434            Err(_) => return false,
1435        }
1436    }
1437}
1438
1439/// Defines an `Arc<str>`-newtype binding component together with the
1440/// `From<String>` / `From<&str>` conversions every such binding shares.
1441///
1442/// The per-type doc comment is passed through verbatim (via the captured
1443/// `#[doc]` attributes) because each records a substantive markup
1444/// contract that must not be flattened.
1445macro_rules! arc_str_binding {
1446    ($(#[doc = $doc:expr])+ $name:ident) => {
1447        $(#[doc = $doc])+
1448        #[derive(Component, Clone, Debug)]
1449        pub struct $name(pub Arc<str>);
1450
1451        impl From<String> for $name {
1452            fn from(s: String) -> Self {
1453                Self(s.into())
1454            }
1455        }
1456
1457        impl From<&str> for $name {
1458            fn from(s: &str) -> Self {
1459                Self(s.into())
1460            }
1461        }
1462    };
1463}
1464
1465arc_str_binding! {
1466    /// Binds this entity's [`TextContent`] to a named entry in [`crate::signals::Signals`]; markup `bind-text="counter"`.
1467    ///
1468    /// - `apply_text_bindings` copies `Signals[name]` into `TextContent` each tick.
1469    /// - When the signal is absent, the existing text is preserved.
1470    /// - The signal name is stored as `Arc<str>` and shared across all entities binding the same name.
1471    BindText
1472}
1473
1474/// Two-way binding for `<toggle bind-checked="signal">`.
1475/// - Signal -> [`Toggleable`] via [`crate::signals::apply_checked_bindings`].
1476/// - [`Toggleable`] -> signal via [`crate::signals::push_toggle_to_signal`] on user flip.
1477#[derive(Component, Clone, Debug)]
1478pub struct BindChecked(pub String);
1479
1480/// Two-way binding for `<slider bind-value="signal">`.
1481/// - Signal -> [`SliderValue`] via [`crate::signals::apply_value_bindings`].
1482/// - [`SliderValue`] -> signal via [`crate::signals::push_slider_to_signal`] on user drag.
1483#[derive(Component, Clone, Debug)]
1484pub struct BindValue(pub String);
1485
1486/// One-way binding for `<button bind-disabled="signal">` (any tag).
1487/// - Signal -> [`Disabled`] marker via
1488///   [`crate::signals::apply_disabled_bindings`]: a truthy signal value
1489///   inserts the marker, a falsy one removes it, letting scripts and
1490///   derived signals enable / disable widgets live.
1491///
1492/// There is no push half - `Disabled` is never mutated by user input.
1493#[derive(Component, Clone, Debug)]
1494pub struct BindDisabled(pub String);
1495
1496/// Two-way binding for `<scroll bind-scroll="signal">` (W6 T6).
1497/// - Signal (f32, logical px, vertical offset) -> [`crate::input::ScrollOffset`]
1498///   via [`crate::signals::apply_scroll_bindings`] - reactive scroll
1499///   control with NO per-frame script hook; a script writes the signal
1500///   once and the dirty-gated reader applies it.
1501/// - [`crate::input::ScrollOffset`] -> signal via
1502///   [`crate::signals::push_scroll_to_signal`], throttled to
1503///   scroll-settle (offset stopped changing and the fling velocity
1504///   slept) so user scrolling doesn't spam the store per frame.
1505#[derive(Component, Clone, Debug)]
1506pub struct BindScroll(pub String);
1507
1508arc_str_binding! {
1509    /// Per-entity text binding: `bind-text="$self.field"` lowers to this
1510    /// marker. The follow-up consumer reads the named field from the
1511    /// owning entity's `ArrayItem` (or other per-entity property bag) each
1512    /// tick and writes it into [`TextContent`]. The field name is stored as
1513    /// `Arc<str>` and shared across instances that bind the same field.
1514    ///
1515    /// W-signal-design step 1 placeholder: the systems that consume this
1516    /// component land in a follow-up commit - installing the marker today
1517    /// just records authoring intent in the spawned entity.
1518    BindSelfText
1519}
1520
1521arc_str_binding! {
1522    /// Per-entity slider-value binding: `bind-value="$self.field"`.
1523    /// Stub component; consumer lands in the follow-up commit.
1524    BindSelfValue
1525}
1526
1527arc_str_binding! {
1528    /// Per-entity toggle binding: `bind-checked="$self.field"`.
1529    /// Stub component; consumer lands in the follow-up commit.
1530    BindSelfChecked
1531}
1532
1533arc_str_binding! {
1534    /// Parent-entity text binding: `bind-text="$parent.field"`. The
1535    /// follow-up consumer walks one [`ChildOf`] step up the tree and reads
1536    /// the named field from the parent's per-entity property bag.
1537    BindParentText
1538}
1539
1540arc_str_binding! {
1541    /// Parent-entity slider-value binding: `bind-value="$parent.field"`.
1542    /// Stub component; consumer lands in the follow-up commit.
1543    BindParentValue
1544}
1545
1546arc_str_binding! {
1547    /// Parent-entity toggle binding: `bind-checked="$parent.field"`.
1548    /// Stub component; consumer lands in the follow-up commit.
1549    BindParentChecked
1550}
1551
1552/// No-op consumer stub for [`BindSelfText`]. Registered so plugin
1553/// scheduling can already wire it in; the follow-up commit populates the
1554/// query and reads from the per-entity property bag. Today this is a
1555/// pure no-op to keep the system graph stable without behavioural
1556/// change.
1557pub fn apply_bind_self_text() {}
1558
1559/// No-op consumer stub for [`BindSelfValue`]. See [`apply_bind_self_text`].
1560pub fn apply_bind_self_value() {}
1561
1562/// No-op consumer stub for [`BindSelfChecked`]. See [`apply_bind_self_text`].
1563pub fn apply_bind_self_checked() {}
1564
1565/// No-op consumer stub for [`BindParentText`]. See [`apply_bind_self_text`].
1566pub fn apply_bind_parent_text() {}
1567
1568/// No-op consumer stub for [`BindParentValue`]. See [`apply_bind_self_text`].
1569pub fn apply_bind_parent_value() {}
1570
1571/// No-op consumer stub for [`BindParentChecked`]. See [`apply_bind_self_text`].
1572pub fn apply_bind_parent_checked() {}
1573
1574/// On/off state for `<toggle>` entities. Click flips `checked` and the runtime emits `on_toggle(id, checked)`.
1575#[derive(Component, Clone, Copy, Debug, Default)]
1576pub struct Toggleable {
1577    /// Current checked state.
1578    pub checked: bool,
1579}
1580
1581/// Bounded scalar state for `<slider>` entities. `value` is held in `[min, max]`; the runtime emits `on_slider(id, value)` on drag or track click.
1582#[derive(Component, Clone, Copy, Debug)]
1583pub struct SliderValue {
1584    /// Current value, clamped to `[min, max]`.
1585    pub value: f32,
1586    /// Lower bound.
1587    pub min: f32,
1588    /// Upper bound.
1589    pub max: f32,
1590    /// Authored `step="..."` increment for keyboard arrows and wheel
1591    /// notches. `None` falls back to `(max - min) / 100` - the
1592    /// `<input type=range>` browser default of 100 discrete positions
1593    /// (see [`Self::step_size`]).
1594    pub step: Option<f32>,
1595}
1596
1597impl SliderValue {
1598    /// Effective step increment: the authored [`Self::step`], or
1599    /// `(max - min) / 100` when unset.
1600    pub fn step_size(&self) -> f32 {
1601        self.step.unwrap_or((self.max - self.min) / 100.0)
1602    }
1603}
1604
1605impl Default for SliderValue {
1606    fn default() -> Self {
1607        Self {
1608            value: 0.0,
1609            min: 0.0,
1610            max: 1.0,
1611            step: None,
1612        }
1613    }
1614}
1615
1616/// How an image fits its layout rectangle; mirrors CSS `object-fit`. Defaults to [`Self::Fill`] (stretch to the entity's `Transform.size`).
1617#[derive(Component, Clone, Copy, Debug, Default, PartialEq, Eq)]
1618pub enum ImageFit {
1619    /// Stretch to fill, ignoring aspect ratio.
1620    #[default]
1621    Fill,
1622    /// Scale to cover the box; aspect-preserved; overflow clipped.
1623    Cover,
1624    /// Scale to fit inside the box; aspect-preserved; may leave empty
1625    /// space on one axis.
1626    Contain,
1627    /// Draw at intrinsic pixel size, top-left aligned. May overflow.
1628    None,
1629    /// `min(None, Contain)` - never enlarges, may shrink.
1630    ScaleDown,
1631}
1632
1633/// An image with a backing GPU texture (uploaded asynchronously).
1634#[derive(Component, Clone, Debug, Default)]
1635pub struct ImageComponent {
1636    /// Source asset path or URL.
1637    pub source: String,
1638    /// Logical pixel size once decoded.
1639    pub natural_size: Option<Vec2>,
1640}
1641
1642/// Type-erased blob sidecar for an image render entity.
1643///
1644/// Attached to render-world entities alongside [`crate::render_world::ExtractedImage`] so
1645/// [`crate::node_ir::transform_extracted_to_nodes`] can splice the payload straight into
1646/// [`crate::node_ir::Node::Image::blob`] without `lumen-core` depending on the concrete blob type
1647/// (today: `lumen_assets::ExtractedImageBlob` wrapping a `vello::peniko::Blob<u8>`).
1648///
1649/// The inner `Arc<dyn Any + Send + Sync>` is downcast back to its concrete type by the renderer
1650/// walker. This lets the asset crate own the vello-typed payload while the core crate stays free of
1651/// the vello dependency.
1652#[derive(Component, Clone)]
1653pub struct ImageBlob(pub Arc<dyn std::any::Any + Send + Sync>);
1654
1655impl std::fmt::Debug for ImageBlob {
1656    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1657        f.debug_struct("ImageBlob").finish_non_exhaustive()
1658    }
1659}
1660
1661/// Type-erased payload sidecar for an SVG render entity.
1662///
1663/// Same shape as [`ImageBlob`] but for the SVG path: the renderer walker downcasts to
1664/// `lumen_assets::ExtractedSvg` to drive the cached `vello::Scene`. Attached to render-world
1665/// entities alongside the SVG's own components so [`crate::node_ir::transform_extracted_to_nodes`]
1666/// can splice the payload straight into [`crate::node_ir::Node::Svg::payload`].
1667///
1668/// `order` mirrors the `ExtractedSvg.order` so the IR builder can sort SVG leaves into painter
1669/// order without depending on the assets crate's concrete `ExtractedSvg` type.
1670#[derive(Component, Clone)]
1671pub struct SvgPayload {
1672    /// Opaque scene payload; the renderer walker downcasts to its concrete type.
1673    pub payload: Arc<dyn std::any::Any + Send + Sync>,
1674    /// Global paint order (mirrors `lumen_assets::ExtractedSvg::order`).
1675    pub order: u32,
1676}
1677
1678impl std::fmt::Debug for SvgPayload {
1679    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1680        f.debug_struct("SvgPayload")
1681            .field("order", &self.order)
1682            .finish_non_exhaustive()
1683    }
1684}
1685
1686/// Visual record for one rect: optional fill (solid or gradient), uniform corner radius, and stacked shadows.
1687///
1688/// - `fill = None` emits no rect; the entity behaves as a layout-only container.
1689/// - Opacity is held in [`Opacity`] separately so it composes with text, image, and SVG paint too.
1690#[derive(Component, Clone, Debug, Default, PartialEq)]
1691pub struct Visuals {
1692    /// Background fill. `None` emits no rect.
1693    pub fill: Option<Fill>,
1694    /// Uniform corner radius in logical pixels (`0` = sharp).
1695    pub radius: f32,
1696    /// Per-corner radii `[top-left, top-right, bottom-right,
1697    /// bottom-left]` (CSS `border-radius` 2-4 value shorthand /
1698    /// per-corner longhands). When `Some`, the paint path uses these
1699    /// and [`Self::radius`] carries the max corner for uniform-only
1700    /// consumers (knob geometry, focus rings).
1701    pub corner_radii: Option<[f32; 4]>,
1702    /// Stacked shadows in source order. Each comma-separated CSS `box-shadow` entry produces one [`ShadowSpec`]; `inset` entries set `inner = true` and render clipped to the rect.
1703    pub shadows: Vec<ShadowSpec>,
1704    /// CSS border paint: per-side widths + one color, solid style.
1705    /// `None` = no border (style `none`). Painted inside the border box
1706    /// (between the outer edge and the padding box), above the
1707    /// background fill and below children - exactly CSS's
1708    /// background -> border -> content order. The matching layout-space
1709    /// widths live in [`Style::border`].
1710    pub border: Option<Border>,
1711}
1712
1713/// Solid border paint record stored on [`Visuals::border`]. Supports
1714/// `border-style: solid` with per-side widths (including `0` = no
1715/// border on that side) and optional per-side colors.
1716#[derive(Clone, Copy, Debug, PartialEq)]
1717pub struct Border {
1718    /// Per-side stroke widths in logical pixels (`0` = that side absent).
1719    pub widths: Edges,
1720    /// Border color shared by all four sides (the uniform fast path).
1721    pub color: Color,
1722    /// Per-side color overrides `[top, right, bottom, left]` (CSS
1723    /// `border-top-color` ...). `None` = every side paints [`Self::color`].
1724    pub side_colors: Option<[Color; 4]>,
1725}
1726
1727impl Border {
1728    /// Uniform border: one width, one color, no per-side overrides.
1729    pub fn uniform(widths: Edges, color: Color) -> Self {
1730        Self {
1731            widths,
1732            color,
1733            side_colors: None,
1734        }
1735    }
1736}
1737
1738impl Visuals {
1739    /// Returns a reference to the first [`ShadowSpec`] in `shadows`, or `None` when the vector is empty.
1740    pub fn primary_shadow(&self) -> Option<&ShadowSpec> {
1741        self.shadows.first()
1742    }
1743}
1744
1745/// Fill brush variants for a [`Visuals`] rect.
1746#[derive(Clone, Debug, PartialEq)]
1747pub enum Fill {
1748    /// Single uniform color.
1749    Solid(Color),
1750    /// Linear gradient. `angle_deg` uses the CSS convention (`0` = left->right, `90` = bottom->top, `180` = top->bottom). `stops` is sorted by ascending offset at parse time.
1751    Linear {
1752        /// Direction in degrees.
1753        angle_deg: f32,
1754        /// `(offset, color)` pairs with `offset` in `0..=1`, ascending.
1755        stops: Vec<(f32, Color)>,
1756    },
1757    /// Radial gradient centred at 50% / 50% of the entity rect. `radius` is normalised to `0..=1` of the rect's min dimension (`1.0` reaches the nearest edge).
1758    Radial {
1759        /// Normalised radius in `0..=1`.
1760        radius: f32,
1761        /// `(offset, color)` pairs with `offset` in `0..=1`, ascending.
1762        stops: Vec<(f32, Color)>,
1763    },
1764    /// Conic (sweep) gradient centred at 50% / 50%. `from_deg` rotates the sweep start using the CSS convention (`0` = north, `90` = east).
1765    Conic {
1766        /// Starting angle in degrees.
1767        from_deg: f32,
1768        /// `(offset, color)` pairs with `offset` in `0..=1`, ascending.
1769        stops: Vec<(f32, Color)>,
1770    },
1771}
1772
1773impl Fill {
1774    /// Returns the inner [`Color`] when `self` is `Fill::Solid`; `None` otherwise.
1775    pub fn as_solid(&self) -> Option<Color> {
1776        if let Fill::Solid(c) = self {
1777            Some(*c)
1778        } else {
1779            None
1780        }
1781    }
1782
1783    /// Constructs a `Fill::Solid(c)` shorthand.
1784    pub const fn solid(c: Color) -> Self {
1785        Fill::Solid(c)
1786    }
1787}
1788
1789/// Shadow record stored on [`Visuals::shadows`].
1790///
1791/// - `offset_x` / `offset_y` move the shadow origin in logical pixels.
1792/// - `blur` is the Gaussian std-dev (`0` = sharp offset clone).
1793/// - `inner = true` renders an inset shadow clipped to the rect with the blurred draw at the negated offset.
1794#[derive(Clone, Copy, Debug, Default, PartialEq)]
1795pub struct ShadowSpec {
1796    /// Horizontal offset in logical pixels (positive = right).
1797    pub offset_x: f32,
1798    /// Vertical offset in logical pixels (positive = down).
1799    pub offset_y: f32,
1800    /// Gaussian blur radius (std-dev). `0` = sharp offset.
1801    pub blur: f32,
1802    /// CSS spread radius - inflates (positive) / deflates (negative)
1803    /// the shadow rect before blurring. Enables the hard double-ring
1804    /// idiom `box-shadow: 0 0 0 2 <color>`.
1805    pub spread: f32,
1806    /// Shadow color; alpha controls softness.
1807    pub color: Color,
1808    /// `true` renders as inset shadow; `false` (default) renders as drop shadow.
1809    pub inner: bool,
1810}
1811
1812/// Alpha multiplier applied to every drawn aspect of this entity (background fill, gradient, image, SVG, text, shadow, outline).
1813///
1814/// - Value range: `[0, 1]`; absent component is equivalent to fully opaque.
1815/// - Applied at extract time by multiplying the alpha channel of each emitted color/brush.
1816#[derive(Component, Clone, Copy, Debug, PartialEq)]
1817pub struct Opacity(pub f32);
1818
1819impl Default for Opacity {
1820    fn default() -> Self {
1821        Self(1.0)
1822    }
1823}
1824
1825impl Opacity {
1826    /// Returns `c` with its alpha multiplied by `self.0` and clamped to `[0, 1]`.
1827    pub fn apply(&self, mut c: Color) -> Color {
1828        c.a = (c.a * self.0).clamp(0.0, 1.0);
1829        c
1830    }
1831}
1832
1833/// Stable string id assigned in markup via `id="..."`. Apps query `Query<(Entity, &LumenId)>` and match by name.
1834#[derive(Component, Clone, Debug)]
1835pub struct LumenId(pub String);
1836
1837/// Generic attribute overflow map for element attributes that have no typed
1838/// component of their own (`role`, `data-*`, `aria-*`, custom attrs). The
1839/// dynamic DOM API's `set_attr`/`get_attr`/`remove_attr` route KNOWN attrs
1840/// (src, id, class, text, ...) to their typed components and everything
1841/// else here. Attribute names are stored verbatim; values are strings.
1842#[derive(Component, Clone, Debug, Default)]
1843pub struct LumenAttributes(pub std::collections::HashMap<String, String>);
1844
1845impl LumenAttributes {
1846    /// Read an attribute value.
1847    pub fn get(&self, name: &str) -> Option<&str> {
1848        self.0.get(name).map(String::as_str)
1849    }
1850
1851    /// Set (or replace) an attribute value.
1852    pub fn set(&mut self, name: &str, value: impl Into<String>) {
1853        self.0.insert(name.to_string(), value.into());
1854    }
1855
1856    /// Remove an attribute, returning its previous value if present.
1857    pub fn remove(&mut self, name: &str) -> Option<String> {
1858        self.0.remove(name)
1859    }
1860}
1861
1862/// Per-element inline style overrides: the DOM `element.style` layer. Stored
1863/// as ordered `(property, value)` pairs so a later write wins and iteration
1864/// is deterministic. The runtime CSS re-apply reads this LAST (highest
1865/// cascade tier, above the stylesheet), mirroring how inline style beats
1866/// author rules in the browser. `set_style`/`style_get`/`style_remove`
1867/// mutate it; `computed_style` reflects it after the cascade.
1868#[derive(Component, Clone, Debug, Default)]
1869pub struct InlineStyle(pub Vec<(String, String)>);
1870
1871impl InlineStyle {
1872    /// Read the inline value for `property`, if set.
1873    pub fn get(&self, property: &str) -> Option<&str> {
1874        self.0
1875            .iter()
1876            .find(|(k, _)| k == property)
1877            .map(|(_, v)| v.as_str())
1878    }
1879
1880    /// Set (or replace) an inline property, keeping first-seen order.
1881    pub fn set(&mut self, property: &str, value: impl Into<String>) {
1882        let value = value.into();
1883        if let Some(slot) = self.0.iter_mut().find(|(k, _)| k == property) {
1884            slot.1 = value;
1885        } else {
1886            self.0.push((property.to_string(), value));
1887        }
1888    }
1889
1890    /// Remove an inline property, returning its previous value if present.
1891    pub fn remove(&mut self, property: &str) -> Option<String> {
1892        let pos = self.0.iter().position(|(k, _)| k == property)?;
1893        Some(self.0.remove(pos).1)
1894    }
1895}
1896
1897/// Form-field validation rules attached when `<input>`, `<toggle>`, or `<slider>` declares `required` / `pattern` / `min` / `max`.
1898/// The `validate_inputs` system in `lumen-primitives` recomputes [`Self::is_valid`] from the entity's content and mirrors the result into the `valid:<id>` reactive signal.
1899#[derive(Component, Clone, Debug, Default)]
1900pub struct Validation {
1901    /// When `true`, the trimmed content must be non-empty for `is_valid`.
1902    pub required: bool,
1903    /// Literal substring the content must contain. (Regex support is not provided; Rhai scripts cover broader matching.)
1904    pub pattern: Option<String>,
1905    /// Lower numeric bound when the content parses as a number.
1906    pub min: Option<f32>,
1907    /// Upper numeric bound when the content parses as a number.
1908    pub max: Option<f32>,
1909    /// Most recent validity result, recomputed by the validator system.
1910    pub is_valid: bool,
1911}
1912
1913/// Class list assigned in markup via `class="a b c"`. Apps test membership with `LumenClasses::has("tile")`.
1914/// Storage is `Vec<Arc<str>>` so repeated class names share one allocation; cloning performs only Arc bumps.
1915#[derive(Component, Clone, Debug, Default)]
1916pub struct LumenClasses(pub Vec<std::sync::Arc<str>>);
1917
1918impl LumenClasses {
1919    /// Returns `true` when any stored class equals `name`.
1920    pub fn has(&self, name: &str) -> bool {
1921        self.0.iter().any(|c| c.as_ref() == name)
1922    }
1923}
1924
1925/// Markup tag name (`tile`, `label`, `button`, ...) retained on entities
1926/// that carry a `class` / `id`, so the runtime can rebuild a minimal
1927/// selector target and re-run the CSS cascade in place on a theme /
1928/// media flip (see `lumenc`'s `reapply_computed_styles`). Only attached
1929/// to selector-reachable entities to keep archetype churn off the plain
1930/// layout containers that no rule can name.
1931#[derive(Component, Clone, Debug)]
1932pub struct LumenTag(pub std::sync::Arc<str>);
1933
1934impl From<Vec<String>> for LumenClasses {
1935    fn from(v: Vec<String>) -> Self {
1936        Self(v.into_iter().map(Into::into).collect())
1937    }
1938}
1939
1940impl From<&[String]> for LumenClasses {
1941    fn from(v: &[String]) -> Self {
1942        Self(v.iter().map(|s| s.as_str().into()).collect())
1943    }
1944}
1945
1946/// Text wrap policy stored inside [`TextStyle`] and `ExtractedText`. Defaults to [`Self::None`] (no wrap, overflow clips).
1947/// Mirrors CSS `white-space: nowrap` (None), `word-wrap: break-word` (Word), and a CJK-style glyph-level break (Glyph).
1948#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1949pub enum TextWrap {
1950    /// No automatic wrapping.
1951    #[default]
1952    None,
1953    /// Word-break wrap at the available width.
1954    Word,
1955    /// Glyph-level wrap.
1956    Glyph,
1957}
1958
1959/// Horizontal text alignment inside the entity's content rectangle, stored inside [`TextStyle`] and `ExtractedText`.
1960/// Defaults to [`Self::Start`] (left in left-to-right reading order).
1961#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1962pub enum TextAlign {
1963    /// Left-aligned.
1964    #[default]
1965    Start,
1966    /// Center-aligned within the content rect.
1967    Center,
1968    /// Right-aligned.
1969    End,
1970}
1971
1972/// Text style record carrying fill color, size, family/weight, alignment, wrap policy, and optional max-line cap.
1973/// [`TextContent`] is stored separately so keystrokes do not bump change detection on these cold fields.
1974/// Default: near-white at 16px, weight 400, platform sans-serif, left-aligned, no wrap, unbounded lines.
1975#[derive(Component, Clone, Debug, PartialEq)]
1976pub struct TextStyle {
1977    /// Fill color. Default is `Color::rgb(0.92, 0.92, 0.94)`.
1978    pub color: Color,
1979    /// Font size in logical pixels.
1980    pub size_px: f32,
1981    /// Horizontal alignment inside the content rect.
1982    pub align: TextAlign,
1983    /// Wrap policy passed to the shaper.
1984    pub wrap: TextWrap,
1985    /// Hard cap on lines after shaping; `None` is unbounded.
1986    pub max_lines: Option<u32>,
1987    /// CSS `font-family` fallback chain as authored (comma-separated;
1988    /// the shaper strips quotes and resolves the first available family
1989    /// against the system font database, honouring the CSS generic
1990    /// keywords). `None` = platform sans-serif. Shared `Arc` so clones
1991    /// (extract, cache keys) don't copy the string.
1992    pub family: Option<std::sync::Arc<str>>,
1993    /// CSS `font-weight` (1-1000; 400 = normal, 700 = bold).
1994    pub weight: u16,
1995    /// Selection highlight background (`selection-color` in CSS; the
1996    /// default skin routes it through the `--lumen-selection` token) -
1997    /// Qt's `QPalette::Highlight` / Slint's `selection-background-color`.
1998    /// `None` falls back to the renderer's single built-in highlight
1999    /// ([`crate::render_world::DEFAULT_SELECTION_BG`]).
2000    ///
2001    /// The paired caret color and selected-glyph color live on the
2002    /// separate [`TextInputPaint`] component so adding them never forces
2003    /// every `TextStyle` literal to change.
2004    pub selection_color: Option<Color>,
2005    /// CSS `line-height`. Inherits down the tree like [`Self::size_px`]
2006    /// (the IR resolves inheritance before this field is populated).
2007    /// `None` => [`DEFAULT_LINE_HEIGHT_MULTIPLIER`] (`line-height: normal`),
2008    /// resolved via [`resolve_line_height`].
2009    pub line_height: Option<LineHeightSpec>,
2010}
2011
2012impl Default for TextStyle {
2013    fn default() -> Self {
2014        Self {
2015            color: Color::rgb(0.92, 0.92, 0.94),
2016            size_px: 16.0,
2017            align: TextAlign::Start,
2018            wrap: TextWrap::None,
2019            max_lines: None,
2020            family: None,
2021            weight: 400,
2022            selection_color: None,
2023            line_height: None,
2024        }
2025    }
2026}
2027
2028/// Optional caret + selected-glyph paint overrides for a text input,
2029/// split from [`TextStyle`] so they can be added without touching every
2030/// `TextStyle` struct literal in the tree. Sourced from the `caret-color`
2031/// / `selection-text-color` CSS properties by the reconciler; absent =>
2032/// the renderer falls back (caret takes the text fill, selected glyphs
2033/// keep their fill on the translucent highlight).
2034///
2035/// - Caret: `caret-color` - Qt/web caret tint.
2036/// - Selected foreground: `selection-text-color` - Qt
2037///   `QPalette::HighlightedText` / Slint `selection-foreground-color`.
2038#[derive(Component, Clone, Copy, Debug, Default, PartialEq)]
2039pub struct TextInputPaint {
2040    /// Caret color; `None` => the text fill (web default).
2041    pub caret_color: Option<Color>,
2042    /// Selected-glyph color; `None` => glyphs keep their normal fill.
2043    pub selection_foreground: Option<Color>,
2044}
2045
2046/// RGBA color, each channel in [0, 1].
2047#[derive(Clone, Copy, Debug, Default, PartialEq)]
2048pub struct Color {
2049    /// Red channel.
2050    pub r: f32,
2051    /// Green channel.
2052    pub g: f32,
2053    /// Blue channel.
2054    pub b: f32,
2055    /// Alpha channel.
2056    pub a: f32,
2057}
2058
2059impl Color {
2060    /// Constructs a fully-opaque `Color` from `r`, `g`, `b` channels.
2061    pub const fn rgb(r: f32, g: f32, b: f32) -> Self {
2062        Self { r, g, b, a: 1.0 }
2063    }
2064
2065    /// Constructs a `Color` from `r`, `g`, `b`, `a` channels.
2066    pub const fn rgba(r: f32, g: f32, b: f32, a: f32) -> Self {
2067        Self { r, g, b, a }
2068    }
2069
2070    /// Packs into `[R, G, B, A]` bytes, clamping each channel to `[0, 1]` and rounding to `u8`.
2071    pub fn to_rgba8(self) -> [u8; 4] {
2072        let q = |c: f32| (c.clamp(0.0, 1.0) * 255.0).round() as u8;
2073        [q(self.r), q(self.g), q(self.b), q(self.a)]
2074    }
2075}
2076
2077// --- Accessibility components (new, additive) --------------------------------
2078//
2079// These components feed the AccessKit tree-build system in `lumen-a11y-accesskit`.
2080// They are deliberately layered on top of the existing primitives (`Toggleable`,
2081// `SliderValue`, `TextInput`, `Validation`, `Visible`, `Focused`) so existing
2082// markup keeps working without explicit a11y annotation; explicit components
2083// override the defaults derived from those primitives.
2084//
2085// Mirrors the GTK 4 `update_state` / `update_property` / `update_relation`
2086// split: [`A11yState`] holds the boolean flags, [`A11yLabel`] /
2087// [`A11yDescription`] / [`A11yValue`] / [`A11yLevel`] / [`A11ySetSize`] /
2088// [`A11yLive`] hold the scalar/structured properties, and [`A11yRelations`]
2089// holds the cross-entity relations. See `docs/audits/a11y.md`.
2090
2091/// Explicit accessibility role override. Maps to [`accesskit::Role`] through
2092/// the `From<A11yRole> for accesskit::Role` impl in `lumen-a11y-accesskit`.
2093///
2094/// - Absent component: role derived from primitives ([`TextInput`] -> text input,
2095///   [`Toggleable`] -> switch/checkbox, [`SliderValue`] -> slider, etc.).
2096/// - Present component: this value wins. Markup author can pin a role with
2097///   `role="dialog"`.
2098#[derive(Component, Clone, Copy, Debug, PartialEq, Eq)]
2099pub enum A11yRole {
2100    /// Push button.
2101    Button,
2102    /// Hyperlink.
2103    Link,
2104    /// Single-line text input. Distinct from [`Self::TextArea`] so screen
2105    /// readers can announce single- vs multi-line behaviour.
2106    TextInput,
2107    /// Multi-line text area.
2108    TextArea,
2109    /// Two-state checkbox.
2110    Checkbox,
2111    /// Two-state on/off switch (checkbox semantics, switch presentation).
2112    /// Distinct from [`Self::Checkbox`] so assistive tech announces it as a
2113    /// switch (`Role::Switch`) - the role `<switch>` pins explicitly.
2114    Switch,
2115    /// Single radio button. Pair with [`Self::RadioGroup`].
2116    Radio,
2117    /// Container for a set of [`Self::Radio`] options.
2118    RadioGroup,
2119    /// Continuous bounded scalar control.
2120    Slider,
2121    /// Read-only progress indicator.
2122    ProgressBar,
2123    /// Drop-down combobox.
2124    ComboBox,
2125    /// Selectable list.
2126    ListBox,
2127    /// One row in a [`Self::ListBox`] or [`Self::Tree`].
2128    ListItem,
2129    /// Top-level menu bar (typically a window's main menu).
2130    MenuBar,
2131    /// Sub-menu container.
2132    Menu,
2133    /// Action menu entry.
2134    MenuItem,
2135    /// Toggleable menu entry (checkbox-style).
2136    MenuItemCheckbox,
2137    /// Menu entry inside a radio group.
2138    MenuItemRadio,
2139    /// Tab strip entry.
2140    Tab,
2141    /// Container for [`Self::Tab`] entries.
2142    TabList,
2143    /// Panel revealed by an active [`Self::Tab`].
2144    TabPanel,
2145    /// Tree container.
2146    Tree,
2147    /// Single row inside a [`Self::Tree`]; use [`A11yLevel`] for depth.
2148    TreeItem,
2149    /// Toolbar (typically a horizontal strip of [`Self::Button`]s).
2150    Toolbar,
2151    /// Modal or modeless dialog.
2152    Dialog,
2153    /// Alert dialog (modal, error-style).
2154    AlertDialog,
2155    /// Tooltip surface.
2156    Tooltip,
2157    /// Polite status region (live-region default).
2158    Status,
2159    /// Assertive alert region (live-region default).
2160    Alert,
2161    /// Visible label (typically associated via [`A11yRelations`]`.labelled_by`).
2162    Label,
2163    /// Heading; pair with [`A11yLevel`] for `<h1>`..`<h6>` depth.
2164    Heading,
2165    /// Generic grouping container (`<fieldset>`, `<section>`).
2166    Group,
2167    /// Named landmark region (`<aside>`, `<section role=region>`).
2168    Region,
2169    /// Page landmark (`<header>`, `<footer>`, `<nav>`, `<main>`).
2170    Landmark,
2171    /// Default generic container with no semantic role.
2172    Generic,
2173}
2174
2175bitflags::bitflags! {
2176    /// Boolean accessibility state flags.
2177    ///
2178    /// - Mirrors GTK 4 `GtkAccessibleState` and Qt 6 `QAccessible::State`.
2179    /// - Translated to AccessKit setters by `From<&A11yState>` in `lumen-a11y-accesskit`.
2180    /// - `HIDDEN` is derived from [`Visible(false)`] at translation time, but the
2181    ///   bit lives here so non-`Visible` carriers (popovers, off-screen drawer
2182    ///   panels) can still mark themselves hidden explicitly.
2183    #[derive(Component, Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
2184    pub struct A11yState: u32 {
2185        /// User input is currently rejected.
2186        const DISABLED  = 1 << 0;
2187        /// Content is read-only (still focusable / copyable).
2188        const READ_ONLY = 1 << 1;
2189        /// Field must be filled in for submission to succeed.
2190        const REQUIRED  = 1 << 2;
2191        /// Rendered as not visible to assistive tech (e.g. `<if mode="hide">`).
2192        const HIDDEN    = 1 << 3;
2193        /// Validation has failed.
2194        const INVALID   = 1 << 4;
2195        /// Disclosure / tree node is open.
2196        const EXPANDED  = 1 << 5;
2197        /// Currently selected within a multi-select container.
2198        const SELECTED  = 1 << 6;
2199        /// Toggle checkbox / switch is on.
2200        const CHECKED   = 1 << 7;
2201        /// Press-style button is currently pressed (toggle button).
2202        const PRESSED   = 1 << 8;
2203        /// Computation in progress; assistive tech may defer reads.
2204        const BUSY      = 1 << 9;
2205        /// Modal dialog overlay.
2206        const MODAL     = 1 << 10;
2207    }
2208}
2209
2210/// Accessible label (`aria-label`). Distinct from [`TextContent`] so prose
2211/// markup does not collide with visible body text.
2212#[derive(Component, Clone, Debug, Default)]
2213pub struct A11yLabel(pub String);
2214
2215/// Accessible description (`aria-description`). Distinct from
2216/// [`A11yLabel`]; screen readers announce label first, then description.
2217#[derive(Component, Clone, Debug, Default)]
2218pub struct A11yDescription(pub String);
2219
2220/// Bounded numeric value carrier (slider / progress / spin). The
2221/// `From<&SliderValue> for A11yValue` impl converts existing
2222/// [`SliderValue`] state without callers having to set both.
2223#[derive(Component, Clone, Debug, Default, PartialEq)]
2224pub struct A11yValue {
2225    /// Current numeric reading.
2226    pub now: f64,
2227    /// Lower bound.
2228    pub min: f64,
2229    /// Upper bound.
2230    pub max: f64,
2231    /// Step granularity for `Action::Increment` / `Decrement`.
2232    /// `0.0` defaults to `(max - min) / 100` in the action handler.
2233    pub step: f64,
2234    /// Optional human-readable value (e.g. "Saturday" for a date picker).
2235    pub text: Option<String>,
2236}
2237
2238impl From<&SliderValue> for A11yValue {
2239    fn from(s: &SliderValue) -> Self {
2240        Self {
2241            now: s.value as f64,
2242            min: s.min as f64,
2243            max: s.max as f64,
2244            // Authored step carries through; 0.0 keeps the action
2245            // handler's `(max - min) / 100` fallback - the same default
2246            // `SliderValue::step_size` applies.
2247            step: s.step.map(f64::from).unwrap_or(0.0),
2248            text: None,
2249        }
2250    }
2251}
2252
2253impl From<&SliderValue> for A11yRole {
2254    fn from(_: &SliderValue) -> Self {
2255        A11yRole::Slider
2256    }
2257}
2258
2259impl From<&Toggleable> for A11yRole {
2260    fn from(_: &Toggleable) -> Self {
2261        A11yRole::Checkbox
2262    }
2263}
2264
2265impl From<&TextInput> for A11yRole {
2266    fn from(t: &TextInput) -> Self {
2267        if t.multiline {
2268            A11yRole::TextArea
2269        } else {
2270            A11yRole::TextInput
2271        }
2272    }
2273}
2274
2275/// Hierarchy level for headings (1..6) and tree items (depth from root).
2276#[derive(Component, Clone, Copy, Debug, Default, PartialEq, Eq)]
2277pub struct A11yLevel(pub u8);
2278
2279/// Position-in-set metadata for list / tree / grid items.
2280#[derive(Component, Clone, Copy, Debug, Default, PartialEq, Eq)]
2281pub struct A11ySetSize {
2282    /// Total number of items in the containing set.
2283    pub size: usize,
2284    /// 1-based index of this item within the set.
2285    pub position: usize,
2286}
2287
2288/// Live-region politeness. Drives `accesskit::Live` on the carrier.
2289#[derive(Component, Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
2290pub enum A11yLive {
2291    /// No live announcements.
2292    #[default]
2293    Off,
2294    /// Announce after the current utterance completes.
2295    Polite,
2296    /// Interrupt the current utterance to announce immediately.
2297    Assertive,
2298}
2299
2300/// Cross-entity accessibility relations. Mirrors GTK 4 `GtkAccessibleRelation`.
2301///
2302/// - Empty fields are heap-free thanks to [`smallvec::SmallVec`].
2303/// - Translation layer turns each [`Entity`] into a `NodeId` via the existing
2304///   `entity_to_node` mapping.
2305#[derive(Component, Clone, Debug, Default)]
2306pub struct A11yRelations {
2307    /// Entities whose labels describe this one (`aria-labelledby`).
2308    pub labelled_by: smallvec::SmallVec<[Entity; 2]>,
2309    /// Entities whose content elaborates on this one (`aria-describedby`).
2310    pub described_by: smallvec::SmallVec<[Entity; 2]>,
2311    /// Entities whose content this one controls (`aria-controls`).
2312    pub controls: smallvec::SmallVec<[Entity; 2]>,
2313    /// Entities this one owns logically (`aria-owns`); used for portal
2314    /// targets and ARIA re-parenting.
2315    pub owns: smallvec::SmallVec<[Entity; 2]>,
2316}
2317
2318impl A11yRelations {
2319    /// `true` when every relation field is empty.
2320    pub fn is_empty(&self) -> bool {
2321        self.labelled_by.is_empty()
2322            && self.described_by.is_empty()
2323            && self.controls.is_empty()
2324            && self.owns.is_empty()
2325    }
2326}
2327
2328/// One-shot live-region announcement. Drained by the a11y translation
2329/// system each tick and emitted as a transient AccessKit node so screen
2330/// readers speak the string and immediately forget it.
2331///
2332/// Mirrors GTK 4 `gtk_accessible_announce` and Qt 6
2333/// `QAccessibleAnnouncementEvent`.
2334#[derive(Component, Clone, Debug, Default)]
2335pub struct A11yAnnouncement(pub String);
2336
2337/// Queue of pending one-shot announcements. Resource form of
2338/// [`A11yAnnouncement`] used by scripts (Rhai `announce(msg, "polite")`)
2339/// that have no entity handle.
2340#[derive(bevy_ecs::resource::Resource, Default, Debug)]
2341pub struct A11yAnnouncementQueue {
2342    /// `(message, politeness)` pairs drained each tick.
2343    pub pending: Vec<(String, A11yLive)>,
2344}
2345
2346/// Latest AccessKit tree update produced by the `sync_a11y_tree` system.
2347///
2348/// - Written each `TickStage::A11ySync` tick.
2349/// - Consumed by `lumen-window-winit` inside `RedrawRequested`; that
2350///   consumer calls `Adapter::update_if_active(...)` with this payload
2351///   instead of re-walking the world.
2352/// - `take()` drains the value so the next tick must build a fresh one.
2353#[derive(bevy_ecs::resource::Resource, Default)]
2354pub struct PendingA11yUpdate {
2355    /// `None` after a consumer takes the update; `Some` after the system fills it.
2356    /// Type-erased as a `Box<dyn Any>` so `lumen-core` (which has no
2357    /// `accesskit` dep) can host the resource. Producers downcast to
2358    /// `Box<accesskit::TreeUpdate>` on push and pop. See
2359    /// `lumen-a11y-accesskit` for the typed wrapper helpers.
2360    pub boxed: Option<Box<dyn std::any::Any + Send + Sync>>,
2361}
2362
2363impl std::fmt::Debug for PendingA11yUpdate {
2364    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2365        f.debug_struct("PendingA11yUpdate")
2366            .field("present", &self.boxed.is_some())
2367            .finish()
2368    }
2369}
2370
2371/// Window-root entity used by the a11y tree as the AccessKit tree root.
2372///
2373/// - Written once by the window backend after the first tick (when the
2374///   markup root has been spawned). The backend looks for the parent-less
2375///   entity carrying [`Transform`] and stores it here.
2376/// - `sync_a11y_tree` uses `entity_to_node(self.0)` as the AccessKit tree
2377///   root and the `focus = root` fallback maps to it.
2378/// - Without this resource the translation layer falls back to its legacy
2379///   synthetic `NodeId(u64::MAX)` root.
2380#[derive(bevy_ecs::resource::Resource, Clone, Copy, Debug)]
2381pub struct RootWindowEntity(pub Entity);
2382
2383/// Optional human-readable label for the AccessKit tree root.
2384///
2385/// - Written by the window backend from `WinitOptions.title` once the
2386///   window exists.
2387/// - When absent the translation layer falls back to the legacy
2388///   `"Lumen app"` hard-coded string.
2389#[derive(bevy_ecs::resource::Resource, Clone, Debug)]
2390pub struct A11yRootLabel(pub String);
2391
2392/// Queue of entities the assistive tech requested be scrolled into view.
2393///
2394/// - Written by `handle_a11y_action` in `lumen-window-winit` when an
2395///   `Action::ScrollIntoView` arrives.
2396/// - Drained by `lumen-primitives::scroll::apply_a11y_scroll_into_view`,
2397///   which walks each entry's [`ChildOf`] chain to its scroll ancestor
2398///   and updates [`ScrollOffset`] so the next layout/render pass brings
2399///   it into view.
2400#[derive(bevy_ecs::resource::Resource, Default, Debug)]
2401pub struct A11yScrollIntoViewRequests {
2402    /// Entities to scroll into view.
2403    pub targets: Vec<Entity>,
2404}
2405
2406/// Queue of entities the assistive tech requested context menus for.
2407///
2408/// - Written by `handle_a11y_action` in `lumen-window-winit` when an
2409///   `Action::ShowContextMenu` arrives.
2410/// - Drained by a system in the application; mirrored to the
2411///   [`crate::input::ShowContextMenu`] message bus by
2412///   `forward_a11y_context_menu_requests` so script handlers and
2413///   `MessageReader<ShowContextMenu>` subscribers receive a typed event.
2414#[derive(bevy_ecs::resource::Resource, Default, Debug)]
2415pub struct A11yContextMenuRequests {
2416    /// Entities a context menu was requested on.
2417    pub targets: Vec<Entity>,
2418}
2419
2420#[cfg(test)]
2421mod style_manager_tests {
2422    use super::{ColorScheme, StyleManager};
2423
2424    #[test]
2425    fn force_light_ignores_system_dark() {
2426        let mut s = StyleManager::with_scheme(ColorScheme::ForceLight);
2427        s.set_system_dark(true);
2428        assert!(!s.effective_dark);
2429    }
2430
2431    #[test]
2432    fn force_dark_ignores_system_light() {
2433        let mut s = StyleManager::with_scheme(ColorScheme::ForceDark);
2434        s.set_system_dark(false);
2435        assert!(s.effective_dark);
2436    }
2437
2438    #[test]
2439    fn default_follows_system_dark() {
2440        let mut s = StyleManager::default();
2441        assert!(matches!(s.scheme, ColorScheme::Default));
2442        assert!(!s.effective_dark);
2443        s.set_system_dark(true);
2444        assert!(s.effective_dark);
2445        s.set_system_dark(false);
2446        assert!(!s.effective_dark);
2447    }
2448
2449    #[test]
2450    fn prefer_light_and_dark_follow_system() {
2451        let mut s = StyleManager::with_scheme(ColorScheme::PreferLight);
2452        s.set_system_dark(true);
2453        assert!(s.effective_dark);
2454        s.set_scheme(ColorScheme::PreferDark);
2455        s.set_system_dark(false);
2456        assert!(!s.effective_dark);
2457    }
2458
2459    #[test]
2460    fn set_scheme_recomputes_in_place() {
2461        let mut s = StyleManager::default();
2462        s.set_system_dark(true);
2463        assert!(s.effective_dark);
2464        s.set_scheme(ColorScheme::ForceLight);
2465        assert!(!s.effective_dark);
2466    }
2467
2468    #[test]
2469    fn bool_bridge_maps_to_force_variants() {
2470        assert!(matches!(ColorScheme::from(true), ColorScheme::ForceDark));
2471        assert!(matches!(ColorScheme::from(false), ColorScheme::ForceLight));
2472        // Round-trip via `Into` so the From impl is exercised both ways
2473        // (this is the legacy `OsTheme.is_dark` callsite shape).
2474        let cs: ColorScheme = true.into();
2475        assert!(matches!(cs, ColorScheme::ForceDark));
2476    }
2477
2478    #[test]
2479    fn from_name_accepts_canonical_and_legacy_spellings() {
2480        assert!(matches!(
2481            ColorScheme::from_name("default").unwrap(),
2482            ColorScheme::Default
2483        ));
2484        assert!(matches!(
2485            ColorScheme::from_name("auto").unwrap(),
2486            ColorScheme::Default
2487        ));
2488        assert!(matches!(
2489            ColorScheme::from_name("FORCE-DARK").unwrap(),
2490            ColorScheme::ForceDark
2491        ));
2492        assert!(matches!(
2493            ColorScheme::from_name("dark").unwrap(),
2494            ColorScheme::ForceDark
2495        ));
2496        assert!(matches!(
2497            ColorScheme::from_name("prefer-light").unwrap(),
2498            ColorScheme::PreferLight
2499        ));
2500        assert!(ColorScheme::from_name("nope").is_none());
2501    }
2502}
2503
2504#[cfg(test)]
2505mod direction_tests {
2506    use super::*;
2507    use bevy_ecs::hierarchy::ChildOf;
2508    use bevy_ecs::schedule::Schedule;
2509    use bevy_ecs::world::World;
2510
2511    #[test]
2512    fn layout_direction_from_str_round_trips() {
2513        assert!(matches!(LayoutDirection::from("ltr"), LayoutDirection::Ltr));
2514        assert!(matches!(LayoutDirection::from("RTL"), LayoutDirection::Rtl));
2515        assert!(matches!(
2516            LayoutDirection::from("auto"),
2517            LayoutDirection::Auto
2518        ));
2519        // Anything unknown defaults to Auto (the parser layer rejects
2520        // malformed inputs separately).
2521        assert!(matches!(
2522            LayoutDirection::from("???"),
2523            LayoutDirection::Auto
2524        ));
2525    }
2526
2527    /// D9: the resolver stamps [`ResolvedDirection`] once, then stays
2528    /// quiet - a steady tick must not re-insert (each insert bumps
2529    /// `Changed<ResolvedDirection>`, which the layout backend's D8 hook
2530    /// turns into a relayout). A real flip re-stamps descendants.
2531    #[test]
2532    fn resolve_layout_direction_stamps_once_then_stays_quiet() {
2533        use bevy_ecs::system::RunSystemOnce;
2534        let mut world = World::new();
2535        let root = world.spawn(LayoutDirection::Rtl).id();
2536        let child = world.spawn(ChildOf(root)).id();
2537
2538        world.run_system_once(resolve_layout_direction).unwrap();
2539        assert_eq!(
2540            world.get::<ResolvedDirection>(child).map(|r| r.0),
2541            Some(LayoutDirection::Rtl)
2542        );
2543
2544        let tick = world
2545            .entity(child)
2546            .get_ref::<ResolvedDirection>()
2547            .unwrap()
2548            .last_changed();
2549        world.run_system_once(resolve_layout_direction).unwrap();
2550        assert_eq!(
2551            world
2552                .entity(child)
2553                .get_ref::<ResolvedDirection>()
2554                .unwrap()
2555                .last_changed(),
2556            tick,
2557            "steady run must not re-stamp ResolvedDirection"
2558        );
2559
2560        // Flip the ancestor: descendants re-resolve.
2561        *world.get_mut::<LayoutDirection>(root).unwrap() = LayoutDirection::Ltr;
2562        world.run_system_once(resolve_layout_direction).unwrap();
2563        assert_eq!(
2564            world.get::<ResolvedDirection>(child).map(|r| r.0),
2565            Some(LayoutDirection::Ltr)
2566        );
2567    }
2568
2569    #[test]
2570    fn edges_resolved_inline_start_rtl_writes_right() {
2571        let e = Edges {
2572            inline_start: Some(8.0),
2573            ..Edges::all(0.0)
2574        };
2575        let r = e.resolved(LayoutDirection::Rtl);
2576        assert_eq!(r.right, 8.0);
2577        assert_eq!(r.left, 0.0);
2578        // The logical override is cleared after resolution so a second
2579        // call is idempotent.
2580        assert!(r.inline_start.is_none());
2581    }
2582
2583    #[test]
2584    fn edges_resolved_inline_start_ltr_writes_left() {
2585        let e = Edges {
2586            inline_start: Some(8.0),
2587            ..Edges::all(0.0)
2588        };
2589        let r = e.resolved(LayoutDirection::Ltr);
2590        assert_eq!(r.left, 8.0);
2591        assert_eq!(r.right, 0.0);
2592    }
2593
2594    #[test]
2595    fn edges_resolved_inline_end_mirrors_under_rtl() {
2596        let e = Edges {
2597            inline_end: Some(12.0),
2598            ..Edges::all(0.0)
2599        };
2600        assert_eq!(e.resolved(LayoutDirection::Ltr).right, 12.0);
2601        assert_eq!(e.resolved(LayoutDirection::Rtl).left, 12.0);
2602    }
2603
2604    #[test]
2605    fn edges_resolved_block_overrides_top_and_bottom() {
2606        let e = Edges {
2607            block_start: Some(4.0),
2608            block_end: Some(5.0),
2609            ..Edges::all(0.0)
2610        };
2611        let r = e.resolved(LayoutDirection::Ltr);
2612        assert_eq!(r.top, 4.0);
2613        assert_eq!(r.bottom, 5.0);
2614    }
2615
2616    #[test]
2617    fn edges_physical_preserved_when_logical_absent() {
2618        let e = Edges {
2619            left: 1.0,
2620            right: 2.0,
2621            top: 3.0,
2622            bottom: 4.0,
2623            ..Edges::default()
2624        };
2625        let r = e.resolved(LayoutDirection::Rtl);
2626        assert_eq!(r.left, 1.0);
2627        assert_eq!(r.right, 2.0);
2628        assert_eq!(r.top, 3.0);
2629        assert_eq!(r.bottom, 4.0);
2630    }
2631
2632    #[test]
2633    fn flex_direction_row_under_rtl_becomes_row_reverse() {
2634        assert!(matches!(
2635            FlexDirection::Row.resolved(LayoutDirection::Rtl),
2636            FlexDirection::RowReverse
2637        ));
2638        assert!(matches!(
2639            FlexDirection::Row.resolved(LayoutDirection::Ltr),
2640            FlexDirection::Row
2641        ));
2642        assert!(matches!(
2643            FlexDirection::Column.resolved(LayoutDirection::Rtl),
2644            FlexDirection::Column
2645        ));
2646        assert!(matches!(
2647            FlexDirection::RowReverse.resolved(LayoutDirection::Rtl),
2648            FlexDirection::Row
2649        ));
2650    }
2651
2652    #[test]
2653    fn layout_direction_auto_inherits_from_parent() {
2654        let mut world = World::new();
2655        world.insert_resource(DefaultLayoutDirection::default());
2656        let root = world.spawn(LayoutDirection::Rtl).id();
2657        // Child has no explicit LayoutDirection -> should inherit Rtl.
2658        let child = world.spawn(ChildOf(root)).id();
2659        // Grandchild explicitly Auto -> still Rtl via cascade.
2660        let grandchild = world.spawn((LayoutDirection::Auto, ChildOf(child))).id();
2661        // Sibling root with no direction -> uses DefaultLayoutDirection (Ltr).
2662        let sibling_root = world.spawn(()).id();
2663
2664        let mut sched = Schedule::default();
2665        sched.add_systems(resolve_layout_direction);
2666        sched.run(&mut world);
2667
2668        assert_eq!(
2669            world.get::<ResolvedDirection>(root).copied(),
2670            Some(ResolvedDirection(LayoutDirection::Rtl))
2671        );
2672        assert_eq!(
2673            world.get::<ResolvedDirection>(child).copied(),
2674            Some(ResolvedDirection(LayoutDirection::Rtl))
2675        );
2676        assert_eq!(
2677            world.get::<ResolvedDirection>(grandchild).copied(),
2678            Some(ResolvedDirection(LayoutDirection::Rtl))
2679        );
2680        assert_eq!(
2681            world.get::<ResolvedDirection>(sibling_root).copied(),
2682            Some(ResolvedDirection(LayoutDirection::Ltr))
2683        );
2684    }
2685
2686    #[test]
2687    fn resolved_falls_back_to_default_resource_under_rtl_locale() {
2688        let mut world = World::new();
2689        world.insert_resource(DefaultLayoutDirection(LayoutDirection::Rtl));
2690        let root = world.spawn(()).id();
2691        let child = world.spawn((LayoutDirection::Auto, ChildOf(root))).id();
2692
2693        let mut sched = Schedule::default();
2694        sched.add_systems(resolve_layout_direction);
2695        sched.run(&mut world);
2696
2697        assert_eq!(
2698            world.get::<ResolvedDirection>(root).copied(),
2699            Some(ResolvedDirection(LayoutDirection::Rtl))
2700        );
2701        assert_eq!(
2702            world.get::<ResolvedDirection>(child).copied(),
2703            Some(ResolvedDirection(LayoutDirection::Rtl))
2704        );
2705    }
2706
2707    #[test]
2708    fn lang_from_str_preserves_bcp47_tag() {
2709        let l: Lang = "ar-EG".into();
2710        assert_eq!(&*l.0, "ar-EG");
2711        let l2: Lang = String::from("en-US").into();
2712        assert_eq!(&*l2.0, "en-US");
2713    }
2714}