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}