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, ¤t)
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}