Skip to main content

lumen_ir/
convert.rs

1//! IR -> runtime-component conversions.
2//!
3//! These `From` impls turn resolved [`Attributes`](crate::layout_ir::Attributes)
4//! (and the `BgSpec` / `ShadowSpec` / `OutlineSpec` value types) into their
5//! `lumen_core` / `lumen_primitives` component counterparts. They live here -
6//! not in `lumenc`'s `spawn` - because the orphan rule requires the impl to
7//! sit in the crate that defines the IR source types, exactly like the
8//! spec->component `From` impls in [`layout_ir`](crate::layout_ir). The
9//! `lumenc` spawn walker calls them via `Style::from(&attrs)` / `.into()`.
10
11use crate::layout_ir::Attributes;
12use lumen_core::components::{Fill, FlexDirection, Gap, ShadowSpec, Style, TextStyle, Visuals};
13
14/// Resolve a Material 3-flavored typography role name to a pixel
15/// font size. Returns `None` for unknown names so the caller can
16/// fall back to the default 16 px.
17///
18/// This table stays in Rust rather than moving to `skins/ua.css` with
19/// the rest of the user-agent defaults: the cascade in `lumen_ir::css`
20/// matches selectors on tag, id, class, and pseudo-class only, with no
21/// attribute-value selector. There is no CSS rule that can say "an
22/// element carrying `style-role="display-xl"` gets `font-size: 128px`" -
23/// the mapping from the role keyword to its pixel size has to happen in
24/// code, the same way a keyword like `font-weight: bold` resolves to a
25/// numeric weight.
26pub fn typography_role_to_px(role: &str) -> Option<f32> {
27    Some(match role {
28        "display-xl" => 128.0,
29        "display-lg" => 96.0,
30        "display-md" => 64.0,
31        "display-sm" => 48.0,
32        "headline-lg" => 40.0,
33        "headline-md" => 32.0,
34        "headline-sm" => 26.0,
35        "title-lg" => 22.0,
36        "title-md" => 18.0,
37        "title-sm" => 16.0,
38        "body-lg" => 18.0,
39        "body-md" => 16.0,
40        "body-sm" => 14.0,
41        "label-lg" => 14.0,
42        "label-md" => 13.0,
43        "label-sm" => 12.0,
44        "caption" => 13.0,
45        "overline" => 11.0,
46        _ => return None,
47    })
48}
49
50impl From<&Attributes> for Style {
51    /// The element's layout style from its resolved attributes: the CSS
52    /// initial values, with every property the attributes set written over
53    /// them.
54    ///
55    /// This is the one mapping from attributes to [`Style`]. Spawning an
56    /// element and a restyle (a class change, a `@media` breakpoint, a
57    /// theme flip) both build the style through here from the element's
58    /// whole resolution, so the two can never disagree about which
59    /// properties reach layout, and a property nothing sets any more is
60    /// back at its initial value.
61    fn from(attrs: &Attributes) -> Self {
62        let mut style = Style::default();
63        if let Some(d) = attrs.display {
64            style.display = d.into();
65        }
66        if let Some(w) = attrs.width {
67            style.width = w.into();
68        }
69        if let Some(h) = attrs.height {
70            style.height = h.into();
71        }
72        if let Some(f) = attrs.flex {
73            style.flex_direction = FlexDirection::from(f);
74        }
75        if let Some(p) = attrs.padding {
76            style.padding = p.into();
77        }
78        if let Some(m) = attrs.margin {
79            style.margin = m.into();
80        }
81        // `gap` sets both axes; `row-gap` / `column-gap` then override
82        // their own axis. Percent gaps ride in the `Gap` pct slots.
83        if let Some(v) = attrs.gap {
84            style.gap = Gap::from(v);
85        }
86        if let Some(r) = attrs.gap_row {
87            style.gap.row = r;
88        }
89        if let Some(c) = attrs.gap_column {
90            style.gap.column = c;
91        }
92        if let Some(p) = attrs.gap_row_pct.or(attrs.gap_pct) {
93            style.gap.row_pct = Some(p);
94        }
95        if let Some(p) = attrs.gap_column_pct.or(attrs.gap_pct) {
96            style.gap.column_pct = Some(p);
97        }
98        if let Some(g) = attrs.grow {
99            style.grow = g;
100        }
101        if let Some(a) = attrs.align {
102            style.align = a.into();
103        }
104        if let Some(j) = attrs.justify {
105            style.justify = j.into();
106        }
107        if let Some(a) = attrs.align_self {
108            style.align_self = Some(a.into());
109        }
110        if let Some(j) = attrs.justify_items {
111            style.justify_items = Some(j.into());
112        }
113        if let Some(j) = attrs.justify_self {
114            style.justify_self = Some(j.into());
115        }
116        if let Some(t) = attrs.grid_template.as_ref() {
117            style.grid_template = Some(t.into());
118        }
119        if let Some(r) = attrs.grid_row {
120            style.grid_row = r;
121        }
122        if let Some(c) = attrs.grid_column {
123            style.grid_column = c;
124        }
125        if let Some(p) = attrs.position {
126            style.position = p.into();
127        }
128        if let Some(i) = attrs.inset {
129            style.inset = i.into();
130        }
131        if let Some(v) = attrs.min_width {
132            style.min_width = v.into();
133        }
134        if let Some(v) = attrs.min_height {
135            style.min_height = v.into();
136        }
137        if let Some(v) = attrs.max_width {
138            style.max_width = v.into();
139        }
140        if let Some(v) = attrs.max_height {
141            style.max_height = v.into();
142        }
143        if attrs.aspect_ratio.is_some() {
144            style.aspect_ratio = attrs.aspect_ratio;
145        }
146        // `overflow` is shorthand for both axes; `overflow-x` /
147        // `overflow-y` override their own axis.
148        if let Some(o) = attrs.overflow_x.or(attrs.overflow) {
149            style.overflow_x = o.into();
150        }
151        if let Some(o) = attrs.overflow_y.or(attrs.overflow) {
152            style.overflow_y = o.into();
153        }
154        if let Some(s) = attrs.shrink {
155            style.shrink = s;
156        }
157        if let Some(b) = attrs.basis {
158            style.basis = b.into();
159        }
160        if let Some(w) = attrs.flex_wrap {
161            style.flex_wrap = w.into();
162        }
163        if let Some(a) = attrs.align_content {
164            style.align_content = Some(a.into());
165        }
166        // CSS border-style folds into computed widths: no solid style =>
167        // zero widths (no layout space, no paint).
168        if attrs.border_style.is_some() || attrs.border_width.is_some() {
169            style.border = attrs
170                .effective_border()
171                .map(|(widths, _)| widths.into())
172                .unwrap_or_default();
173        }
174        if let Some(b) = attrs.box_sizing {
175            style.box_sizing = b.into();
176        }
177        style
178    }
179}
180
181/// `None` when the element carries no visible-rect inputs (no fill,
182/// no radius, no shadow). Keeping `Visuals` absent for plain layout
183/// containers preserves the "only entities that paint get a Visuals"
184/// invariant the render extract relies on.
185impl From<&Attributes> for Option<Visuals> {
186    fn from(attrs: &Attributes) -> Self {
187        let fill = attrs.bg.as_ref().and_then(|bg| Fill::try_from(bg).ok());
188        let radius = attrs.radius.unwrap_or(0.0);
189        let corner_radii = attrs.radius_corners;
190        let shadows: Vec<ShadowSpec> = attrs.shadows.iter().copied().map(Into::into).collect();
191        let border =
192            attrs
193                .effective_border()
194                .map(|(widths, color)| lumen_core::components::Border {
195                    widths: widths.into(),
196                    color: color.into(),
197                    side_colors: attrs
198                        .effective_border_colors(color)
199                        .map(|cs| cs.map(Into::into)),
200                });
201        if fill.is_none()
202            && radius == 0.0
203            && corner_radii.is_none()
204            && shadows.is_empty()
205            && border.is_none()
206        {
207            return None;
208        }
209        Some(Visuals {
210            fill,
211            radius,
212            corner_radii,
213            shadows,
214            border,
215        })
216    }
217}
218
219/// A colour or gradient `bg` is a [`Fill`]. An image is not one: it paints
220/// through the asset pipeline instead, and the conversion hands back the path
221/// it names.
222impl<'a> TryFrom<&'a crate::layout_ir::BgSpec> for Fill {
223    type Error = &'a str;
224
225    fn try_from(spec: &'a crate::layout_ir::BgSpec) -> Result<Self, Self::Error> {
226        Ok(match spec {
227            crate::layout_ir::BgSpec::Image(path) => return Err(path),
228            crate::layout_ir::BgSpec::Solid(rgba) => Fill::Solid((*rgba).into()),
229            crate::layout_ir::BgSpec::Linear { angle_deg, stops } => Fill::Linear {
230                angle_deg: *angle_deg,
231                stops: stops.iter().map(|(o, c)| (*o, (*c).into())).collect(),
232            },
233            crate::layout_ir::BgSpec::Radial { radius, stops } => Fill::Radial {
234                radius: *radius,
235                stops: stops.iter().map(|(o, c)| (*o, (*c).into())).collect(),
236            },
237            crate::layout_ir::BgSpec::Conic { from_deg, stops } => Fill::Conic {
238                from_deg: *from_deg,
239                stops: stops.iter().map(|(o, c)| (*o, (*c).into())).collect(),
240            },
241        })
242    }
243}
244
245impl From<crate::layout_ir::ShadowSpec> for ShadowSpec {
246    fn from(s: crate::layout_ir::ShadowSpec) -> Self {
247        ShadowSpec {
248            offset_x: s.offset_x,
249            offset_y: s.offset_y,
250            blur: s.blur,
251            spread: s.spread,
252            color: s.color.into(),
253            inner: s.inner,
254        }
255    }
256}
257
258/// `None` when the element carries no text-style overrides (no color,
259/// no font size, no align, no wrap, no max-lines, no typography role,
260/// no line-height, ...). Absent => defaults apply at extract time -
261/// `<column>` / `<row>` containers stay component-light.
262impl From<&Attributes> for Option<TextStyle> {
263    fn from(attrs: &Attributes) -> Self {
264        let role_size = attrs.style_role.as_deref().and_then(typography_role_to_px);
265        let size_px = attrs.font_size.or(role_size);
266        let ellipsis = matches!(
267            attrs.text_overflow,
268            Some(crate::layout_ir::TextOverflowSpec::Ellipsis)
269        );
270        if attrs.text_color.is_none()
271            && size_px.is_none()
272            && attrs.text_align.is_none()
273            && attrs.text_wrap.is_none()
274            && attrs.max_lines.is_none()
275            && !ellipsis
276            && attrs.font_family.is_none()
277            && attrs.font_weight.is_none()
278            && attrs.selection_color.is_none()
279            && attrs.line_height.is_none()
280        {
281            return None;
282        }
283        let defaults = TextStyle::default();
284        // `lumen_ir::layout_ir::LineHeightSpec` -> `lumen_core::components::LineHeightSpec`,
285        // a 1:1 variant mapping. `lumen-core` cannot depend on `lumen-ir`
286        // (the dependency would cycle: `lumen-ir` already depends on
287        // `lumen-core`), so the two enums are separate types kept in sync
288        // by hand; `run::restyle`'s live-reapply path does the same
289        // conversion at its own IR/ECS boundary.
290        let line_height = attrs.line_height.map(|lh| match lh {
291            crate::layout_ir::LineHeightSpec::Multiplier(m) => {
292                lumen_core::components::LineHeightSpec::Multiplier(m)
293            }
294            crate::layout_ir::LineHeightSpec::Px(px) => {
295                lumen_core::components::LineHeightSpec::Px(px)
296            }
297        });
298        // `text-overflow: ellipsis` lowers onto the existing runtime
299        // wrap machinery: glyph-wrap at the container width with a
300        // 1-line cap makes the shaper truncate and append `...` (the
301        // trim-to-fit pass in lumen-text-cosmic keeps the ellipsis
302        // inside the box). Author-supplied `wrap` / `max-lines` win -
303        // a multi-line clamp (`wrap="word" max-lines="2"`) is already
304        // ellipsized by the same shaper path.
305        let (wrap, max_lines) = if ellipsis {
306            (
307                attrs
308                    .text_wrap
309                    .map(Into::into)
310                    .unwrap_or(lumen_core::components::TextWrap::Glyph),
311                Some(attrs.max_lines.unwrap_or(1)),
312            )
313        } else {
314            (
315                attrs.text_wrap.map(Into::into).unwrap_or(defaults.wrap),
316                attrs.max_lines,
317            )
318        };
319        Some(TextStyle {
320            color: attrs.text_color.map(Into::into).unwrap_or(defaults.color),
321            size_px: size_px.unwrap_or(defaults.size_px),
322            align: attrs.text_align.map(Into::into).unwrap_or(defaults.align),
323            wrap,
324            max_lines,
325            family: attrs
326                .font_family
327                .as_deref()
328                .map(std::sync::Arc::<str>::from),
329            weight: attrs.font_weight.unwrap_or(defaults.weight),
330            selection_color: attrs.selection_color.map(Into::into),
331            line_height,
332        })
333    }
334}
335
336/// `None` when the element opts into no interaction state (no
337/// `hover-bg`, no `press-bg`, no `focus-outline`). Keeping the
338/// component absent for non-interactive surfaces avoids one row in the
339/// inspector and one archetype move per spawn.
340impl From<&Attributes> for Option<lumen_primitives::Interaction> {
341    fn from(attrs: &Attributes) -> Self {
342        let hover_tint = attrs.hover_bg.map(Into::into);
343        let press_tint = attrs.press_bg.map(Into::into);
344        let focus_outline = attrs.focus_outline.map(Into::into);
345        let focus_visible_outline = attrs.focus_visible_outline.map(Into::into);
346        let hover_border = attrs.hover_border.map(Into::into);
347        let focus_border = attrs.focus_border.map(Into::into);
348        if hover_tint.is_none()
349            && press_tint.is_none()
350            && focus_outline.is_none()
351            && focus_visible_outline.is_none()
352            && hover_border.is_none()
353            && focus_border.is_none()
354        {
355            return None;
356        }
357        Some(lumen_primitives::Interaction {
358            hover_tint,
359            press_tint,
360            focus_outline,
361            focus_visible_outline,
362            hover_border,
363            focus_border,
364        })
365    }
366}
367
368/// The slider an element's `min`, `max`, `value` and `step` describe.
369///
370/// The defaults are the range a `<slider>` has when the markup names none:
371/// zero to one, starting at the bottom. They live here rather than at each
372/// reader, because a page emitted from one set of defaults and adopted by an
373/// app holding another shows the thumb in two places.
374impl From<&Attributes> for lumen_core::components::SliderValue {
375    fn from(attrs: &Attributes) -> Self {
376        let min = attrs.min.unwrap_or(0.0);
377        let max = attrs.max.unwrap_or(1.0);
378        let slider = Self {
379            value: 0.0,
380            min,
381            max,
382            step: attrs.step,
383        };
384        Self {
385            value: slider.clamp(attrs.value.unwrap_or(min)),
386            ..slider
387        }
388    }
389}
390
391/// The progress bar an element's `value`, `max` and `duration` describe.
392///
393/// No `value` is an indeterminate bar, which is a bar with nothing to say
394/// about where it is rather than one sitting at zero. A `bind-value` write
395/// turns it determinate.
396impl From<&Attributes> for lumen_primitives::ProgressBar {
397    fn from(attrs: &Attributes) -> Self {
398        Self {
399            value: attrs.value,
400            max: attrs.max.unwrap_or(1.0),
401            period_ms: attrs
402                .progress_duration
403                .unwrap_or(lumen_primitives::PROGRESS_PERIOD_MS),
404        }
405    }
406}
407
408impl From<crate::layout_ir::OutlineSpec> for lumen_primitives::FocusOutlineSpec {
409    fn from(spec: crate::layout_ir::OutlineSpec) -> Self {
410        lumen_primitives::FocusOutlineSpec {
411            width: spec.width,
412            color: spec.color.into(),
413            offset: spec.offset,
414        }
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421    use crate::layout_ir::LineHeightSpec as IrLineHeightSpec;
422    use lumen_core::components::LineHeightSpec as CoreLineHeightSpec;
423
424    /// The range a `<slider>` gets when the markup names none. Every reader
425    /// of a slider's bounds comes through this conversion, so the defaults
426    /// are pinned here rather than at each of them: the spawner builds the
427    /// widget with it and the web emitter writes the page with it, and a page
428    /// written from a different range than the app holds shows the thumb in
429    /// the wrong place.
430    #[test]
431    fn a_slider_with_no_bounds_runs_from_zero_to_one() {
432        let slider = lumen_core::components::SliderValue::from(&Attributes::default());
433        assert_eq!((slider.min, slider.max), (0.0, 1.0));
434        assert_eq!(slider.value, 0.0, "and starts at the bottom of its range");
435        assert_eq!(slider.step, None);
436    }
437
438    #[test]
439    fn a_slider_starts_inside_its_own_bounds() {
440        let attrs = Attributes {
441            min: Some(0.0),
442            max: Some(100.0),
443            value: Some(250.0),
444            ..Attributes::default()
445        };
446        assert_eq!(
447            lumen_core::components::SliderValue::from(&attrs).value,
448            100.0,
449            "an authored value past the top is held to it, not carried"
450        );
451    }
452
453    /// A bar with nothing to say about where it is, which is what an
454    /// indeterminate `<progress>` is. Zero would be a bar saying it has not
455    /// started, which is a different claim.
456    #[test]
457    fn a_progress_bar_with_no_value_is_indeterminate() {
458        let bar = lumen_primitives::ProgressBar::from(&Attributes::default());
459        assert_eq!(bar.value, None);
460        assert_eq!(bar.max, 1.0);
461        assert_eq!(bar.period_ms, lumen_primitives::PROGRESS_PERIOD_MS);
462    }
463
464    /// A `line-height` alone - no color, size, align, wrap, max-lines,
465    /// family, weight, or selection-color - must still produce a
466    /// `TextStyle`. The early-return guard in `Option<&Attributes> for
467    /// Option<TextStyle>` used to check every OTHER text property but not
468    /// `line_height`, so an element authoring only `line-height` returned
469    /// `None` and the value was silently dropped instead of reaching the
470    /// spawned entity.
471    #[test]
472    fn line_height_alone_produces_a_text_style() {
473        let attrs = Attributes {
474            line_height: Some(IrLineHeightSpec::Multiplier(1.5)),
475            ..Attributes::default()
476        };
477        let ts = Option::<TextStyle>::from(&attrs).expect("line-height alone must produce Some");
478        assert_eq!(
479            ts.line_height,
480            Some(CoreLineHeightSpec::Multiplier(1.5)),
481            "the authored value must survive, not just trigger Some(..)"
482        );
483    }
484
485    /// The `Px` variant survives the `lumen_ir` -> `lumen_core` conversion
486    /// too - the two `LineHeightSpec` enums are distinct types (`lumen-core`
487    /// cannot depend on `lumen-ir`), converted 1:1 by hand at this
488    /// boundary, so each variant needs its own coverage.
489    #[test]
490    fn line_height_px_variant_survives_conversion() {
491        let attrs = Attributes {
492            line_height: Some(IrLineHeightSpec::Px(19.0)),
493            ..Attributes::default()
494        };
495        let ts = Option::<TextStyle>::from(&attrs).expect("line-height alone must produce Some");
496        assert_eq!(ts.line_height, Some(CoreLineHeightSpec::Px(19.0)));
497    }
498
499    /// An element with no text properties at all - including no
500    /// line-height - still returns `None`, preserving the
501    /// "component-light plain container" contract the guard exists for.
502    #[test]
503    fn no_text_properties_returns_none() {
504        let attrs = Attributes::default();
505        assert!(Option::<TextStyle>::from(&attrs).is_none());
506    }
507
508    /// A property already covered by the guard (`font-size`) still
509    /// combines correctly with an authored `line-height` on the same
510    /// element - the fix to the guard must not regress the
511    /// already-working multi-property case.
512    #[test]
513    fn line_height_combines_with_other_text_properties() {
514        let attrs = Attributes {
515            font_size: Some(20.0),
516            line_height: Some(IrLineHeightSpec::Multiplier(1.4)),
517            ..Attributes::default()
518        };
519        let ts = Option::<TextStyle>::from(&attrs).expect("must produce Some");
520        assert_eq!(ts.size_px, 20.0);
521        assert_eq!(ts.line_height, Some(CoreLineHeightSpec::Multiplier(1.4)));
522    }
523}