Skip to main content

lumen_runtime/run/
restyle.rs

1use super::*;
2
3/// In-app error banner. Hot-reload parse failures land here; a
4/// dedicated system paints a red strip at the top of the window with
5/// the error text. Esc / click clears the banner.
6#[derive(Resource, Default, Debug)]
7pub struct ErrorBanner(pub Option<String>);
8
9/// Marker for the entity spawned to render the [`ErrorBanner`]. Used
10/// to find + update + despawn the banner across ticks.
11#[derive(bevy_ecs::component::Component, Clone, Copy, Debug, Default)]
12pub struct ErrorBannerMarker;
13
14/// Reconcile the on-screen banner against [`ErrorBanner`]:
15///
16/// * banner present, no entity -> spawn an absolute-positioned red
17///   strip with the message at the top of the viewport.
18/// * banner present, entity exists -> patch the message in place.
19/// * banner cleared, entity exists -> despawn.
20///
21/// Runs in `TickStage::Systems` so the layout pass picks the
22/// banner's new style up the same tick it spawns.
23pub fn reconcile_error_banner(
24    mut commands: bevy_ecs::system::Commands,
25    banner: bevy_ecs::system::Res<ErrorBanner>,
26    existing: bevy_ecs::system::Query<
27        (Entity, Option<&mut lumen_core::components::TextContent>),
28        bevy_ecs::prelude::With<ErrorBannerMarker>,
29    >,
30) {
31    let want = banner.0.as_deref();
32    let mut existing = existing;
33    let first = existing.iter_mut().next();
34    match (want, first) {
35        (Some(msg), None) => {
36            commands.spawn((
37                ErrorBannerMarker,
38                lumen_core::components::DirtyLayout,
39                lumen_core::components::Style {
40                    width: lumen_core::components::Length::Percent(100.0),
41                    height: lumen_core::components::Length::Px(36.0),
42                    position: lumen_core::components::Position::Absolute,
43                    // W2.6: never feed `f32::INFINITY` into taffy. The
44                    // engine's `edges_to_lpa` only NaN-checks, and
45                    // `LengthPercentageAuto::length(INF)` then poisons
46                    // downstream comparisons inside taffy (bug 5 in
47                    // `docs/audits/layout.md`). Clamp to `f32::MAX / 2`
48                    // which is "effectively unbounded" without breaking
49                    // taffy's `MIN..MAX` arithmetic.
50                    inset: lumen_core::components::Edges {
51                        top: 0.0,
52                        right: 0.0,
53                        bottom: f32::MAX / 2.0,
54                        left: 0.0,
55                        // W5.5: logical-edge overrides default to
56                        // None so the physical sides above win.
57                        ..Default::default()
58                    },
59                    padding: lumen_core::components::Edges {
60                        top: 8.0,
61                        bottom: 8.0,
62                        left: 16.0,
63                        right: 16.0,
64                        ..Default::default()
65                    },
66                    ..Default::default()
67                },
68                lumen_core::components::Visuals {
69                    fill: Some(lumen_core::components::Fill::Solid(
70                        lumen_core::components::Color::rgba(0.78, 0.15, 0.15, 0.96),
71                    )),
72                    ..Default::default()
73                },
74                lumen_core::components::TextContent(msg.to_string()),
75                lumen_core::components::TextStyle {
76                    color: lumen_core::components::Color::rgb(1.0, 1.0, 1.0),
77                    size_px: 14.0,
78                    ..Default::default()
79                },
80            ));
81        }
82        (Some(msg), Some((_, Some(mut tc)))) if tc.0 != msg => {
83            tc.0 = msg.to_string();
84        }
85        (None, Some((entity, _))) => {
86            commands.entity(entity).despawn();
87        }
88        _ => {}
89    }
90}
91
92/// Esc clears the error banner. Wired alongside the dialog Esc
93/// handler so a single keystroke dismisses any blocking surface.
94pub fn dismiss_error_banner_on_escape(
95    mut keys: bevy_ecs::message::MessageReader<lumen_core::input::KeyPressed>,
96    mut banner: bevy_ecs::system::ResMut<ErrorBanner>,
97) {
98    if banner.0.is_none() {
99        keys.read().for_each(drop);
100        return;
101    }
102    let pressed = keys.read().any(|k| {
103        matches!(
104            &k.key,
105            lumen_core::input::Key::Named(lumen_core::input::NamedKey::Escape)
106        )
107    });
108    if pressed {
109        banner.0 = None;
110    }
111}
112
113/// W4.7: detect a runtime change to the root entity's [`LumenClasses`]
114/// (from `set_root_class("theme-dark")`, OS theme follow, etc) and bump
115/// [`StyleVersion`] so downstream consumers (cascade re-resolver,
116/// `Visuals` / `TextStyle` recompute) know the computed style for any
117/// rule whose match set intersects the changed class list is now stale.
118///
119/// Pre-W4.7 this respawned the entire markup tree, reread `main.css`
120/// from disk, and round-tripped every entity through a state
121/// snapshot/restore pass. That broke on shipped binaries (no source
122/// `main.css`), dropped any ECS state outside the snapshot whitelist,
123/// and cost O(tree) per flip. See `docs/audits/theming.md` section 6.
124///
125/// Today the function:
126/// 1. Reads the latest [`lumen_scene::spawn::DocumentRoot`] `LumenClasses` (already mutated
127///    in place by [`lumen_core::signals::apply_theme_signal_to_root_classes`]
128///    or by `set_root_class`).
129/// 2. Diffs against [`RootClassesCache`]; no-ops when unchanged.
130/// 3. Consults [`StyleInvalidationCache::diff_affects`] - when no
131///    selector in the parsed stylesheet mentions any changed class,
132///    nothing can re-resolve, so we update the cache and exit.
133/// 4. Otherwise bumps [`StyleVersion`]. The cache + downstream
134///    `Changed<StyleVersion>` consumers re-resolve only entities whose
135///    style depended on the changed classes / media features. The
136///    parsed `Stylesheet` is reused - no disk read, no respawn.
137pub(crate) fn reapply_styles_on_root_class_change(world: &mut World) {
138    let Some(root) = world
139        .get_resource::<lumen_scene::spawn::DocumentRoot>()
140        .map(|r| r.0)
141    else {
142        return;
143    };
144    // Alloc-free change gate: compare the live `Arc<str>` class list
145    // against the cached `Vec<String>` in place. Only when they actually
146    // differ do we materialise the owned `Vec<String>` copies the
147    // diff/cache paths below need - steady-state ticks (the overwhelming
148    // majority) never allocate here.
149    let Some(classes) = world.get::<lumen_core::components::LumenClasses>(root) else {
150        return;
151    };
152    let unchanged = match world.get_resource::<RootClassesCache>() {
153        Some(c) => {
154            c.0.len() == classes.0.len()
155                && c.0
156                    .iter()
157                    .zip(classes.0.iter())
158                    .all(|(a, b)| a.as_str() == b.as_ref())
159        }
160        // Absent cache behaves as an empty one (the old `unwrap_or_default`).
161        None => classes.0.is_empty(),
162    };
163    if unchanged {
164        return;
165    }
166    // Changed - now copy into owned `Vec<String>`s for the diff/cache paths.
167    let current: Vec<String> = classes.0.iter().map(|s| s.to_string()).collect();
168    let cached = world
169        .get_resource::<RootClassesCache>()
170        .map(|c| c.0.clone())
171        .unwrap_or_default();
172    // Fast rejection: when none of the changed classes appears in any
173    // CSS selector, no computed style can have re-resolved. Update the
174    // cache and exit without bumping `StyleVersion` - saves downstream
175    // re-resolution passes from a no-op tick.
176    if let Some(inv) = world.get_resource::<StyleInvalidationCache>()
177        && !inv.classes.is_empty()
178        && !inv.diff_affects(&cached, &current)
179    {
180        if let Some(mut cache) = world.get_resource_mut::<RootClassesCache>() {
181            cache.0 = current;
182        }
183        return;
184    }
185    // In-place path: bump `StyleVersion` so downstream consumers know
186    // computed style for affected entities is stale, then update the
187    // class cache. No despawn, no disk re-read, no IR rebuild - the
188    // existing entity tree retains every component the snapshot/restore
189    // path used to lose.
190    StyleVersion::bump(world);
191    if let Some(mut cache) = world.get_resource_mut::<RootClassesCache>() {
192        cache.0 = current;
193    } else {
194        world.insert_resource(RootClassesCache(current));
195    }
196}
197
198/// Make sure the root carries a
199/// [`LumenClasses`](lumen_core::components::LumenClasses) list.
200///
201/// The spawn pass attaches that component only to elements whose markup
202/// declared a class, and a root usually declares none. Without it the OS
203/// theme follow has nothing to write `theme-dark` / `theme-light` onto. An
204/// empty list is the class list a root that declares none has, so giving it
205/// one costs nothing and every writer finds it there.
206///
207/// A window's, and not every assembly's: the theme a class follows is the
208/// one the OS reports, and a browser page has no such reading. Writing
209/// `theme-light` onto a page's root because nothing said otherwise would
210/// hold every visitor to the light theme.
211pub(crate) fn install_root_class_list(world: &mut World, root: Entity) {
212    use lumen_core::components::LumenClasses;
213    if world.get::<LumenClasses>(root).is_none() {
214        world
215            .entity_mut(root)
216            .insert(LumenClasses::from(Vec::<String>::new()));
217    }
218}
219
220/// Cached snapshot of the root entity's `LumenClasses` so
221/// [`reapply_styles_on_root_class_change`] can detect when a
222/// `set_root_class(...)` actually changed something (avoids spurious
223/// [`StyleVersion`] bumps on no-op writes).
224#[derive(Resource, Default, Debug, Clone)]
225pub(crate) struct RootClassesCache(pub(crate) Vec<String>);
226
227/// Cross-thread payload pushed by the W4.6 `set_color_scheme(name)`
228/// Rhai builtin. Carries the parsed
229/// [`lumen_core::components::ColorScheme`] intent over the bounded
230/// [`lumen_core::command::CommandQueue`] so the main-thread handler
231/// can apply it via [`lumen_core::components::StyleManager::set_scheme`]
232/// during [`TickStage::CommandDrain`].
233pub struct ColorSchemeIntent(pub lumen_core::components::ColorScheme);
234
235/// Per-tick LRU eviction sweep that reads [`MemoryBudget`] and calls each ECS-resident cache's `evict_until` so its `bytes_used` falls below the cap.
236/// `AssetServer` and the text shaper are handled here; a renderer's `lumen_paint::FragmentCache` bounds itself by entry count.
237pub(crate) fn enforce_budget(
238    budget: Res<lumen_core::components::MemoryBudget>,
239    mut server: ResMut<lumen_assets::AssetServer>,
240    shaper: Option<NonSendMut<ShaperService>>,
241) {
242    let image_cap = (budget.images_mb as usize).saturating_mul(1024 * 1024);
243    if server.bytes_used() > image_cap {
244        server.evict_until(image_cap);
245    }
246    if let Some(mut s) = shaper {
247        let cap = budget.shape_entries as usize;
248        if s.cache_len() > cap {
249            s.set_capacity(cap);
250        }
251    }
252}
253/// Union of every class name mentioned by every selector in the
254/// user CSS + active skin CSS, plus per-media-feature flags. Populated
255/// once at startup.
256///
257/// - The class set drives `reapply_styles_on_root_class_change` as a
258///   fast-rejection filter: when no class in the symmetric diff
259///   (between cached and current root classes) appears in this set,
260///   no selector could match anything new, and the expensive
261///   disk-reload + respawn is skipped.
262/// - The media-feature flags (W4.4) record which `@media` features the
263///   stylesheet exercises, so the runtime can decide whether to
264///   re-resolve styles when `StyleManager` / viewport width / motion / contrast
265///   preferences change. W4.7 lands the actual in-place re-resolve; until
266///   then this is a recompute trigger surface.
267///
268/// Pattern: Blink/Stylo's `RuleFeatureSet` - pre-compute which class
269/// changes can possibly affect styling so most class-change events
270/// are no-ops at zero cost.
271#[derive(Resource, Default, Debug, Clone)]
272pub(crate) struct StyleInvalidationCache {
273    classes: std::collections::HashSet<String>,
274    /// Which `@media` features the stylesheet exercises. Read by
275    /// `detect_media_change` to decide whether a theme / viewport flip
276    /// can affect styling at all before bumping `StyleVersion`.
277    media_features: MediaFeatureUsage,
278    /// Sorted, de-duplicated viewport-width thresholds (`min-width` /
279    /// `max-width` / `width` px values) mentioned by any `@media` rule.
280    /// `detect_media_change` treats a resize as a breakpoint crossing
281    /// only when old and new widths land on opposite sides of one of
282    /// these - a plain drag-resize inside a band never re-resolves.
283    width_breakpoints: Vec<f32>,
284    /// Some selector steps across siblings (`+` / `~`), so an element's
285    /// class change can restyle the siblings after it.
286    sibling_combinators: bool,
287}
288
289/// Which `@media` features the stylesheet contains at least one rule
290/// guarded by. Each flag, when set, tells the runtime to re-resolve
291/// computed styles when the matching context bit changes.
292#[derive(Default, Debug, Clone, Copy)]
293#[allow(dead_code)]
294struct MediaFeatureUsage {
295    color_scheme: bool,
296    reduced_motion: bool,
297    contrast: bool,
298    viewport_width: bool,
299}
300
301impl StyleInvalidationCache {
302    /// Build from the already-combined (skin + user) stylesheet carried on
303    /// the IR. Works off the parsed AST - no CSS source string and no
304    /// re-parse - so it is identical for the parse-from-source and the
305    /// AOT-artifact load paths, and never pulls in the source parser.
306    pub(crate) fn from_stylesheet(sheet: &lumen_ir::css::Stylesheet) -> Self {
307        let mut media_features = MediaFeatureUsage::default();
308        let mut breakpoints: Vec<f32> = Vec::new();
309        let classes = sheet.class_invalidation_set();
310        collect_media_usage(sheet, &mut media_features);
311        collect_width_breakpoints(sheet, &mut breakpoints);
312        breakpoints.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
313        breakpoints.dedup();
314        Self {
315            classes,
316            media_features,
317            width_breakpoints: breakpoints,
318            sibling_combinators: sheet.uses_sibling_combinators(),
319        }
320    }
321
322    /// Symmetric-difference of two class lists includes ANY name in
323    /// the invalidation set?
324    fn diff_affects(&self, old: &[String], new: &[String]) -> bool {
325        let old_set: std::collections::HashSet<&String> = old.iter().collect();
326        let new_set: std::collections::HashSet<&String> = new.iter().collect();
327        old_set
328            .symmetric_difference(&new_set)
329            .any(|c| self.classes.contains(c.as_str()))
330    }
331}
332
333/// Walk a parsed stylesheet and OR-in any media feature it touches.
334/// Used by `StyleInvalidationCache::build`.
335fn collect_media_usage(sheet: &lumen_ir::css::Stylesheet, out: &mut MediaFeatureUsage) {
336    use lumen_ir::css::MediaFeature;
337    for rule in &sheet.rules {
338        let Some(mq) = &rule.media else { continue };
339        for feat in &mq.features {
340            match feat {
341                MediaFeature::PrefersColorScheme(_) => out.color_scheme = true,
342                MediaFeature::PrefersReducedMotion(_) => out.reduced_motion = true,
343                MediaFeature::PrefersContrast(_) => out.contrast = true,
344                MediaFeature::MinWidth(_) | MediaFeature::MaxWidth(_) | MediaFeature::Width(_) => {
345                    out.viewport_width = true
346                }
347            }
348        }
349    }
350}
351
352/// Collect the px thresholds of every width `@media` feature into `out`.
353/// Used to build [`StyleInvalidationCache::width_breakpoints`].
354fn collect_width_breakpoints(sheet: &lumen_ir::css::Stylesheet, out: &mut Vec<f32>) {
355    use lumen_ir::css::MediaFeature;
356    for rule in &sheet.rules {
357        let Some(mq) = &rule.media else { continue };
358        for feat in &mq.features {
359            match feat {
360                MediaFeature::MinWidth(px)
361                | MediaFeature::MaxWidth(px)
362                | MediaFeature::Width(px) => out.push(*px),
363                _ => {}
364            }
365        }
366    }
367}
368
369/// Live copy of the combined (skin + user) stylesheet, kept as a
370/// resource so [`reapply_computed_styles`] can re-run the cascade on a
371/// theme / media flip without re-reading `main.css` from disk. Refreshed
372/// on every hot reload so an edited stylesheet re-resolves correctly.
373#[derive(Resource, Clone)]
374pub(crate) struct RuntimeStylesheet(pub(crate) lumen_ir::css::Stylesheet);
375
376/// The last [`MediaContext`] observed by [`detect_media_change`]. Used
377/// to detect a color-scheme flip or a viewport-width breakpoint crossing
378/// between ticks. `None` until the first post-window tick, which forces
379/// one re-resolve against the real OS context (the initial load applied
380/// with the default best-guess context before the window existed).
381#[derive(Resource, Clone, Copy)]
382pub(crate) struct LastMediaContext(pub(crate) lumen_ir::css::MediaContext);
383
384/// The [`StyleVersion`] value [`reapply_computed_styles`] last consumed.
385/// The re-resolver re-walks entities only when the live `StyleVersion`
386/// has moved past this, so a steady-state tick is a single integer
387/// compare.
388#[derive(Resource, Clone, Copy)]
389pub(crate) struct AppliedStyleVersion(pub(crate) u64);
390
391/// Build a [`MediaContext`] from the live [`StyleManager`] (color scheme)
392/// and [`Viewport`] (logical width). Missing resources (pre-window)
393/// leave the corresponding field unknown so no `@media` rule matches
394/// spuriously.
395pub(crate) fn media_context_from_world(world: &World) -> lumen_ir::css::MediaContext {
396    use lumen_ir::css::{ColorSchemePreference, MediaContext};
397    let color_scheme = world
398        .get_resource::<lumen_core::components::StyleManager>()
399        .map(|sm| {
400            if sm.effective_dark {
401                ColorSchemePreference::Dark
402            } else {
403                ColorSchemePreference::Light
404            }
405        });
406    let viewport_width = world
407        .get_resource::<lumen_core::prelude::Viewport>()
408        .map(|v| v.size.x);
409    MediaContext {
410        color_scheme,
411        viewport_width,
412        ..Default::default()
413    }
414}
415
416/// W4.7 (media half): detect an OS color-scheme flip or a viewport-width
417/// resize that crosses a stylesheet breakpoint, and bump [`StyleVersion`]
418/// so [`reapply_computed_styles`] re-resolves. The first post-window tick
419/// (no [`LastMediaContext`] yet) always bumps once, replacing the
420/// best-guess context the pre-window initial load applied with.
421///
422/// Bounded: a color-scheme flip only bumps when the stylesheet actually
423/// contains a `prefers-color-scheme` rule; a resize only bumps when it
424/// crosses one of [`StyleInvalidationCache::width_breakpoints`]. A plain
425/// drag-resize inside a band, or a theme flip on a stylesheet with no
426/// `@media`, is a cheap no-op.
427pub(crate) fn detect_media_change(world: &mut World) {
428    let media = media_context_from_world(world);
429    let prev = world.get_resource::<LastMediaContext>().map(|m| m.0);
430    // Borrow the invalidation cache in place - the breakpoint list was
431    // being cloned into a fresh `Vec` every tick just to be read once.
432    // Scoping the borrow lets us drop it before the mutable insert below.
433    let changed = match prev {
434        // First observation with a real context: force one re-resolve.
435        None => true,
436        Some(prev) => {
437            let cache = world.get_resource::<StyleInvalidationCache>();
438            let uses_scheme = cache
439                .map(|c| c.media_features.color_scheme)
440                .unwrap_or(false);
441            let scheme_changed = uses_scheme && prev.color_scheme != media.color_scheme;
442            let width_crossed = match (prev.viewport_width, media.viewport_width) {
443                (Some(o), Some(n)) => cache
444                    .map(|c| c.width_breakpoints.iter().any(|t| (o >= *t) != (n >= *t)))
445                    .unwrap_or(false),
446                (None, Some(_)) | (Some(_), None) => cache
447                    .map(|c| !c.width_breakpoints.is_empty())
448                    .unwrap_or(false),
449                (None, None) => false,
450            };
451            scheme_changed || width_crossed
452        }
453    };
454    // Always refresh the baseline so breakpoint-crossing detection tracks
455    // the latest width even on ticks that don't bump.
456    world.insert_resource(LastMediaContext(media));
457    if changed {
458        StyleVersion::bump(world);
459    }
460}
461
462/// Re-resolve elements whose styles may have changed and rebuild their
463/// style components from scratch.
464///
465/// Two triggers. A bump of [`StyleVersion`] (a theme, a breakpoint, a root
466/// class, a tree edit) restyles every element of the document. An element
467/// marked [`StyleDirty`] (its own classes, id, inline style or disabled
468/// state changed) restyles itself and everything under it, which is every
469/// element whose styles such a change can reach. A steady tick costs one
470/// integer compare and one empty query.
471///
472/// Each element is resolved the way the first cascade resolved it, through
473/// [`lumen_ir::css::RestyleWalk`]: from its tag, classes, id and place in
474/// the tree, with what its markup declares and what a script set with
475/// `set_style` ranking above every rule, against the user-agent sheet, the
476/// skin and the app's stylesheet. The walk runs top-down, so each element
477/// inherits from the parent it just resolved. The result replaces the
478/// element's style components through
479/// [`ComputedStyle::commit`](lumen_scene::computed::ComputedStyle::commit):
480/// a property nothing sets any more goes back to its initial value, and a
481/// value a runtime system holds in
482/// [`StyleOverrides`](lumen_core::style_overrides::StyleOverrides) stays on
483/// top of it.
484pub(crate) fn reapply_computed_styles(world: &mut World) {
485    use lumen_core::components::{LumenTag, StyleDirty};
486    let version = world
487        .get_resource::<StyleVersion>()
488        .map(|v| v.0)
489        .unwrap_or(0);
490    let full = world
491        .get_resource::<AppliedStyleVersion>()
492        .is_none_or(|a| a.0 != version);
493    let mut dirty_q = world.query_filtered::<Entity, With<StyleDirty>>();
494    let dirty: Vec<Entity> = dirty_q.iter(world).collect();
495    if !full && dirty.is_empty() {
496        return;
497    }
498    for &entity in &dirty {
499        world.entity_mut(entity).remove::<StyleDirty>();
500    }
501    world.insert_resource(AppliedStyleVersion(version));
502
503    // The app's stylesheet styles the app's document. A tree mounted as a
504    // root of its own beside it (a dev tool's panel, styled by its own
505    // sheet when it was spawned) is left alone, as the first cascade left
506    // it alone.
507    let document = world
508        .get_resource::<lumen_scene::spawn::DocumentRoot>()
509        .map(|r| r.0);
510    let roots: Vec<Entity> = if full {
511        match document {
512            Some(root) => vec![root],
513            None => {
514                let mut q = world.query_filtered::<Entity, (With<LumenTag>, Without<ChildOf>)>();
515                q.iter(world).collect()
516            }
517        }
518    } else {
519        // With a `+` / `~` rule in the sheet, an element's change reaches
520        // the siblings after it, so its parent's subtree restyles.
521        let siblings = world
522            .get_resource::<StyleInvalidationCache>()
523            .is_some_and(|c| c.sibling_combinators);
524        let dirty: Vec<Entity> = dirty
525            .iter()
526            .copied()
527            .filter(|&e| world.get_entity(e).is_ok())
528            .map(|e| match world.get::<ChildOf>(e) {
529                Some(c) if siblings => c.parent(),
530                _ => e,
531            })
532            .collect();
533        // The topmost dirty elements: one under another restyles with it.
534        let set: std::collections::HashSet<Entity> = dirty.iter().copied().collect();
535        dirty
536            .iter()
537            .copied()
538            .filter(|&e| document.is_none_or(|root| topmost_ancestor(world, e) == root))
539            .filter(|&e| !ancestors_of(world, e).iter().any(|a| set.contains(a)))
540            .collect::<std::collections::BTreeSet<Entity>>()
541            .into_iter()
542            .collect()
543    };
544    if roots.is_empty() {
545        return;
546    }
547
548    let media = world
549        .get_resource::<LastMediaContext>()
550        .map(|m| m.0)
551        .unwrap_or_default();
552    // Restyle tweens only make sense while the window can animate: an
553    // unfocused or occluded window's redraw scheduler is paused, so a tween
554    // started there would freeze mid-flight until focus returns. Snap
555    // instead, which is what every native toolkit shows when the OS theme
556    // flips a background window.
557    let animate = world
558        .get_resource::<lumen_core::window_backend::RedrawScheduler>()
559        .map(|s| !s.paused)
560        .unwrap_or(true);
561    let sheet = world
562        .remove_resource::<RuntimeStylesheet>()
563        .map(|s| s.0)
564        .unwrap_or_default();
565    let siblings = sheet.uses_sibling_combinators();
566    let mut warnings = Vec::new();
567    for root in roots {
568        let ancestors: Vec<lumen_ir::css::AncestorInfo> = ancestors_of(world, root)
569            .into_iter()
570            .rev()
571            .map(|a| {
572                let place = place_of(world, a, siblings);
573                cascade_node(world, a, place, !tagged_children(world, a).is_empty())
574            })
575            .collect();
576        let mut walk = lumen_ir::css::RestyleWalk::new(&sheet, &media, &ancestors);
577        let place = place_of(world, root, siblings);
578        restyle_subtree(world, &mut walk, root, place, siblings, animate);
579        warnings.extend(walk.take_warnings());
580    }
581    world.insert_resource(RuntimeStylesheet(sheet));
582    for w in warnings {
583        tracing::debug!("restyle: {w}");
584    }
585}
586
587/// Where an element sits among its element siblings, and who they are
588/// when the stylesheet steps across siblings.
589#[derive(Default)]
590struct Place {
591    position: lumen_ir::css::SiblingPosition,
592    siblings: Option<lumen_ir::css::Siblings>,
593}
594
595/// Resolve `entity` against `walk` and everything under it, rebuilding
596/// each element's style components.
597fn restyle_subtree(
598    world: &mut World,
599    walk: &mut lumen_ir::css::RestyleWalk<'_>,
600    entity: Entity,
601    place: Place,
602    siblings: bool,
603    animate: bool,
604) {
605    use lumen_core::components::LumenTag;
606    let children: Vec<Entity> = world
607        .get::<bevy_ecs::hierarchy::Children>(entity)
608        .map(|c| c.iter().collect())
609        .unwrap_or_default();
610    let element = world.get::<LumenTag>(entity).is_some();
611    // A renderer-internal leaf (a toggle's knob, say) has no styles of its
612    // own to resolve.
613    if !element && children.is_empty() {
614        return;
615    }
616    let tagged: Vec<Entity> = children
617        .iter()
618        .copied()
619        .filter(|&k| world.get::<LumenTag>(k).is_some())
620        .collect();
621    let node = cascade_node(world, entity, place, !tagged.is_empty());
622    let mut attrs = walk.resolve(&node);
623    if element {
624        commit_resolved(world, entity, &mut attrs, animate);
625    }
626    if children.is_empty() {
627        return;
628    }
629    // One sibling list per parent, shared by every child of it.
630    let peers =
631        siblings.then(|| lumen_ir::css::Siblings::new(tagged.iter().map(|&k| identity(world, k))));
632    let count = tagged.len() as i32;
633    let mut index = 0;
634    walk.enter();
635    for child in children {
636        // `tagged` is `children` with the non-elements left out, so the
637        // elements come up in its order.
638        let place = if tagged.get(index) == Some(&child) {
639            index += 1;
640            Place {
641                position: lumen_ir::css::SiblingPosition::new(index as i32, count),
642                siblings: peers.clone(),
643            }
644        } else {
645            Place::default()
646        };
647        restyle_subtree(world, walk, child, place, siblings, animate);
648    }
649    walk.leave();
650}
651
652/// Build the element's style components from what the cascade resolved
653/// for it and commit them over the live ones.
654fn commit_resolved(world: &mut World, entity: Entity, attrs: &mut Attributes, animate: bool) {
655    use lumen_core::components::{BindDisabled, Disabled, LumenTag};
656    use lumen_ir::layout_ir::WidgetPart;
657    use lumen_scene::computed::{ComputedStyle, StyleContext};
658    let Some(tag) = world.get::<LumenTag>(entity).map(|t| t.0.clone()) else {
659        return;
660    };
661    // What the element was written with that its styles are computed from
662    // but no rule can set.
663    attrs.style_role = world
664        .get::<lumen_scene::spawn::TypographyRole>(entity)
665        .map(|r| r.0.to_string());
666    let in_scroll = world
667        .get::<ChildOf>(entity)
668        .is_some_and(|c| world.get::<lumen_core::input::Scroll>(c.parent()).is_some());
669    let has = |world: &World, f: fn(&bevy_ecs::world::EntityRef<'_>) -> bool| {
670        world.get_entity(entity).is_ok_and(|e| f(&e))
671    };
672    let part = if has(world, |e| e.contains::<lumen_primitives::CheckboxBox>()) {
673        Some(WidgetPart::CheckboxBox)
674    } else if has(world, |e| e.contains::<lumen_primitives::RadioDot>()) {
675        Some(WidgetPart::RadioDot)
676    } else if has(world, |e| e.contains::<lumen_primitives::ProgressFill>()) {
677        Some(WidgetPart::ProgressFill)
678    } else {
679        None
680    };
681    let bind_disabled = has(world, |e| e.contains::<BindDisabled>());
682    let ctx = StyleContext {
683        tag: &tag,
684        static_disabled: has(world, |e| e.contains::<Disabled>()) && !bind_disabled,
685        bind_disabled,
686        part,
687        tab_strip: has(world, |e| e.contains::<lumen_primitives::TabStripButton>()),
688        dropdown_button: has(world, |e| e.contains::<lumen_primitives::DropdownButton>()),
689        radio: has(world, |e| e.contains::<lumen_primitives::RadioButton>()),
690        page_gate: world
691            .get::<lumen_scene::spawn::IfMarker>(entity)
692            .is_some_and(|m| m.signal_name == lumen_core::nav::PATH_SIGNAL),
693        virtualized_for: world
694            .get::<lumen_scene::spawn::ForMarker>(entity)
695            .is_some_and(|m| m.virtualized),
696        in_scroll,
697        layout_boundary: attrs.layout_boundary,
698    };
699    let computed = ComputedStyle::new(&ctx, attrs);
700    let mut ent = world.entity_mut(entity);
701    computed.commit(&mut ent, animate);
702    lumen_scene::spawn::apply_background_image(&mut ent, &tag, attrs);
703}
704
705/// An element's tag, classes and id, as selectors match them.
706fn identity(world: &World, e: Entity) -> (String, Vec<String>, Option<String>) {
707    use lumen_core::components::{LumenClasses, LumenId, LumenTag};
708    (
709        world
710            .get::<LumenTag>(e)
711            .map(|t| t.0.to_string())
712            .unwrap_or_default(),
713        world
714            .get::<LumenClasses>(e)
715            .map(|c| c.0.iter().map(|s| s.to_string()).collect())
716            .unwrap_or_default(),
717        world.get::<LumenId>(e).map(|i| i.0.clone()),
718    )
719}
720
721/// The children of `e` that are elements. Only those count as siblings: a
722/// renderer-internal child added beside a row does not shift the row's
723/// `:nth-child`.
724fn tagged_children(world: &World, e: Entity) -> Vec<Entity> {
725    world
726        .get::<bevy_ecs::hierarchy::Children>(e)
727        .map(|c| {
728            c.iter()
729                .filter(|&k| world.get::<lumen_core::components::LumenTag>(k).is_some())
730                .collect()
731        })
732        .unwrap_or_default()
733}
734
735/// Where `entity` sits under its parent, read off the parent's children.
736/// The walk hands this down instead for every element below its root.
737fn place_of(world: &World, entity: Entity, siblings: bool) -> Place {
738    let Some(parent) = world.get::<ChildOf>(entity).map(|c| c.parent()) else {
739        return Place::default();
740    };
741    let peers = tagged_children(world, parent);
742    let Some(i) = peers.iter().position(|&p| p == entity) else {
743        return Place::default();
744    };
745    Place {
746        position: lumen_ir::css::SiblingPosition::new(i as i32 + 1, peers.len() as i32),
747        siblings: siblings
748            .then(|| lumen_ir::css::Siblings::new(peers.iter().map(|&k| identity(world, k)))),
749    }
750}
751
752/// One element as the cascade sees it: its identity and `place`, whether
753/// it has element children and what text it holds (for `:empty`), and its
754/// own declarations, its markup's and then its inline style's.
755fn cascade_node(
756    world: &World,
757    entity: Entity,
758    place: Place,
759    has_element_children: bool,
760) -> lumen_ir::css::AncestorInfo {
761    use lumen_core::components::{InlineStyle, MarkupStyle};
762    let (tag, classes, id) = identity(world, entity);
763    let text = world
764        .get::<lumen_core::components::TextContent>(entity)
765        .map(|t| t.0.clone());
766    let mut declarations: Vec<(String, String)> = world
767        .get::<MarkupStyle>(entity)
768        .map(|m| m.0.to_vec())
769        .unwrap_or_default();
770    if let Some(inline) = world.get::<InlineStyle>(entity) {
771        declarations.extend(inline.0.iter().cloned());
772    }
773    lumen_ir::css::AncestorInfo::new(tag, classes, id)
774        .with_position(place.position)
775        .with_siblings(place.siblings)
776        .with_content(has_element_children, text)
777        .with_declarations(declarations)
778}
779
780/// `entity`'s ancestors, nearest first.
781fn ancestors_of(world: &World, entity: Entity) -> Vec<Entity> {
782    let mut out = Vec::new();
783    let mut cur = entity;
784    // The same cap the cascade's ancestor walk always had: a restyle must
785    // never hang the tick.
786    for _ in 0..256 {
787        match world.get::<ChildOf>(cur) {
788            Some(c) => {
789                out.push(c.parent());
790                cur = c.parent();
791            }
792            None => break,
793        }
794    }
795    out
796}
797
798/// The root of the tree `entity` sits in: the last `ChildOf` parent up
799/// from it, or `entity` itself when it has none.
800fn topmost_ancestor(world: &World, entity: Entity) -> Entity {
801    ancestors_of(world, entity)
802        .last()
803        .copied()
804        .unwrap_or(entity)
805}