Skip to main content

eui_core/
style.rs

1//! Style RESOLUTION for layout: a node's style object (token references
2//! already resolved by the theme, conditions still nested) → the flat map for
3//! this pass (`conditions::resolve_conditions`: media against the SURFACE
4//! box, the state keys against the node's states) → logical properties made
5//! physical from the surface direction → a taffy `Style` plus the painted
6//! [`Visual`].
7//!
8//! Rules from the VAPP-4 spike that every painter relies on: border-box, flex
9//! row default, `min-width: auto`, `overflow: hidden` = clip, the border
10//! widths (`borderWidth` and the four sides) are the visual keys with layout
11//! effect, absolute nodes paint last among their siblings (no z-index).
12//! Round 1 adds the per-side and per-corner keys, gradients, paint-only
13//! transforms, transitions (resolved durations + easings), text styling,
14//! `overflowX/Y`, `visibility`, `pointerEvents`, `userSelect`, `cursor`.
15
16use serde::{Deserialize, Serialize};
17use serde_json::{Map, Value};
18use taffy::prelude::*;
19use taffy::style::{AlignContent, AlignItems, BoxSizing, Direction, Display, FlexDirection, FlexWrap, LengthPercentage, LengthPercentageAuto, Overflow, Position, TextAlign};
20
21pub use crate::conditions::{is_condition_key, resolve_conditions, ConditionContext};
22use crate::tracks;
23
24/// One shadow layer, resolved.
25#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
26pub struct ShadowLayer {
27    pub x: f32,
28    pub y: f32,
29    pub blur: f32,
30    pub spread: f32,
31    pub color: String,
32}
33
34/// One gradient stop: a resolved colour at 0..1.
35#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
36pub struct GradientStop {
37    pub color: String,
38    pub offset: f32,
39}
40
41/// A linear gradient painted OVER `background_color` (CSS angle: 0 = up,
42/// 90 = right).
43#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
44pub struct Gradient {
45    pub angle: f32,
46    pub stops: Vec<GradientStop>,
47}
48
49/// One paint-only transform function, in source order (applied around the
50/// box's centre, like CSS `transform-origin: center`).
51#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
52#[serde(tag = "op", rename_all = "camelCase")]
53pub enum TransformOp {
54    Translate { x: f32, y: f32 },
55    Scale { factor: f32 },
56    Rotate { degrees: f32 },
57}
58
59/// The motion a node's colour/opacity/transform/size changes animate with:
60/// the resolved `$motion.*` duration (0 under reduced motion) and the
61/// `$ease.*` cubic bezier (CSS `ease` when unset).
62#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
63pub struct Transition {
64    pub duration_ms: f32,
65    pub easing: [f32; 4],
66}
67
68/// CSS `ease`, the easing of a `transition` without `transitionEasing`.
69pub const DEFAULT_EASING: [f32; 4] = [0.25, 0.1, 0.25, 1.0];
70
71/// Painted properties, RESOLVED (hex colours, px numbers, shadow layers,
72/// family names). Painters never read the theme; they paint this.
73#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
74pub struct Visual {
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub background_color: Option<String>,
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub background_gradient: Option<Gradient>,
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub color: Option<String>,
81    /// The uniform border width (`borderWidth`); per-side widths are in
82    /// `border_widths` when any side differs.
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub border_width: Option<f32>,
85    /// `[top, right, bottom, left]` when a per-side width is set.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub border_widths: Option<[f32; 4]>,
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub border_color: Option<String>,
90    /// `solid | dashed | dotted` (solid when unset).
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub border_style: Option<String>,
93    /// The uniform radius in px (a `%` resolves against the laid-out box).
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub border_radius: Option<f32>,
96    /// `[topLeft, topRight, bottomRight, bottomLeft]` when a corner is set.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub corner_radii: Option<[f32; 4]>,
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub opacity: Option<f32>,
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub box_shadow: Option<Vec<ShadowLayer>>,
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub font_size: Option<f32>,
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub font_weight: Option<u16>,
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub line_height: Option<f32>,
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub font_family: Option<String>,
111    /// PHYSICAL: `left | right | center | justify` (start/end resolved by
112    /// the surface direction).
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub text_align: Option<String>,
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub letter_spacing: Option<f32>,
117    /// `none | underline | line-through`
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub text_decoration: Option<String>,
120    /// `none | uppercase | lowercase | capitalize`
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub text_transform: Option<String>,
123    /// `normal | italic`
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub font_style: Option<String>,
126    /// The recipe's padding on a MEASURED leaf (a Button's label inset): the
127    /// painter draws it, the measurer includes it; taffy never sees it.
128    /// The averages of the two sides; `padding` has each side.
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub padding_horizontal: Option<f32>,
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub padding_vertical: Option<f32>,
133    /// A leaf's padding `[top, right, bottom, left]` (logical keys resolved).
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub padding: Option<[f32; 4]>,
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub gap: Option<f32>,
138    /// `native: true` on the part's recipe: the painter may use the platform's
139    /// own control (geometry still from the tokens).
140    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
141    pub native: bool,
142    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
143    pub overflow_hidden: bool,
144    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
145    pub overflow_scroll: bool,
146    /// Per axis: `visible | hidden | clip | scroll | auto` (overflow merged).
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub overflow_x: Option<String>,
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub overflow_y: Option<String>,
151    /// Paint-only transform functions in source order.
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub transform: Option<Vec<TransformOp>>,
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub transition: Option<Transition>,
156    /// `visibility: hidden`: the box stays, nothing paints, out of the a11y tree.
157    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
158    pub visibility_hidden: bool,
159    /// VAPP-126: `display: none` (a base style or a matching media block).
160    /// The node stays in the tree, laid out at zero size with its subtree;
161    /// a painter clips it, so a control inside draws nothing past the box.
162    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
163    pub display_none: bool,
164    /// `pointerEvents: none`: presses pass through.
165    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
166    pub pointer_events_none: bool,
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub user_select: Option<String>,
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub cursor: Option<String>,
171    /// A Chart's series (or slice) colours, resolved for the mode.
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub series_colors: Option<Vec<String>>,
174    /// Round 2: a LEAF's resolved direction (`ltr | rtl`): the bidi
175    /// paragraph direction its text is shaped with.
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub direction: Option<String>,
178    /// Round 2: `backdropBlur` in px (`$blur.*` resolved): blur what is
179    /// behind the box through its translucent background. No platform blur
180    /// = paint the background alone.
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub backdrop_blur: Option<f32>,
183    /// Round 2: the keyframe animation with its resolved timing; a painter
184    /// keeps the time the node entered the tree and paints
185    /// [`crate::animation::frame_with_timing`].
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub animation: Option<VisualAnimation>,
188    /// Round 2: `position: sticky` (the pinned offsets are
189    /// `LayoutOutput::sticky`).
190    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
191    pub sticky: bool,
192}
193
194/// A node's animation (`animation` + `animationDuration`, resolved).
195#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
196#[serde(rename_all = "camelCase")]
197pub struct VisualAnimation {
198    pub name: String,
199    pub timing: crate::animation::AnimationTiming,
200    /// Reduced motion: paint the rest frame, no band.
201    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
202    pub reduced: bool,
203}
204
205/// Logical properties → physical sides for the surface direction. taffy has
206/// only physical rects. A logical key wins over the physical one it maps to
207/// (it states the direction-aware intent). `insetBlockStart/End` are
208/// `top`/`bottom`; `textAlign: start|end` becomes `left|right`.
209pub fn resolve_logical(style: &mut Map<String, Value>, direction: Direction) {
210    let (start, end) = match direction {
211        Direction::Rtl => ("Right", "Left"),
212        _ => ("Left", "Right"),
213    };
214    for (logical, prefix) in [
215        ("marginInlineStart", "margin"),
216        ("marginInlineEnd", "margin"),
217        ("paddingInlineStart", "padding"),
218        ("paddingInlineEnd", "padding"),
219        ("insetInlineStart", ""),
220        ("insetInlineEnd", ""),
221    ] {
222        if let Some(v) = style.shift_remove(logical) {
223            let side = if logical.ends_with("Start") { start } else { end };
224            let physical = if prefix.is_empty() { side.to_lowercase() } else { format!("{prefix}{side}") };
225            style.insert(physical, v);
226        }
227    }
228    if let Some(v) = style.shift_remove("insetBlockStart") {
229        style.insert("top".into(), v);
230    }
231    if let Some(v) = style.shift_remove("insetBlockEnd") {
232        style.insert("bottom".into(), v);
233    }
234    let rtl = matches!(direction, Direction::Rtl);
235    match style.get("textAlign").and_then(Value::as_str) {
236        Some("start") => {
237            style.insert("textAlign".into(), Value::String(if rtl { "right" } else { "left" }.into()));
238        }
239        Some("end") => {
240            style.insert("textAlign".into(), Value::String(if rtl { "left" } else { "right" }.into()));
241        }
242        _ => {}
243    }
244}
245
246fn num(v: &Value) -> Option<f32> {
247    v.as_f64().map(|n| n as f32)
248}
249
250fn percent_of(s: &str) -> Option<f32> {
251    s.strip_suffix('%').and_then(|n| n.trim().parse::<f32>().ok()).map(|n| n / 100.0)
252}
253
254fn px_of(s: &str) -> Option<f32> {
255    s.strip_suffix("px").and_then(|n| n.trim().parse::<f32>().ok())
256}
257
258fn dimension(v: &Value) -> Option<Dimension> {
259    if let Some(n) = num(v) {
260        return Some(length(n));
261    }
262    match v.as_str()? {
263        "auto" => Some(Dimension::auto()),
264        s => percent_of(s).map(percent).or_else(|| px_of(s).map(length)),
265    }
266}
267
268fn lpa(v: &Value) -> Option<LengthPercentageAuto> {
269    if let Some(n) = num(v) {
270        return Some(length(n));
271    }
272    match v.as_str()? {
273        "auto" => Some(LengthPercentageAuto::auto()),
274        s => percent_of(s).map(percent).or_else(|| px_of(s).map(length)),
275    }
276}
277
278fn lp(v: &Value) -> Option<LengthPercentage> {
279    if let Some(n) = num(v) {
280        return Some(length(n));
281    }
282    let s = v.as_str()?;
283    percent_of(s).map(percent).or_else(|| px_of(s).map(length))
284}
285
286/// A length in px when it is a plain number or `Npx` (not `%`/`auto`).
287pub fn px(v: &Value) -> Option<f32> {
288    if let Some(n) = num(v) {
289        return Some(n);
290    }
291    v.as_str().and_then(px_of)
292}
293
294/// A `"N%"` length as a fraction (0.5 for `50%`).
295pub fn percent_value(v: &Value) -> Option<f32> {
296    v.as_str().and_then(percent_of)
297}
298
299fn rect_lp(r: &Map<String, Value>, all: &str, horizontal: &str, vertical: &str, sides: [&str; 4]) -> Rect<LengthPercentage> {
300    let mut out: Rect<LengthPercentage> = Rect::zero();
301    if let Some(v) = r.get(all).and_then(lp) {
302        out = Rect { left: v, right: v, top: v, bottom: v };
303    }
304    if let Some(v) = r.get(horizontal).and_then(lp) {
305        out.left = v;
306        out.right = v;
307    }
308    if let Some(v) = r.get(vertical).and_then(lp) {
309        out.top = v;
310        out.bottom = v;
311    }
312    let [t, rr, b, l] = sides;
313    if let Some(v) = r.get(t).and_then(lp) {
314        out.top = v;
315    }
316    if let Some(v) = r.get(rr).and_then(lp) {
317        out.right = v;
318    }
319    if let Some(v) = r.get(b).and_then(lp) {
320        out.bottom = v;
321    }
322    if let Some(v) = r.get(l).and_then(lp) {
323        out.left = v;
324    }
325    out
326}
327
328fn rect_lpa(r: &Map<String, Value>, all: &str, horizontal: &str, vertical: &str, sides: [&str; 4], default: LengthPercentageAuto) -> Rect<LengthPercentageAuto> {
329    let mut out = Rect { left: default, right: default, top: default, bottom: default };
330    if let Some(v) = r.get(all).and_then(lpa) {
331        out = Rect { left: v, right: v, top: v, bottom: v };
332    }
333    if let Some(v) = r.get(horizontal).and_then(lpa) {
334        out.left = v;
335        out.right = v;
336    }
337    if let Some(v) = r.get(vertical).and_then(lpa) {
338        out.top = v;
339        out.bottom = v;
340    }
341    let [t, rr, b, l] = sides;
342    if let Some(v) = r.get(t).and_then(lpa) {
343        out.top = v;
344    }
345    if let Some(v) = r.get(rr).and_then(lpa) {
346        out.right = v;
347    }
348    if let Some(v) = r.get(b).and_then(lpa) {
349        out.bottom = v;
350    }
351    if let Some(v) = r.get(l).and_then(lpa) {
352        out.left = v;
353    }
354    out
355}
356
357/// The px sides `[top, right, bottom, left]` of a padding-like key family
358/// (`padding`, `paddingHorizontal`, `paddingVertical`, the four sides; the
359/// logical keys are already physical here). `None` when no key is set.
360pub fn sides_px(r: &Map<String, Value>, all: &str, horizontal: &str, vertical: &str, sides: [&str; 4]) -> Option<[f32; 4]> {
361    let keys = [all, horizontal, vertical, sides[0], sides[1], sides[2], sides[3]];
362    if !keys.iter().any(|k| r.contains_key(*k)) {
363        return None;
364    }
365    let mut out = [0.0f32; 4];
366    if let Some(v) = r.get(all).and_then(px) {
367        out = [v; 4];
368    }
369    if let Some(v) = r.get(horizontal).and_then(px) {
370        out[1] = v;
371        out[3] = v;
372    }
373    if let Some(v) = r.get(vertical).and_then(px) {
374        out[0] = v;
375        out[2] = v;
376    }
377    for (i, k) in sides.iter().enumerate() {
378        if let Some(v) = r.get(*k).and_then(px) {
379            out[i] = v;
380        }
381    }
382    Some(out)
383}
384
385/// The border widths `[top, right, bottom, left]` (`borderWidth` then the
386/// sides); `None` when no width key is set.
387pub fn border_sides(r: &Map<String, Value>) -> Option<[f32; 4]> {
388    let sides = ["borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth"];
389    if !r.contains_key("borderWidth") && !sides.iter().any(|k| r.contains_key(*k)) {
390        return None;
391    }
392    let all = r.get("borderWidth").and_then(num).unwrap_or(0.0);
393    let mut out = [all; 4];
394    for (i, k) in sides.iter().enumerate() {
395        if let Some(v) = r.get(*k).and_then(num) {
396            out[i] = v;
397        }
398    }
399    Some(out)
400}
401
402fn align_items(s: &str) -> Option<AlignItems> {
403    Some(match s {
404        "flex-start" | "start" => AlignItems::FLEX_START,
405        "flex-end" | "end" => AlignItems::FLEX_END,
406        "center" => AlignItems::CENTER,
407        "stretch" => AlignItems::STRETCH,
408        "baseline" => AlignItems::BASELINE,
409        _ => return None,
410    })
411}
412
413fn align_content(s: &str) -> Option<AlignContent> {
414    Some(match s {
415        "flex-start" | "start" => AlignContent::FLEX_START,
416        "flex-end" | "end" => AlignContent::FLEX_END,
417        "center" => AlignContent::CENTER,
418        "stretch" => AlignContent::STRETCH,
419        "space-between" => AlignContent::SPACE_BETWEEN,
420        "space-around" => AlignContent::SPACE_AROUND,
421        "space-evenly" => AlignContent::SPACE_EVENLY,
422        _ => return None,
423    })
424}
425
426fn overflow_of(s: &str) -> Result<Overflow, String> {
427    Ok(match s {
428        "visible" => Overflow::Visible,
429        "hidden" | "clip" => Overflow::Clip,
430        "scroll" | "auto" => Overflow::Scroll,
431        other => return Err(format!("unsupported overflow {other:?}")),
432    })
433}
434
435/// The overflow of one axis: the axis key over `overflow`.
436pub fn axis_overflow<'a>(r: &'a Map<String, Value>, axis: &str) -> Option<&'a str> {
437    r.get(axis).or_else(|| r.get("overflow")).and_then(Value::as_str)
438}
439
440/// Is the flat style a scroll container on either axis?
441pub fn is_scroll_container(r: &Map<String, Value>) -> (bool, bool) {
442    let scrolls = |v: Option<&str>| matches!(v, Some("scroll") | Some("auto"));
443    (scrolls(axis_overflow(r, "overflowX")), scrolls(axis_overflow(r, "overflowY")))
444}
445
446/// How a node's padding and border reach taffy.
447#[derive(Debug, Clone, Copy, PartialEq, Eq)]
448pub enum BoxKind {
449    /// A container: padding/border are taffy's (the spike rule).
450    Container,
451    /// A host-measured leaf: the measurer reports the BORDER box of the whole
452    /// control, so recipe padding/border stay out of taffy (`Visual` carries
453    /// them for the painter and the measurer).
454    Leaf,
455}
456
457/// Resolved flat map → taffy style. `direction` is NOT inherited by taffy, so
458/// the caller passes the surface's and every node gets it.
459pub fn to_taffy(r: &Map<String, Value>, direction: Direction, kind: BoxKind) -> Result<Style, String> {
460    let mut s = Style { box_sizing: BoxSizing::BorderBox, direction, ..Style::default() };
461    let str_of = |k: &str| r.get(k).and_then(Value::as_str);
462
463    s.display = match str_of("display") {
464        None | Some("flex") => Display::Flex,
465        Some("grid") => Display::Grid,
466        Some("block") => Display::Block,
467        Some("none") => Display::None,
468        Some(other) => return Err(format!("unsupported display {other:?}")),
469    };
470    s.flex_direction = match str_of("flexDirection") {
471        None | Some("row") => FlexDirection::Row,
472        Some("column") => FlexDirection::Column,
473        Some("row-reverse") => FlexDirection::RowReverse,
474        Some("column-reverse") => FlexDirection::ColumnReverse,
475        Some(other) => return Err(format!("unsupported flexDirection {other:?}")),
476    };
477    s.flex_wrap = match str_of("flexWrap") {
478        None | Some("nowrap") => FlexWrap::NoWrap,
479        Some("wrap") => FlexWrap::Wrap,
480        Some("wrap-reverse") => FlexWrap::WrapReverse,
481        Some(other) => return Err(format!("unsupported flexWrap {other:?}")),
482    };
483    s.position = match str_of("position") {
484        None | Some("relative") => Position::Relative,
485        Some("absolute") => Position::Absolute,
486        // Round 2: sticky = relative in layout; the surface pins it by its
487        // insets inside the nearest scroller (`LayoutOutput::sticky`).
488        Some("sticky") => Position::Relative,
489        Some(other) => return Err(format!("unsupported position {other:?}")),
490    };
491    let ox = axis_overflow(r, "overflowX").map(overflow_of).transpose()?;
492    let oy = axis_overflow(r, "overflowY").map(overflow_of).transpose()?;
493    s.overflow = taffy::geometry::Point { x: ox.unwrap_or(Overflow::Visible), y: oy.unwrap_or(Overflow::Visible) };
494    if let Some(v) = str_of("justifyContent") {
495        s.justify_content = Some(align_content(v).ok_or(format!("justifyContent {v:?}"))?);
496    }
497    if let Some(v) = str_of("alignContent") {
498        s.align_content = Some(align_content(v).ok_or(format!("alignContent {v:?}"))?);
499    }
500    if let Some(v) = str_of("alignItems") {
501        s.align_items = Some(align_items(v).ok_or(format!("alignItems {v:?}"))?);
502    }
503    if let Some(v) = str_of("alignSelf") {
504        s.align_self = Some(align_items(v).ok_or(format!("alignSelf {v:?}"))?);
505    }
506    if let Some(v) = str_of("justifySelf") {
507        s.justify_self = Some(align_items(v).ok_or(format!("justifySelf {v:?}"))?);
508    }
509    if let Some(v) = str_of("textAlign") {
510        s.text_align = match v {
511            "left" => TextAlign::LegacyLeft,
512            "right" => TextAlign::LegacyRight,
513            "center" => TextAlign::LegacyCenter,
514            _ => TextAlign::Auto,
515        };
516    }
517    if let Some(v) = r.get("flexGrow").and_then(num) {
518        s.flex_grow = v;
519    }
520    if let Some(v) = r.get("flexShrink").and_then(num) {
521        s.flex_shrink = v;
522    }
523    if let Some(v) = r.get("flexBasis").and_then(dimension) {
524        s.flex_basis = v;
525    }
526    let gap_all = r.get("gap").and_then(lp);
527    s.gap = Size {
528        width: r.get("columnGap").and_then(lp).or(gap_all).unwrap_or(LengthPercentage::ZERO),
529        height: r.get("rowGap").and_then(lp).or(gap_all).unwrap_or(LengthPercentage::ZERO),
530    };
531    s.size = Size {
532        width: r.get("width").and_then(dimension).unwrap_or(Dimension::auto()),
533        height: r.get("height").and_then(dimension).unwrap_or(Dimension::auto()),
534    };
535    s.min_size = Size {
536        width: r.get("minWidth").and_then(dimension).unwrap_or(Dimension::auto()),
537        height: r.get("minHeight").and_then(dimension).unwrap_or(Dimension::auto()),
538    };
539    s.max_size = Size {
540        width: r.get("maxWidth").and_then(dimension).unwrap_or(Dimension::auto()),
541        height: r.get("maxHeight").and_then(dimension).unwrap_or(Dimension::auto()),
542    };
543    if let Some(v) = r.get("aspectRatio") {
544        s.aspect_ratio = num(v).or_else(|| {
545            let (a, b) = v.as_str()?.split_once('/')?;
546            Some(a.trim().parse::<f32>().ok()? / b.trim().parse::<f32>().ok()?)
547        });
548    }
549    if str_of("position") != Some("sticky") {
550        s.inset = rect_lpa(r, "inset", "insetHorizontal", "insetVertical", ["top", "right", "bottom", "left"], LengthPercentageAuto::auto());
551    }
552    s.margin = rect_lpa(r, "margin", "marginHorizontal", "marginVertical", ["marginTop", "marginRight", "marginBottom", "marginLeft"], LengthPercentageAuto::ZERO);
553    if kind == BoxKind::Container {
554        s.padding = rect_lp(r, "padding", "paddingHorizontal", "paddingVertical", ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"]);
555        if let Some([t, rr, b, l]) = border_sides(r) {
556            s.border = Rect { left: length(l), right: length(rr), top: length(t), bottom: length(b) };
557        }
558    }
559    if let Some(v) = str_of("gridTemplateColumns") {
560        s.grid_template_columns = tracks::parse_tracks(v)?;
561    }
562    if let Some(v) = str_of("gridTemplateRows") {
563        s.grid_template_rows = tracks::parse_tracks(v)?;
564    }
565    if let Some(rows) = r.get("gridTemplateAreas").and_then(Value::as_array) {
566        let rows: Vec<String> = rows.iter().filter_map(|v| v.as_str().map(str::to_string)).collect();
567        s.grid_template_areas = tracks::parse_areas(&rows)?;
568    }
569    if let Some(v) = str_of("gridArea") {
570        let l = tracks::parse_area_placement(v);
571        s.grid_row = l.clone();
572        s.grid_column = l;
573    }
574    if let Some(v) = str_of("gridColumn") {
575        s.grid_column = tracks::parse_placement(v)?;
576    }
577    if let Some(v) = str_of("gridRow") {
578        s.grid_row = tracks::parse_placement(v)?;
579    }
580    Ok(s)
581}
582
583fn shadow_layers(v: &Value) -> Option<Vec<ShadowLayer>> {
584    let layers = v.as_array()?;
585    Some(
586        layers
587            .iter()
588            .filter_map(|l| {
589                let o = l.as_object()?;
590                Some(ShadowLayer {
591                    x: o.get("x").and_then(num).unwrap_or(0.0),
592                    y: o.get("y").and_then(num).unwrap_or(0.0),
593                    blur: o.get("blur").and_then(num).unwrap_or(0.0),
594                    spread: o.get("spread").and_then(num).unwrap_or(0.0),
595                    color: o.get("color").and_then(Value::as_str).unwrap_or("#00000000").to_string(),
596                })
597            })
598            .collect(),
599    )
600}
601
602fn gradient(v: &Value) -> Option<Gradient> {
603    let o = v.as_object()?;
604    let stops = o
605        .get("stops")?
606        .as_array()?
607        .iter()
608        .filter_map(|s| Some(GradientStop { color: s.get("color")?.as_str()?.to_string(), offset: s.get("offset").and_then(num).unwrap_or(0.0) }))
609        .collect::<Vec<_>>();
610    (stops.len() >= 2).then(|| Gradient { angle: o.get("angle").and_then(num).unwrap_or(180.0), stops })
611}
612
613/// `translate(Xpx, Ypx) scale(N) rotate(Ndeg)` → the ops in order.
614pub fn parse_transform(s: &str) -> Option<Vec<TransformOp>> {
615    if !crate::style_check::is_transform(s) {
616        return None;
617    }
618    let mut out = Vec::new();
619    let mut rest = s;
620    while let Some(close) = rest.find(')') {
621        let (piece, after) = rest.split_at(close + 1);
622        if let Some(args) = piece.strip_prefix("translate(").and_then(|r| r.strip_suffix(')')) {
623            let (x, y) = args.split_once(", ")?;
624            out.push(TransformOp::Translate { x: x.strip_suffix("px")?.parse().ok()?, y: y.strip_suffix("px")?.parse().ok()? });
625        } else if let Some(n) = piece.strip_prefix("scale(").and_then(|r| r.strip_suffix(')')) {
626            out.push(TransformOp::Scale { factor: n.parse().ok()? });
627        } else if let Some(n) = piece.strip_prefix("rotate(").and_then(|r| r.strip_suffix(')')) {
628            out.push(TransformOp::Rotate { degrees: n.strip_suffix("deg")?.parse().ok()? });
629        }
630        rest = after.strip_prefix(' ').unwrap_or(after);
631    }
632    Some(out)
633}
634
635fn easing(v: &Value) -> Option<[f32; 4]> {
636    let a = v.as_array()?;
637    if a.len() != 4 {
638        return None;
639    }
640    Some([num(&a[0])?, num(&a[1])?, num(&a[2])?, num(&a[3])?])
641}
642
643/// Average of two sides (what a measurer that adds `2 × padding` needs).
644fn avg(a: f32, b: f32) -> f32 {
645    (a + b) / 2.0
646}
647
648/// The painted subset of a resolved flat map. For a [`BoxKind::Leaf`] the
649/// recipe's padding/gap ride along for the painter and the measurer.
650/// `%` radii are left out here (the surface resolves them against the box).
651pub fn visual(r: &Map<String, Value>, kind: BoxKind) -> Visual {
652    let str_of = |k: &str| r.get(k).and_then(Value::as_str).map(str::to_string);
653    let leaf = kind == BoxKind::Leaf;
654    let pads = if leaf { sides_px(r, "padding", "paddingHorizontal", "paddingVertical", ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"]) } else { None };
655    let borders = border_sides(r);
656    let uniform_border = borders.filter(|b| b.iter().all(|w| (w - b[0]).abs() < 0.001)).map(|b| b[0]);
657    let corners = ["borderTopLeftRadius", "borderTopRightRadius", "borderBottomRightRadius", "borderBottomLeftRadius"];
658    let radius = r.get("borderRadius").and_then(px);
659    let corner_radii = corners.iter().any(|k| r.get(*k).and_then(px).is_some()).then(|| {
660        let base = radius.unwrap_or(0.0);
661        let mut out = [base; 4];
662        for (i, k) in corners.iter().enumerate() {
663            if let Some(v) = r.get(*k).and_then(px) {
664                out[i] = v;
665            }
666        }
667        out
668    });
669    let ox = axis_overflow(r, "overflowX").map(str::to_string);
670    let oy = axis_overflow(r, "overflowY").map(str::to_string);
671    let any = |v: &Option<String>, set: &[&str]| v.as_deref().is_some_and(|x| set.contains(&x));
672    let transition = r.get("transition").and_then(num).map(|ms| Transition { duration_ms: ms, easing: r.get("transitionEasing").and_then(easing).unwrap_or(DEFAULT_EASING) });
673    Visual {
674        background_color: str_of("backgroundColor"),
675        background_gradient: r.get("backgroundGradient").and_then(gradient),
676        color: str_of("color"),
677        border_width: uniform_border.or_else(|| r.get("borderWidth").and_then(num)),
678        border_widths: borders.filter(|_| uniform_border.is_none()),
679        border_color: str_of("borderColor"),
680        border_style: str_of("borderStyle"),
681        border_radius: radius,
682        corner_radii,
683        opacity: r.get("opacity").and_then(num),
684        box_shadow: r.get("boxShadow").and_then(shadow_layers),
685        font_size: r.get("fontSize").and_then(px),
686        font_weight: r.get("fontWeight").and_then(|v| v.as_u64()).map(|w| w as u16),
687        line_height: r.get("lineHeight").and_then(px),
688        font_family: str_of("fontFamily"),
689        text_align: str_of("textAlign"),
690        letter_spacing: r.get("letterSpacing").and_then(px),
691        text_decoration: str_of("textDecoration"),
692        text_transform: str_of("textTransform"),
693        font_style: str_of("fontStyle"),
694        padding_horizontal: pads.map(|p| avg(p[1], p[3])),
695        padding_vertical: pads.map(|p| avg(p[0], p[2])),
696        padding: pads,
697        gap: if leaf { r.get("gap").and_then(px) } else { None },
698        native: r.get("native").and_then(Value::as_bool).unwrap_or(false),
699        overflow_hidden: any(&ox, &["hidden", "clip"]) || any(&oy, &["hidden", "clip"]),
700        overflow_scroll: any(&ox, &["scroll", "auto"]) || any(&oy, &["scroll", "auto"]),
701        overflow_x: ox,
702        overflow_y: oy,
703        transform: r.get("transform").and_then(Value::as_str).and_then(parse_transform),
704        transition,
705        visibility_hidden: r.get("visibility").and_then(Value::as_str) == Some("hidden"),
706        display_none: r.get("display").and_then(Value::as_str) == Some("none"),
707        pointer_events_none: r.get("pointerEvents").and_then(Value::as_str) == Some("none"),
708        user_select: str_of("userSelect"),
709        cursor: str_of("cursor"),
710        series_colors: None,
711        direction: None,
712        backdrop_blur: r.get("backdropBlur").and_then(px),
713        animation: None,
714        sticky: r.get("position").and_then(Value::as_str) == Some("sticky"),
715    }
716}
717
718/// A resolved style's own `direction` (inherited down the tree by the
719/// surface; the root's, else the locale's, is the surface direction).
720pub fn direction_of(r: &Map<String, Value>) -> Option<Direction> {
721    match r.get("direction").and_then(Value::as_str) {
722        Some("rtl") => Some(Direction::Rtl),
723        Some("ltr") => Some(Direction::Ltr),
724        _ => None,
725    }
726}
727
728#[cfg(test)]
729mod tests {
730    use super::*;
731    use serde_json::json;
732
733    fn ctx(width: f32, states: &[&str]) -> ConditionContext {
734        ConditionContext { width, states: states.iter().map(|s| s.to_string()).collect(), breakpoints: crate::conditions::default_breakpoints(), ..Default::default() }
735    }
736
737    #[test]
738    fn media_and_states_flatten_in_source_order() {
739        let style = json!({"gap": 4, "@media (min-width: 600px)": {"gap": 8}, "@media (min-width: 900px)": {"gap": 12}, ":pressed": {"opacity": 0.6}});
740        let m = style.as_object().unwrap();
741        assert_eq!(resolve_conditions(m, &ctx(390.0, &[])).get("gap"), Some(&json!(4)));
742        let wide = resolve_conditions(m, &ctx(900.0, &["pressed"]));
743        assert_eq!(wide.get("gap"), Some(&json!(12)));
744        assert_eq!(wide.get("opacity"), Some(&json!(0.6)));
745    }
746
747    #[test]
748    fn logical_properties_follow_the_direction() {
749        let mut m = json!({"marginInlineStart": 8, "insetInlineEnd": 4, "paddingInlineEnd": 2, "insetBlockEnd": 3, "textAlign": "start"}).as_object().unwrap().clone();
750        resolve_logical(&mut m, Direction::Rtl);
751        assert_eq!(m.get("marginRight"), Some(&json!(8)));
752        assert_eq!(m.get("left"), Some(&json!(4)));
753        assert_eq!(m.get("paddingLeft"), Some(&json!(2)));
754        assert_eq!(m.get("bottom"), Some(&json!(3)));
755        assert_eq!(m.get("textAlign"), Some(&json!("right")));
756        assert!(m.get("marginInlineStart").is_none());
757    }
758
759    #[test]
760    fn leaf_padding_stays_out_of_taffy_with_every_side() {
761        let m = json!({"paddingHorizontal": 16, "paddingTop": 2, "paddingLeft": 10, "height": 36, "borderWidth": 1}).as_object().unwrap().clone();
762        let leaf = to_taffy(&m, Direction::Ltr, BoxKind::Leaf).unwrap();
763        assert_eq!(leaf.padding, Rect::zero());
764        assert_eq!(leaf.border, Rect::zero());
765        let container = to_taffy(&m, Direction::Ltr, BoxKind::Container).unwrap();
766        assert_eq!(container.padding.left, length(10.0));
767        assert_eq!(container.padding.right, length(16.0));
768        let v = visual(&m, BoxKind::Leaf);
769        assert_eq!(v.padding, Some([2.0, 16.0, 0.0, 10.0]));
770        assert_eq!(v.padding_horizontal, Some(13.0));
771        assert_eq!(v.padding_vertical, Some(1.0));
772        assert_eq!(v.border_width, Some(1.0));
773    }
774
775    #[test]
776    fn per_side_borders_reach_taffy_and_the_visual() {
777        let m = json!({"borderWidth": 1, "borderBottomWidth": 3, "borderStyle": "dashed", "borderRadius": 8, "borderTopLeftRadius": 2}).as_object().unwrap().clone();
778        let s = to_taffy(&m, Direction::Ltr, BoxKind::Container).unwrap();
779        assert_eq!(s.border.bottom, length(3.0));
780        assert_eq!(s.border.top, length(1.0));
781        let v = visual(&m, BoxKind::Container);
782        assert_eq!(v.border_widths, Some([1.0, 1.0, 3.0, 1.0]));
783        assert_eq!(v.border_width, Some(1.0));
784        assert_eq!(v.border_style.as_deref(), Some("dashed"));
785        assert_eq!(v.corner_radii, Some([2.0, 8.0, 8.0, 8.0]));
786    }
787
788    #[test]
789    fn overflow_per_axis_and_paint_only_keys() {
790        let m = json!({"overflowX": "scroll", "overflow": "hidden", "transform": "translate(4px, -2px) rotate(90deg)", "transition": 150, "transitionEasing": [0.2, 0, 0, 1], "visibility": "hidden", "pointerEvents": "none", "backgroundGradient": {"angle": 90, "stops": [{"color": "#ff0000", "offset": 0}, {"color": "#0000ff", "offset": 1}]}}).as_object().unwrap().clone();
791        let s = to_taffy(&m, Direction::Ltr, BoxKind::Container).unwrap();
792        assert_eq!(s.overflow.x, Overflow::Scroll);
793        assert_eq!(s.overflow.y, Overflow::Clip);
794        assert_eq!(is_scroll_container(&m), (true, false));
795        let v = visual(&m, BoxKind::Container);
796        assert_eq!(v.transform, Some(vec![TransformOp::Translate { x: 4.0, y: -2.0 }, TransformOp::Rotate { degrees: 90.0 }]));
797        assert_eq!(v.transition, Some(Transition { duration_ms: 150.0, easing: [0.2, 0.0, 0.0, 1.0] }));
798        assert!(v.visibility_hidden && v.pointer_events_none);
799        assert_eq!(v.background_gradient.as_ref().unwrap().stops.len(), 2);
800    }
801}