Skip to main content

lumen_scene/
computed.rs

1//! An element's computed style: the components its resolved attributes
2//! become, built one way for spawning and for a restyle.
3//!
4//! [`ComputedStyle::new`] turns the cascade's result for one element into
5//! every style component it carries: the layout [`Style`], the paint, the
6//! text style, the state patches, the widget palettes. Spawning inserts
7//! them; a restyle [commits](ComputedStyle::commit) them over the live
8//! components, which removes what the cascade no longer sets, keeps what a
9//! runtime system holds in [`StyleOverrides`], and starts a `transition`
10//! where one is declared.
11
12use bevy_ecs::prelude::*;
13use bevy_ecs::world::EntityWorldMut;
14use lumen_core::components::{
15    CaretWidth, Color, Fill, ImageFit, Length, Opacity, PasswordCharacter, ShadowSpec, Style,
16    TextInputPaint, TextStyle, Visuals, ZIndex,
17};
18use lumen_core::input::ScrollbarStyle;
19use lumen_core::style_overrides::{StyleOverrides, StyleValues};
20use lumen_ir::layout_ir::{Attributes, WidgetPart};
21use lumen_primitives::{
22    BackgroundTransition, BorderColorTransition, Interaction, OpacityTransition, StatePatch,
23    StateVisuals, TextColorTransition, TransitionProperty, TransitionSpec, TransitionSpecs,
24    retarget,
25};
26
27use crate::spawn::{DISABLED_OPACITY_DEFAULT, apply_ua_style_defaults, track_style_over};
28
29/// What an element is, beyond its styles, that decides how its styles
30/// become components. Spawning reads it off the element's attributes; a
31/// restyle reads it off the components spawning attached.
32#[derive(Clone, Copy, Debug, Default)]
33pub struct StyleContext<'a> {
34    /// The element's tag.
35    pub tag: &'a str,
36    /// Disabled with no `bind-disabled` to change it: the `:disabled`
37    /// styles apply as the element's own.
38    pub static_disabled: bool,
39    /// Carries `bind-disabled`: the `:disabled` styles become a state
40    /// patch that comes and goes with the marker.
41    pub bind_disabled: bool,
42    /// The widget part the parser synthesized it as, if any.
43    pub part: Option<WidgetPart>,
44    /// A `<tabs>` strip button.
45    pub tab_strip: bool,
46    /// A `<dropdown>`'s header button.
47    pub dropdown_button: bool,
48    /// A `<radio>` that names its group and value.
49    pub radio: bool,
50    /// The gate a page of a multi-page app mounts inside.
51    pub page_gate: bool,
52    /// A virtualized `<for>` block.
53    pub virtualized_for: bool,
54    /// The element's parent is a scroll container.
55    pub in_scroll: bool,
56    /// Asked for a relayout boundary in markup (`layout-boundary`).
57    pub layout_boundary: bool,
58}
59
60impl<'a> StyleContext<'a> {
61    /// The context spawning reads off an element's attributes.
62    pub fn of_attrs(tag: &'a str, attrs: &Attributes, in_scroll: bool) -> Self {
63        Self {
64            tag,
65            static_disabled: attrs.disabled && attrs.bind_disabled.is_none(),
66            bind_disabled: attrs.bind_disabled.is_some(),
67            part: attrs.part,
68            tab_strip: attrs.tab_strip.is_some(),
69            dropdown_button: attrs.dropdown_button.is_some(),
70            radio: tag == "radio" && attrs.radio_group.is_some() && attrs.radio_value.is_some(),
71            page_gate: tag == "if"
72                && attrs.if_signal.as_deref() == Some(lumen_core::nav::PATH_SIGNAL),
73            virtualized_for: tag == "for" && attrs.virtualized,
74            in_scroll,
75            layout_boundary: attrs.layout_boundary,
76        }
77    }
78}
79
80/// Every style component one element carries, computed from its resolved
81/// attributes. `None` is a component it does not carry.
82#[derive(Clone, Debug)]
83pub struct ComputedStyle {
84    /// Layout.
85    pub style: Style,
86    /// Whether layout may stop its dirty walk at this element.
87    pub relayout_boundary: bool,
88    /// Paint.
89    pub visuals: Option<Visuals>,
90    /// Text.
91    pub text: Option<TextStyle>,
92    /// Opacity; absent is fully opaque.
93    pub opacity: Option<f32>,
94    /// Sibling paint order.
95    pub z_index: Option<i32>,
96    /// Hover / press tints, focus rings, state borders.
97    pub interaction: Option<Interaction>,
98    /// `:hover` / `:focus` / `:active` / `:drag-over` / `:disabled` patches.
99    pub state_visuals: Option<StateVisuals>,
100    /// The `transition` declarations.
101    pub transitions: Vec<TransitionSpec>,
102    /// Overlay scrollbar styling.
103    pub scrollbar: Option<ScrollbarStyle>,
104    /// `fit` for an image.
105    pub image_fit: Option<ImageFit>,
106    /// Caret and selected-glyph paint of a text field.
107    pub input_paint: Option<TextInputPaint>,
108    /// Caret width of a text field.
109    pub caret_width: Option<CaretWidth>,
110    /// Mask character of a text field.
111    pub password_character: Option<PasswordCharacter>,
112    /// `caret-blink`, which lands on the one shared blink clock.
113    pub caret_blink_ms: Option<u32>,
114    /// A `<toggle>` / `<switch>` track palette.
115    pub track: Option<lumen_primitives::TrackStyle>,
116    /// A `<checkbox>`'s checked fill.
117    pub checkbox: Option<lumen_primitives::CheckboxStyle>,
118    /// A `<radio>`'s selected fill.
119    pub radio: Option<lumen_primitives::RadioStyle>,
120    /// A tab strip button's palette.
121    pub tab_button: Option<lumen_primitives::TabButtonStyle>,
122    /// An indeterminate `<progress>`'s sweep width.
123    pub progress_chunk: Option<lumen_primitives::progress::ProgressChunk>,
124    /// The gap a dropdown's panel opens at.
125    pub popup_gap: Option<lumen_primitives::popup::PopupGap>,
126    /// Knob and thumb geometry of a `<toggle>` / `<switch>` / `<slider>`.
127    pub knob_geometry: Option<lumen_primitives::controls::KnobGeometry>,
128    /// Knob and thumb fill.
129    pub knob_fill: Color,
130}
131
132impl ComputedStyle {
133    /// Compute the style components of an element from its resolved
134    /// attributes.
135    pub fn new(ctx: &StyleContext<'_>, attrs: &Attributes) -> Self {
136        let tag = ctx.tag;
137        let mut style = Style::from(attrs);
138        apply_ua_style_defaults(tag, attrs, ctx.page_gate, &mut style);
139        // The content of a scroll container keeps its own size instead of
140        // being squeezed into the viewport - that is what there is to
141        // scroll. Every layout here is flex, so without this a `height:
142        // 900` block inside a 200px-tall scroller shrinks to 200 and the
143        // scrollbar has nothing to travel over. Qt's `QScrollArea` keeps
144        // its widget's size hint and Slint's `ScrollView` its content's
145        // preferred size; on the web the same shape needs `flex-shrink: 0`
146        // written by hand. An authored `shrink` still wins.
147        if attrs.shrink.is_none() && ctx.in_scroll {
148            style.shrink = 0.0;
149        }
150        // A virtualized block pins its rows with `position: absolute`
151        // against itself, so left to shrink to content it would shrink to
152        // nothing. It fills its scroll parent instead, which gives the rows
153        // a real reference rect.
154        if ctx.virtualized_for {
155            style.width = Length::Percent(100.0);
156        }
157        let relayout_boundary = is_relayout_boundary(&style, ctx.layout_boundary, attrs);
158
159        let track = matches!(tag, "toggle" | "switch")
160            .then(|| track_style_over(lumen_primitives::TrackStyle::default(), attrs));
161        let tab_button = ctx.tab_strip.then(|| {
162            let defaults = lumen_primitives::TabButtonStyle::default();
163            lumen_primitives::TabButtonStyle {
164                selected_bg: attrs
165                    .selected_bg
166                    .map(Into::into)
167                    .unwrap_or(defaults.selected_bg),
168                unselected_bg: attrs
169                    .bg
170                    .as_ref()
171                    .and_then(|b| Fill::try_from(b).ok()?.as_solid())
172                    .unwrap_or(defaults.unselected_bg),
173            }
174        });
175
176        // Paint, with the fallback each widget paints when no skin or
177        // author rule styled it: a track, a groove, a field's chrome.
178        let mut visuals = Option::<Visuals>::from(attrs).or_else(|| {
179            let fallback = |fill: Color, radius: f32| Visuals {
180                fill: Some(Fill::Solid(fill)),
181                radius,
182                corner_radii: None,
183                shadows: Vec::new(),
184                border: None,
185            };
186            use lumen_primitives::controls::{TOGGLE_CHECKED_BG, TOGGLE_UNCHECKED_BG};
187            match (tag, ctx.part) {
188                ("toggle", _) => Some(fallback(track?.unchecked_bg, 12.0)),
189                ("switch", _) => Some(fallback(track?.unchecked_bg, 14.0)),
190                ("slider", _) => Some(fallback(TOGGLE_UNCHECKED_BG, 4.0)),
191                ("progress", _) => Some(fallback(TOGGLE_UNCHECKED_BG, 3.0)),
192                (_, Some(WidgetPart::CheckboxBox)) => Some(fallback(TOGGLE_UNCHECKED_BG, 4.0)),
193                (_, Some(WidgetPart::RadioDot)) => Some(fallback(TOGGLE_UNCHECKED_BG, 9.0)),
194                (_, Some(WidgetPart::ProgressFill)) => Some(fallback(TOGGLE_CHECKED_BG, 3.0)),
195                // An unstyled field is otherwise invisible, with no edge to
196                // aim the caret at. A subtle translucent fill works on both
197                // light and dark app themes.
198                ("input" | "textarea", _) => Some(fallback(Color::rgba(0.5, 0.5, 0.5, 0.18), 4.0)),
199                _ if ctx.tab_strip => Some(fallback(tab_button?.unselected_bg, 0.0)),
200                _ => None,
201            }
202        });
203        let mut text = Option::<TextStyle>::from(attrs);
204        let mut opacity = attrs.opacity;
205
206        // `disabled-opacity` (markup attr or the CSS property) wins;
207        // `DISABLED_OPACITY_DEFAULT` is the single Rust-side fallback.
208        let disabled_opacity_default = attrs
209            .disabled_opacity_default
210            .unwrap_or(DISABLED_OPACITY_DEFAULT);
211        let state_visuals = state_visuals(ctx, attrs, disabled_opacity_default);
212        if let Some(sv) = &state_visuals {
213            // A state text-color swap needs a TextStyle slot to write into
214            // even when the resting style authored none, and a state shadow
215            // or background swap a Visuals.
216            let patches = [
217                &sv.hover,
218                &sv.focus,
219                &sv.focus_visible,
220                &sv.active,
221                &sv.drag_over,
222                &sv.disabled,
223            ];
224            if patches.iter().any(|p| p.text_color.is_some()) && text.is_none() {
225                text = Some(TextStyle::default());
226            }
227            let needs_visuals = patches.iter().any(|p| p.shadows.is_some())
228                || sv.drag_over.bg.is_some()
229                || sv.disabled.bg.is_some();
230            if needs_visuals && visuals.is_none() {
231                visuals = Some(Visuals::default());
232            }
233        }
234
235        // A statically disabled element (no binding to re-enable it) takes
236        // its `:disabled` styles as its own.
237        if ctx.static_disabled {
238            if let Some(c) = attrs.disabled_text_color {
239                text.get_or_insert_with(TextStyle::default).color = c.into();
240            }
241            if let Some(o) = attrs.disabled_opacity {
242                opacity = Some(o);
243            }
244            if let Some(shadows) = &attrs.disabled_shadows {
245                visuals.get_or_insert_with(Visuals::default).shadows =
246                    shadows.iter().copied().map(Into::into).collect();
247            }
248            if let Some(c) = attrs.disabled_bg {
249                let v = visuals.get_or_insert_with(|| Visuals {
250                    radius: attrs.radius.unwrap_or(0.0),
251                    corner_radii: attrs.radius_corners,
252                    ..Visuals::default()
253                });
254                v.fill = Some(Fill::Solid(c.into()));
255            } else if attrs.opacity.is_none() && attrs.disabled_opacity.is_none() {
256                // The default disabled look when no `:disabled { bg }` /
257                // `:disabled { opacity }` rule supplied one: dim the whole
258                // element.
259                opacity = Some(disabled_opacity_default);
260            }
261        }
262
263        let is_input = matches!(tag, "input" | "textarea");
264        let knob = matches!(tag, "toggle" | "switch" | "slider");
265        Self {
266            style,
267            relayout_boundary,
268            visuals,
269            text,
270            opacity,
271            z_index: attrs.z_index,
272            interaction: Option::<Interaction>::from(attrs),
273            state_visuals,
274            transitions: attrs
275                .effective_transitions()
276                .iter()
277                .map(Into::into)
278                .collect(),
279            scrollbar: scrollbar_style(attrs),
280            image_fit: attrs.image_fit.map(ImageFit::from),
281            // Caret / selected-glyph paint rides its own component so
282            // non-input text never carries the extra row; absent, the
283            // renderer falls back (caret = text fill, selected glyphs keep
284            // their fill).
285            input_paint: (is_input
286                && (attrs.caret_color.is_some() || attrs.selection_text_color.is_some()))
287            .then(|| TextInputPaint {
288                caret_color: attrs.caret_color.map(Into::into),
289                selection_foreground: attrs.selection_text_color.map(Into::into),
290            }),
291            // Each component's own `Default` carries the runtime's
292            // fallback when neither markup nor CSS set one.
293            caret_width: is_input.then(|| attrs.caret_width.map(CaretWidth).unwrap_or_default()),
294            password_character: is_input.then(|| {
295                attrs
296                    .password_character
297                    .map(PasswordCharacter)
298                    .unwrap_or_default()
299            }),
300            caret_blink_ms: is_input.then_some(attrs.caret_blink_ms).flatten(),
301            track,
302            checkbox: (tag == "checkbox").then(|| lumen_primitives::CheckboxStyle {
303                // `checkbox:checked { bg }` routes here via checked_bg; the
304                // accent constant is the single Rust fallback.
305                checked_bg: attrs
306                    .checked_bg
307                    .map(Into::into)
308                    .unwrap_or(lumen_primitives::CheckboxStyle::default().checked_bg),
309            }),
310            radio: ctx.radio.then(|| lumen_primitives::RadioStyle {
311                // `radio:selected { bg }` routes here via selected_bg.
312                selected_bg: attrs
313                    .selected_bg
314                    .map(Into::into)
315                    .unwrap_or(lumen_primitives::RadioStyle::default().selected_bg),
316            }),
317            tab_button,
318            progress_chunk: (tag == "progress").then(|| {
319                attrs
320                    .progress_chunk
321                    .map(lumen_primitives::progress::ProgressChunk)
322                    .unwrap_or_default()
323            }),
324            popup_gap: ctx.dropdown_button.then(|| {
325                attrs
326                    .popup_gap
327                    .map(lumen_primitives::popup::PopupGap)
328                    .unwrap_or_default()
329            }),
330            knob_geometry: knob.then(|| lumen_primitives::controls::KnobGeometry {
331                inset: attrs
332                    .knob_inset
333                    .unwrap_or(lumen_primitives::controls::KNOB_INSET),
334                thumb_size: attrs
335                    .thumb_size
336                    .unwrap_or(lumen_primitives::controls::THUMB_SIZE),
337            }),
338            // `knob-color` (markup attr or the CSS property, UA-seeded by
339            // the skins) wins; `KNOB_FILL` is the single Rust fallback.
340            knob_fill: attrs
341                .knob_color
342                .map(Into::into)
343                .unwrap_or(lumen_primitives::controls::KNOB_FILL),
344        }
345    }
346
347    /// The values a holder in [`StyleOverrides`] can hold, as this
348    /// computes them.
349    fn holdable(&self) -> StyleValues {
350        let v = self.visuals.as_ref();
351        StyleValues {
352            display: Some(self.style.display),
353            position: Some(self.style.position),
354            inset: Some(self.style.inset),
355            width: Some(self.style.width),
356            height: Some(self.style.height),
357            min_height: Some(self.style.min_height),
358            fill: Some(v.and_then(|v| v.fill.clone())),
359            shadows: Some(v.map(|v| v.shadows.clone()).unwrap_or_default()),
360            border: Some(v.and_then(|v| v.border)),
361            opacity: Some(self.opacity),
362            text_color: self.text.as_ref().map(|t| t.color),
363        }
364    }
365
366    /// Lay what runtime systems hold over this, so it is what the live
367    /// components show.
368    fn lay_over(&mut self, held: &StyleValues) {
369        held.write_style(&mut &mut self.style);
370        if held.fill.is_some() || held.shadows.is_some() || held.border.is_some() {
371            held.write_visuals(&mut self.visuals.get_or_insert_with(Visuals::default));
372        }
373        if held.text_color.is_some()
374            && let Some(text) = self.text.as_mut()
375        {
376            held.write_text(&mut &mut *text);
377        }
378        if let Some(o) = held.opacity {
379            self.opacity = o;
380        }
381    }
382
383    /// Insert every component onto a freshly spawned element.
384    pub fn insert(self, ent: &mut EntityWorldMut<'_>) {
385        if self.relayout_boundary {
386            ent.insert(lumen_core::components::RelayoutBoundary);
387        }
388        ent.insert(self.style);
389        if let Some(v) = self.visuals {
390            ent.insert(v);
391        }
392        if let Some(t) = self.text {
393            ent.insert(t);
394        }
395        if let Some(o) = self.opacity {
396            ent.insert(Opacity(o));
397        }
398        if let Some(z) = self.z_index {
399            ent.insert(ZIndex(z));
400        }
401        if let Some(ix) = self.interaction {
402            ent.insert(ix);
403        }
404        if let Some(sv) = self.state_visuals {
405            ent.insert(sv);
406        }
407        if !self.transitions.is_empty() {
408            ent.insert(TransitionSpecs(self.transitions));
409        }
410        insert_some(ent, self.scrollbar);
411        insert_some(ent, self.image_fit);
412        insert_some(ent, self.input_paint);
413        insert_some(ent, self.caret_width);
414        insert_some(ent, self.password_character);
415        insert_some(ent, self.track);
416        insert_some(ent, self.checkbox);
417        insert_some(ent, self.radio);
418        insert_some(ent, self.tab_button);
419        insert_some(ent, self.progress_chunk);
420        insert_some(ent, self.popup_gap);
421        insert_some(ent, self.knob_geometry);
422        if let Some(ms) = self.caret_blink_ms {
423            set_caret_blink(ent, ms);
424        }
425    }
426
427    /// Replace a live element's style components with these.
428    ///
429    /// A component this does not carry is removed, so a property nothing
430    /// sets any more goes back to its initial value. A value a runtime
431    /// system holds in [`StyleOverrides`] stays on top, and the cascade's
432    /// new value becomes what it falls back to when the system lets go.
433    /// A property with a `transition` tweens from what is on screen to its
434    /// new value when `animate`; a tween already running is retargeted
435    /// from where it is. Each component is written only when it changed,
436    /// so an unchanged restyle marks nothing changed.
437    pub fn commit(mut self, ent: &mut EntityWorldMut<'_>, animate: bool) {
438        let held = match ent.get_mut::<StyleOverrides>() {
439            Some(mut ov) if !ov.is_empty() => {
440                let fresh = self.holdable();
441                Some(ov.rebase(&fresh))
442            }
443            _ => None,
444        };
445        let held_fields = held.as_ref().map(StyleValues::fields).unwrap_or_default();
446        if let Some(held) = &held {
447            self.lay_over(held);
448        }
449
450        // Transitions: the declarations land first, so the tween checks
451        // below read the restyled list.
452        let specs = TransitionSpecs(std::mem::take(&mut self.transitions));
453        let spec_for = |p: TransitionProperty| {
454            if !animate {
455                return None;
456            }
457            specs
458                .for_property(p)
459                .filter(|s| !s.duration.is_zero())
460                .copied()
461        };
462        let mut tweened = false;
463        use lumen_core::style_overrides::StyleFields;
464
465        // A property with a `transition` tweens from what is on screen
466        // now toward its new value, and a tween already heading for that
467        // value runs on undisturbed. A tween heading anywhere else is
468        // retargeted from where it is, or retired when nothing declares a
469        // transition any more. A field a system holds is the system's to
470        // animate, so it never tweens here.
471        let live_visuals = ent.get::<Visuals>().cloned();
472        if !held_fields.contains(StyleFields::FILL) {
473            let cur = live_visuals
474                .as_ref()
475                .and_then(|v| v.fill.as_ref())
476                .and_then(Fill::as_solid);
477            let new = self
478                .visuals
479                .as_ref()
480                .and_then(|v| v.fill.as_ref())
481                .and_then(Fill::as_solid);
482            let running = ent.get::<BackgroundTransition>().map(|t| t.0.to);
483            match (cur, new) {
484                (Some(cur), Some(new)) if running == Some(new) => {
485                    set_solid_fill(&mut self.visuals, cur);
486                }
487                (Some(cur), Some(new)) => {
488                    match spec_for(TransitionProperty::BackgroundColor)
489                        .and_then(|spec| retarget(cur, new, &spec))
490                    {
491                        Some(t) => {
492                            ent.insert(BackgroundTransition(t));
493                            set_solid_fill(&mut self.visuals, cur);
494                            tweened = true;
495                        }
496                        None if running.is_some() => {
497                            ent.remove::<BackgroundTransition>();
498                        }
499                        None => {}
500                    }
501                }
502                _ if running.is_some() => {
503                    ent.remove::<BackgroundTransition>();
504                }
505                _ => {}
506            }
507        }
508        if !held_fields.contains(StyleFields::BORDER) {
509            let cur = live_visuals.as_ref().and_then(|v| v.border);
510            let new = self.visuals.as_ref().and_then(|v| v.border);
511            let running = ent.get::<BorderColorTransition>().map(|t| t.0.to);
512            match (cur, new) {
513                (Some(cur), Some(new))
514                    if cur.widths == new.widths && running == Some(new.color) =>
515                {
516                    set_border_color(&mut self.visuals, cur.color);
517                }
518                (Some(cur), Some(new)) if cur.widths == new.widths => {
519                    match spec_for(TransitionProperty::BorderColor)
520                        .and_then(|spec| retarget(cur.color, new.color, &spec))
521                    {
522                        Some(t) => {
523                            ent.insert(BorderColorTransition(t));
524                            set_border_color(&mut self.visuals, cur.color);
525                            tweened = true;
526                        }
527                        None if running.is_some() => {
528                            ent.remove::<BorderColorTransition>();
529                        }
530                        None => {}
531                    }
532                }
533                _ if running.is_some() => {
534                    ent.remove::<BorderColorTransition>();
535                }
536                _ => {}
537            }
538        }
539        if !held_fields.contains(StyleFields::TEXT_COLOR) {
540            let cur = ent.get::<TextStyle>().map(|t| t.color);
541            let new = self.text.as_ref().map(|t| t.color);
542            let running = ent.get::<TextColorTransition>().map(|t| t.0.to);
543            match (cur, new, self.text.as_mut()) {
544                (Some(cur), Some(new), Some(text)) if running == Some(new) => {
545                    text.color = cur;
546                }
547                (Some(cur), Some(new), Some(text)) => {
548                    match spec_for(TransitionProperty::TextColor)
549                        .and_then(|spec| retarget(cur, new, &spec))
550                    {
551                        Some(t) => {
552                            ent.insert(TextColorTransition(t));
553                            text.color = cur;
554                            tweened = true;
555                        }
556                        None if running.is_some() => {
557                            ent.remove::<TextColorTransition>();
558                        }
559                        None => {}
560                    }
561                }
562                _ if running.is_some() => {
563                    ent.remove::<TextColorTransition>();
564                }
565                _ => {}
566            }
567        }
568        // Opacity tweens the same way. No component is fully opaque, so an
569        // element that had none starts from `1`, as in CSS, and a mount
570        // fade-in that is still running carries on to its value.
571        if !held_fields.contains(StyleFields::OPACITY) {
572            let cur = ent.get::<Opacity>().map(|o| o.0);
573            let target = self.opacity.unwrap_or(1.0);
574            let running = ent.get::<OpacityTransition>().map(|t| t.0.to);
575            if running == Some(target) {
576                self.opacity = cur;
577            } else {
578                match spec_for(TransitionProperty::Opacity)
579                    .and_then(|spec| retarget(cur.unwrap_or(1.0), target, &spec))
580                {
581                    Some(t) => {
582                        ent.insert(OpacityTransition(t));
583                        self.opacity = Some(cur.unwrap_or(1.0));
584                        tweened = true;
585                    }
586                    None if running.is_some() => {
587                        ent.remove::<OpacityTransition>();
588                    }
589                    None => {}
590                }
591            }
592        }
593
594        if specs.0.is_empty() {
595            if ent.contains::<TransitionSpecs>() {
596                ent.remove::<TransitionSpecs>();
597            }
598        } else if ent.get::<TransitionSpecs>() != Some(&specs) {
599            ent.insert(specs);
600        }
601
602        // Layout. The layout crate's `Changed<Style>` watcher marks the
603        // element dirty on any mutable access, so it is written only when
604        // something moved.
605        if ent.get::<Style>() != Some(&self.style) {
606            ent.insert(self.style);
607        }
608        put_marker::<lumen_core::components::RelayoutBoundary>(ent, self.relayout_boundary);
609        put(ent, self.visuals);
610        put(ent, self.text);
611        put(ent, self.opacity.map(Opacity));
612        put(ent, self.z_index.map(ZIndex));
613        put(ent, self.interaction);
614        put(ent, self.state_visuals);
615        put(ent, self.scrollbar);
616        put(ent, self.image_fit);
617        put(ent, self.input_paint);
618        put(ent, self.caret_width);
619        put(ent, self.password_character);
620        put(ent, self.track);
621        put(ent, self.checkbox);
622        put(ent, self.radio);
623        put(ent, self.tab_button);
624        put(ent, self.progress_chunk);
625        put(ent, self.popup_gap);
626        put(ent, self.knob_geometry);
627        if let Some(ms) = self.caret_blink_ms {
628            set_caret_blink(ent, ms);
629        }
630        if self.knob_geometry.is_some() {
631            set_knob_fill(ent, self.knob_fill);
632        }
633
634        // Keep the loop awake while a tween runs; without the wake the app
635        // can sleep mid-tween and freeze a theme flip halfway.
636        if tweened {
637            ent.world_scope(|world| {
638                if let Some(anim) =
639                    world.get_resource::<lumen_core::render_world::AnimationsActive>()
640                {
641                    anim.request();
642                }
643            });
644        }
645    }
646}
647
648/// The state patches for `:hover` / `:focus` / `:focus-visible` /
649/// `:active` / `:drag-over`, and `:disabled` on an element whose disabled
650/// state can change. `None` when no state styles anything.
651fn state_visuals(
652    ctx: &StyleContext<'_>,
653    attrs: &Attributes,
654    disabled_opacity_default: f32,
655) -> Option<StateVisuals> {
656    let to_shadows = |v: &Option<Vec<lumen_ir::layout_ir::ShadowSpec>>| {
657        v.as_ref().map(|list| {
658            list.iter()
659                .copied()
660                .map(Into::into)
661                .collect::<Vec<ShadowSpec>>()
662        })
663    };
664    // `:disabled` swaps at runtime only when the disabled state can change
665    // at runtime, which is what `bind-disabled` says. Static disabled
666    // markup takes those styles as its own instead.
667    let disabled = if ctx.bind_disabled {
668        StatePatch {
669            text_color: attrs.disabled_text_color.map(Into::into),
670            // The default disabled look when no `:disabled { bg }` /
671            // `:disabled { opacity }` rule supplied one: dim the whole
672            // element.
673            opacity: attrs.disabled_opacity.or({
674                if attrs.disabled_bg.is_none() && attrs.opacity.is_none() {
675                    Some(disabled_opacity_default)
676                } else {
677                    None
678                }
679            }),
680            shadows: to_shadows(&attrs.disabled_shadows),
681            bg: attrs.disabled_bg.map(Into::into),
682        }
683    } else {
684        StatePatch::default()
685    };
686    let sv = StateVisuals {
687        hover: StatePatch {
688            text_color: attrs.hover_text_color.map(Into::into),
689            opacity: attrs.hover_opacity,
690            shadows: to_shadows(&attrs.hover_shadows),
691            bg: None,
692        },
693        focus: StatePatch {
694            text_color: attrs.focus_text_color.map(Into::into),
695            opacity: attrs.focus_opacity,
696            shadows: to_shadows(&attrs.focus_shadows),
697            bg: None,
698        },
699        focus_visible: StatePatch {
700            text_color: attrs.focus_visible_text_color.map(Into::into),
701            opacity: attrs.focus_visible_opacity,
702            shadows: to_shadows(&attrs.focus_visible_shadows),
703            bg: None,
704        },
705        active: StatePatch {
706            text_color: attrs.active_text_color.map(Into::into),
707            opacity: attrs.active_opacity,
708            shadows: to_shadows(&attrs.active_shadows),
709            bg: None,
710        },
711        drag_over: StatePatch {
712            text_color: attrs.drag_over_text_color.map(Into::into),
713            opacity: attrs.drag_over_opacity,
714            shadows: to_shadows(&attrs.drag_over_shadows),
715            bg: attrs.drag_over_bg.map(Into::into),
716        },
717        disabled,
718    };
719    (!sv.is_empty()).then_some(sv)
720}
721
722/// The overlay-scrollbar style the `scrollbar-*` properties describe, or
723/// `None` when none is authored. The component's `Default` carries the
724/// no-stylesheet fallback for every field left unset.
725fn scrollbar_style(attrs: &Attributes) -> Option<ScrollbarStyle> {
726    if attrs.scrollbar_color.is_none()
727        && attrs.scrollbar_width.is_none()
728        && attrs.scrollbar_thickness.is_none()
729        && attrs.scrollbar_thickness_thin.is_none()
730        && attrs.scrollbar_margin.is_none()
731        && attrs.scrollbar_min_thumb.is_none()
732        && attrs.scrollbar_track_hover.is_none()
733        && attrs.scrollbar_hover_boost.is_none()
734        && attrs.scrollbar_fade_delay_ms.is_none()
735        && attrs.scrollbar_fade_duration_ms.is_none()
736    {
737        return None;
738    }
739    let mut sb = ScrollbarStyle::default();
740    if let Some((thumb, track)) = attrs.scrollbar_color {
741        sb.thumb = thumb.into();
742        sb.track = track.map(Into::into);
743    }
744    if let Some(w) = attrs.scrollbar_width {
745        sb.width = w.into();
746    }
747    if let Some(v) = attrs.scrollbar_thickness {
748        sb.thickness = v;
749    }
750    if let Some(v) = attrs.scrollbar_thickness_thin {
751        sb.thickness_thin = v;
752    }
753    if let Some(v) = attrs.scrollbar_margin {
754        sb.margin = v;
755    }
756    if let Some(v) = attrs.scrollbar_min_thumb {
757        sb.min_thumb = v;
758    }
759    if let Some(c) = attrs.scrollbar_track_hover {
760        sb.hover_track = c.into();
761    }
762    if let Some(v) = attrs.scrollbar_hover_boost {
763        sb.hover_boost = v;
764    }
765    // `ScrollbarStyle` keeps fade timings in seconds; the CSS properties
766    // parse as milliseconds (`Nms` / `Ns`, shared with
767    // `transition-duration`).
768    if let Some(ms) = attrs.scrollbar_fade_delay_ms {
769        sb.fade_delay_secs = ms as f32 / 1000.0;
770    }
771    if let Some(ms) = attrs.scrollbar_fade_duration_ms {
772        sb.fade_secs = ms as f32 / 1000.0;
773    }
774    Some(sb)
775}
776
777/// Layout may stop its dirty walk at this element:
778///
779/// 1. the markup asked for it with `layout-boundary`;
780/// 2. it is a scroll container, whose content size never feeds back into
781///    its own box beyond what the already-known clip box allows;
782/// 3. both `width` and `height` are fixed `Px` and the element cannot be
783///    flex-shrunk (`shrink: 0`) - its size is then constraint-imposed, so
784///    descendants can re-flow internally without changing the parent's
785///    measured dimensions.
786///
787/// The `shrink: 0` half of rule 3 is what keeps the boundary sound. On an
788/// overflowing line a shrinkable item is squeezed down to its automatic
789/// minimum size, which is the smaller of its content's min-content size and
790/// its authored width - so under the CSS default `flex-shrink: 1` a purely
791/// internal change can still move the item's own outer box, and stopping
792/// the dirty walk there would leave ancestors holding stale geometry.
793///
794/// Pattern: Flutter's `_relayoutBoundary`, set on `RenderObjects` that pass
795/// the same constraint test.
796fn is_relayout_boundary(style: &Style, layout_boundary: bool, attrs: &Attributes) -> bool {
797    use lumen_core::components::Overflow;
798    if layout_boundary {
799        return true;
800    }
801    // `<scroll>` carries its axis on `attrs.scroll` and never sets the
802    // `overflow` properties, so the style check alone missed the one tag
803    // whose whole job is scrolling.
804    if attrs.scroll.is_some()
805        || matches!(style.overflow_x, Overflow::Scroll)
806        || matches!(style.overflow_y, Overflow::Scroll)
807    {
808        return true;
809    }
810    matches!(style.width, Length::Px(_))
811        && matches!(style.height, Length::Px(_))
812        && style.shrink == 0.0
813}
814
815/// Point a solid fill at `color`, leaving any other fill alone.
816fn set_solid_fill(visuals: &mut Option<Visuals>, color: Color) {
817    if let Some(Some(Fill::Solid(slot))) = visuals.as_mut().map(|v| v.fill.as_mut()) {
818        *slot = color;
819    }
820}
821
822/// Point a border's color at `color`.
823fn set_border_color(visuals: &mut Option<Visuals>, color: Color) {
824    if let Some(Some(border)) = visuals.as_mut().map(|v| v.border.as_mut()) {
825        border.color = color;
826    }
827}
828
829fn insert_some<C: Component>(ent: &mut EntityWorldMut<'_>, c: Option<C>) {
830    if let Some(c) = c {
831        ent.insert(c);
832    }
833}
834
835/// Make the element carry `want`, or not carry the component at all,
836/// writing only on a change.
837fn put<C: Component + PartialEq>(ent: &mut EntityWorldMut<'_>, want: Option<C>) {
838    match want {
839        Some(c) => {
840            if ent.get::<C>() != Some(&c) {
841                ent.insert(c);
842            }
843        }
844        None => {
845            if ent.contains::<C>() {
846                ent.remove::<C>();
847            }
848        }
849    }
850}
851
852fn put_marker<C: Component + Default>(ent: &mut EntityWorldMut<'_>, want: bool) {
853    match (want, ent.contains::<C>()) {
854        (true, false) => {
855            ent.insert(C::default());
856        }
857        (false, true) => {
858            ent.remove::<C>();
859        }
860        _ => {}
861    }
862}
863
864/// `caret-blink` lands on the one shared blink clock: one phase drives
865/// every focused field's caret, so there is no per-element value to keep.
866fn set_caret_blink(ent: &mut EntityWorldMut<'_>, ms: u32) {
867    ent.world_scope(|world| {
868        if let Some(mut blink) = world.get_resource_mut::<lumen_core::components::CaretBlink>() {
869            let period = std::time::Duration::from_millis(u64::from(ms));
870            if blink.period != period {
871                blink.period = period;
872            }
873        }
874    });
875}
876
877/// Repaint the knob or thumb child a `<toggle>` / `<switch>` / `<slider>`
878/// spawned. It carries no tag of its own, so no rule reaches it; the
879/// track's `knob-color` is its fill.
880fn set_knob_fill(ent: &mut EntityWorldMut<'_>, fill: Color) {
881    let id = ent.id();
882    ent.world_scope(|world| {
883        let children: Vec<Entity> = world
884            .get::<bevy_ecs::hierarchy::Children>(id)
885            .map(|c| c.iter().collect())
886            .unwrap_or_default();
887        for child in children {
888            let is_knob = world.get::<lumen_primitives::ToggleKnob>(child).is_some()
889                || world.get::<lumen_primitives::SliderThumb>(child).is_some()
890                || world.get::<lumen_primitives::SwitchThumb>(child).is_some();
891            if is_knob
892                && let Some(mut v) = world.get_mut::<Visuals>(child)
893                && v.fill != Some(Fill::Solid(fill))
894            {
895                v.fill = Some(Fill::Solid(fill));
896            }
897        }
898    });
899}
900
901#[cfg(test)]
902mod tests {
903    //! What one element's resolved attributes become, and how a restyle
904    //! lays a new resolution over the live components. The cascade itself
905    //! is exercised in `lumen_ir::css`; the whole restyle in the runtime's
906    //! `restyle_dynamic` tests.
907    use super::*;
908    use lumen_core::components::{CaretBlink, LineHeightSpec as CoreLineHeightSpec, TextInput};
909    use lumen_core::style_overrides::{Live, StyleOwner};
910    use lumen_ir::layout_ir::{BgSpec, LineHeightSpec as IrLineHeightSpec, Rgba};
911
912    fn ctx(tag: &str) -> StyleContext<'_> {
913        StyleContext {
914            tag,
915            ..StyleContext::default()
916        }
917    }
918
919    fn rgba(r: f32, g: f32, b: f32) -> Rgba {
920        Rgba { r, g, b, a: 1.0 }
921    }
922
923    /// Commit `attrs` as the resolution of a `tag` element.
924    fn commit(world: &mut World, e: Entity, tag: &str, attrs: &Attributes) {
925        ComputedStyle::new(&ctx(tag), attrs).commit(&mut world.entity_mut(e), true);
926    }
927
928    #[test]
929    fn line_height_reaches_the_text_style_in_both_units() {
930        for (ir, core) in [
931            (
932                IrLineHeightSpec::Multiplier(1.5),
933                CoreLineHeightSpec::Multiplier(1.5),
934            ),
935            (IrLineHeightSpec::Px(19.0), CoreLineHeightSpec::Px(19.0)),
936        ] {
937            let attrs = Attributes {
938                line_height: Some(ir),
939                ..Default::default()
940            };
941            let text = ComputedStyle::new(&ctx("label"), &attrs)
942                .text
943                .expect("a text style");
944            assert_eq!(text.line_height, Some(core));
945        }
946    }
947
948    /// `caret-width`, `password-character` and `caret-blink` are a text
949    /// field's: anything else never carries them, and never repaints the
950    /// shared blink clock.
951    #[test]
952    fn text_field_properties_reach_only_text_fields() {
953        let attrs = Attributes {
954            caret_width: Some(3.5),
955            password_character: Some('#'),
956            caret_blink_ms: Some(250),
957            ..Default::default()
958        };
959        let mut world = World::new();
960        world.insert_resource(CaretBlink {
961            visible: true,
962            phase: std::time::Instant::now(),
963            period: std::time::Duration::from_millis(530),
964        });
965        let plain = world.spawn(Style::default()).id();
966        commit(&mut world, plain, "label", &attrs);
967        assert!(world.get::<CaretWidth>(plain).is_none());
968        assert!(world.get::<PasswordCharacter>(plain).is_none());
969        assert_eq!(
970            world.resource::<CaretBlink>().period,
971            std::time::Duration::from_millis(530),
972        );
973
974        let input = world.spawn((Style::default(), TextInput::default())).id();
975        commit(&mut world, input, "input", &attrs);
976        assert_eq!(world.get::<CaretWidth>(input), Some(&CaretWidth(3.5)));
977        assert_eq!(
978            world.get::<PasswordCharacter>(input),
979            Some(&PasswordCharacter('#'))
980        );
981        assert_eq!(
982            world.resource::<CaretBlink>().period,
983            std::time::Duration::from_millis(250)
984        );
985    }
986
987    /// A restyle writes every layout property the cascade resolved, exactly
988    /// as spawning would have: justify, align, position, inset, overflow and
989    /// the grid properties were once missing from the restyle path.
990    #[test]
991    fn a_restyle_builds_the_layout_spawning_builds() {
992        use lumen_ir::layout_ir::{
993            DisplaySpec, Edges, FlexAlign, FlexJustify, LengthSpec, OverflowSpec, PositionSpec,
994        };
995        let attrs = Attributes {
996            display: Some(DisplaySpec::Grid),
997            width: Some(LengthSpec::Px(120.0)),
998            align: Some(FlexAlign::Center),
999            justify: Some(FlexJustify::End),
1000            position: Some(PositionSpec::Absolute),
1001            inset: Some(Edges::all(3.0)),
1002            overflow_y: Some(OverflowSpec::Scroll),
1003            ..Default::default()
1004        };
1005        let mut world = World::new();
1006        let e = world.spawn(Style::default()).id();
1007        commit(&mut world, e, "tile", &attrs);
1008        assert_eq!(
1009            world.get::<Style>(e),
1010            Some(&ComputedStyle::new(&ctx("tile"), &attrs).style),
1011            "restyle and spawn agree"
1012        );
1013        assert_eq!(world.get::<Style>(e).unwrap(), &Style::from(&attrs));
1014    }
1015
1016    /// What the cascade no longer sets is gone after a restyle, as in CSS:
1017    /// the paint, the text style, the tints, the layout properties.
1018    #[test]
1019    fn a_restyle_removes_what_nothing_sets_any_more() {
1020        use lumen_ir::layout_ir::{FlexJustify, LengthSpec};
1021        let styled = Attributes {
1022            bg: Some(BgSpec::Solid(rgba(1.0, 0.0, 0.0))),
1023            hover_bg: Some(rgba(0.0, 1.0, 0.0)),
1024            text_color: Some(rgba(0.0, 0.0, 1.0)),
1025            opacity: Some(0.5),
1026            justify: Some(FlexJustify::End),
1027            height: Some(LengthSpec::Px(40.0)),
1028            ..Default::default()
1029        };
1030        let mut world = World::new();
1031        let e = world.spawn_empty().id();
1032        ComputedStyle::new(&ctx("row"), &styled).insert(&mut world.entity_mut(e));
1033        assert!(world.get::<Visuals>(e).is_some());
1034
1035        commit(&mut world, e, "row", &Attributes::default());
1036        assert!(world.get::<Visuals>(e).is_none(), "no bg, no paint");
1037        assert!(world.get::<TextStyle>(e).is_none(), "no text style");
1038        assert!(world.get::<Interaction>(e).is_none(), "no hover tint");
1039        assert!(world.get::<Opacity>(e).is_none(), "fully opaque again");
1040        let style = world.get::<Style>(e).unwrap();
1041        assert_eq!(style.justify, Style::default().justify);
1042        assert_eq!(style.height, Length::Auto);
1043    }
1044
1045    struct Holder;
1046    impl StyleOwner for Holder {
1047        const RANK: u8 = 40;
1048    }
1049
1050    /// A value a runtime system holds stays on screen through a restyle,
1051    /// and the new resolution is what it lets go onto.
1052    #[test]
1053    fn a_held_value_survives_a_restyle_and_lets_go_onto_the_new_one() {
1054        let old = Attributes {
1055            bg: Some(BgSpec::Solid(rgba(0.0, 0.0, 0.0))),
1056            ..Default::default()
1057        };
1058        let mut world = World::new();
1059        let e = world.spawn(StyleOverrides::default()).id();
1060        ComputedStyle::new(&ctx("button"), &old).insert(&mut world.entity_mut(e));
1061        let tint = Fill::Solid(Color::rgb(1.0, 0.0, 0.0));
1062        let mut ent = world.entity_mut(e);
1063        let vis = ent.get::<Visuals>().unwrap().clone();
1064        let out = ent.get_mut::<StyleOverrides>().unwrap().hold::<Holder>(
1065            StyleValues {
1066                fill: Some(Some(tint.clone())),
1067                ..Default::default()
1068            },
1069            &Live {
1070                visuals: Some(&vis),
1071                ..Live::default()
1072            },
1073        );
1074        out.write_visuals(&mut ent.get_mut::<Visuals>().unwrap());
1075
1076        let blue = rgba(0.0, 0.0, 1.0);
1077        let new = Attributes {
1078            bg: Some(BgSpec::Solid(blue)),
1079            ..Default::default()
1080        };
1081        commit(&mut world, e, "button", &new);
1082        assert_eq!(
1083            world.get::<Visuals>(e).unwrap().fill,
1084            Some(tint),
1085            "the held tint stays on top"
1086        );
1087        let mut ent = world.entity_mut(e);
1088        let out = ent.get_mut::<StyleOverrides>().unwrap().release::<Holder>();
1089        out.write_visuals(&mut ent.get_mut::<Visuals>().unwrap());
1090        assert_eq!(
1091            world.get::<Visuals>(e).unwrap().fill,
1092            Some(Fill::Solid(blue.into())),
1093            "letting go lands on the restyled fill"
1094        );
1095    }
1096
1097    /// An element with an opacity transition and no authored opacity fades
1098    /// from the computed `1` the first time a rule gives it one.
1099    #[test]
1100    fn an_opacity_transition_starts_from_one_when_none_was_authored() {
1101        use lumen_ir::layout_ir::{EasingIr, TransitionIr, TransitionPropertyIr};
1102        let mut world = World::new();
1103        let e = world.spawn(Style::default()).id();
1104        let attrs = Attributes {
1105            opacity: Some(0.2),
1106            transitions: vec![TransitionIr {
1107                property: TransitionPropertyIr::Opacity,
1108                duration_ms: 6000,
1109                easing: EasingIr::Linear,
1110            }],
1111            ..Default::default()
1112        };
1113        commit(&mut world, e, "tile", &attrs);
1114        let tween = world
1115            .get::<OpacityTransition>(e)
1116            .expect("the change tweens");
1117        assert_eq!((tween.0.from, tween.0.to), (1.0, 0.2));
1118        assert_eq!(world.get::<Opacity>(e), Some(&Opacity(1.0)));
1119    }
1120
1121    /// A tween already heading for the value a restyle resolves keeps
1122    /// running from where it is rather than restarting or snapping.
1123    #[test]
1124    fn a_running_tween_toward_the_same_value_runs_on() {
1125        use lumen_ir::layout_ir::{EasingIr, TransitionIr, TransitionPropertyIr};
1126        let red = rgba(1.0, 0.0, 0.0);
1127        let attrs = Attributes {
1128            bg: Some(BgSpec::Solid(red)),
1129            transitions: vec![TransitionIr {
1130                property: TransitionPropertyIr::BackgroundColor,
1131                duration_ms: 6000,
1132                easing: EasingIr::Linear,
1133            }],
1134            ..Default::default()
1135        };
1136        let mut world = World::new();
1137        let e = world.spawn_empty().id();
1138        let start = Attributes {
1139            bg: Some(BgSpec::Solid(rgba(0.0, 0.0, 0.0))),
1140            ..attrs.clone()
1141        };
1142        ComputedStyle::new(&ctx("tile"), &start).insert(&mut world.entity_mut(e));
1143        commit(&mut world, e, "tile", &attrs);
1144        let first = world.get::<BackgroundTransition>(e).expect("a tween").0;
1145        assert_eq!(first.to, red.into());
1146        commit(&mut world, e, "tile", &attrs);
1147        let again = world
1148            .get::<BackgroundTransition>(e)
1149            .expect("still tweening")
1150            .0;
1151        assert_eq!(again.start, first.start, "the same tween, not a restart");
1152        assert_eq!(
1153            world.get::<Visuals>(e).unwrap().fill,
1154            Some(Fill::Solid(Color::rgb(0.0, 0.0, 0.0))),
1155            "the tween, not the restyle, moves the fill"
1156        );
1157    }
1158
1159    /// A restyle that resolves nothing new leaves `Style` untouched, so it
1160    /// does not mark the element's layout dirty.
1161    #[test]
1162    fn an_unchanged_restyle_leaves_style_unchanged() {
1163        let mut world = World::new();
1164        let e = world.spawn(Style::default()).id();
1165        world.clear_trackers();
1166        let tick = world.change_tick();
1167        commit(&mut world, e, "tile", &Attributes::default());
1168        let changed = world
1169            .entity(e)
1170            .get_change_ticks::<Style>()
1171            .unwrap()
1172            .is_changed(tick, world.change_tick());
1173        assert!(!changed, "an unchanged restyle must not touch Style");
1174    }
1175}