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