Skip to main content

eui_core/
geometry.rs

1//! The MEASURE contract a painter (built in or a host's override) must keep
2//! for a control (VAPP-92; mirrors `src/geometry.ts`): its box comes from the
3//! theme's tokens through the recipe of the part that IS the control, never
4//! from the painter. `control-geometry.json` records every built-in theme ×
5//! control × size; [`check_geometry`] is what the conformance suite runs an
6//! override through.
7
8use indexmap::IndexMap;
9use serde::{Deserialize, Serialize};
10
11use crate::json;
12use crate::theme::{resolve_recipe, Mode, RecipeQuery, ResolvedTheme};
13use crate::types::Props;
14
15/// The part whose recipe sizes each control.
16pub const CONTROL_PARTS: &[(&str, &str)] = &[
17    ("Button", "root"),
18    ("Toggle", "root"),
19    ("Segmented", "item"),
20    ("Input", "field"),
21    ("Textarea", "field"),
22    ("Select", "trigger"),
23    ("DatePicker", "trigger"),
24    ("Checkbox", "box"),
25    ("Radio", "item"),
26    ("Switch", "track"),
27    ("Slider", "thumb"),
28    ("Avatar", "root"),
29    ("Icon", "root"),
30    ("Spinner", "root"),
31    ("Ring", "root"),
32    ("Tabs", "tab"),
33    // Round 1: the new fields keep the input height contract.
34    ("NumberField", "field"),
35    ("ChipInput", "field"),
36    ("TimePicker", "trigger"),
37    ("DateRangePicker", "trigger"),
38];
39
40pub const GEOMETRY_KEYS: &[&str] =
41    &["width", "height", "minWidth", "minHeight", "paddingHorizontal", "paddingVertical", "padding", "gap", "borderWidth", "borderRadius"];
42
43/// The sizing part of a control (`root` for anything not listed).
44pub fn control_part(component: &str) -> &'static str {
45    CONTROL_PARTS.iter().find(|(c, _)| *c == component).map(|(_, p)| *p).unwrap_or("root")
46}
47
48/// The numeric box of a control for a theme (mode-independent), in
49/// [`GEOMETRY_KEYS`] order, numeric values only.
50pub fn control_geometry(theme: &ResolvedTheme, component: &str, props: &Props, states: &[String]) -> IndexMap<String, f64> {
51    let query = RecipeQuery::new(component, control_part(component), props.clone(), states.to_vec());
52    let style = resolve_recipe(theme, &query, Mode::Light);
53    let mut out = IndexMap::new();
54    for key in GEOMETRY_KEYS {
55        if let Some(v) = style.get(*key).and_then(|v| v.as_f64()) {
56            out.insert(key.to_string(), v);
57        }
58    }
59    out
60}
61
62/// What a painter reports after measuring the control it drew.
63#[derive(Clone, Copy, PartialEq, Debug, Default, Serialize, Deserialize)]
64#[serde(rename_all = "camelCase")]
65pub struct MeasuredBox {
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub width: Option<f64>,
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub height: Option<f64>,
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub padding_horizontal: Option<f64>,
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub padding_vertical: Option<f64>,
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub border_width: Option<f64>,
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub border_radius: Option<f64>,
78}
79
80/// A host's replacement painter for one native kind: it may draw anything,
81/// but `measure` must return the theme's box.
82pub trait PainterOverride {
83    fn component(&self) -> &str;
84    fn measure(&self, theme: &ResolvedTheme, props: &Props, states: &[String]) -> MeasuredBox;
85}
86
87#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)]
88pub struct GeometryIssue {
89    pub key: String,
90    pub expected: f64,
91    pub actual: Option<f64>,
92}
93
94/// Every geometry key the theme fixes that the measured box misses or
95/// changes (a key the theme leaves open is the painter's).
96pub fn check_geometry(expected: &IndexMap<String, f64>, measured: &MeasuredBox, tolerance: f64) -> Vec<GeometryIssue> {
97    let mut issues = Vec::new();
98    let mut probe = |key: &str, actual: Option<f64>| {
99        let Some(&want) = expected.get(key) else { return };
100        if actual.is_none_or(|a| (a - want).abs() > tolerance) {
101            issues.push(GeometryIssue { key: key.into(), expected: want, actual });
102        }
103    };
104    probe("width", measured.width);
105    // A control with a minimum height (Textarea) grows from it: the recipe's
106    // `height` is its one-line base, so only the floor is checked (VAPP-91).
107    if !expected.contains_key("minHeight") {
108        probe("height", measured.height);
109    }
110    probe("paddingHorizontal", measured.padding_horizontal);
111    probe("paddingVertical", measured.padding_vertical);
112    probe("borderWidth", measured.border_width);
113    probe("borderRadius", measured.border_radius);
114    if let Some(&min) = expected.get("minHeight") {
115        if measured.height.is_none_or(|h| h + tolerance < min) {
116            issues.push(GeometryIssue { key: "minHeight".into(), expected: min, actual: measured.height });
117        }
118    }
119    issues
120}
121
122/// The tolerance the TS defaults to.
123pub const DEFAULT_TOLERANCE: f64 = 0.5;
124
125/// One fixture case an override is run through.
126#[derive(Clone, PartialEq, Debug, Default, Serialize, Deserialize)]
127pub struct GeometryCase {
128    #[serde(default)]
129    pub props: Props,
130    #[serde(default)]
131    pub states: Vec<String>,
132}
133
134#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)]
135pub struct GeometryFailure {
136    pub name: String,
137    pub issues: Vec<GeometryIssue>,
138}
139
140/// Run an override through every case the fixture holds for its kind; only
141/// the failing cases come back (named `k=v,…` or `default`).
142pub fn verify_painter_geometry(override_: &dyn PainterOverride, theme: &ResolvedTheme, cases: &[GeometryCase]) -> Vec<GeometryFailure> {
143    cases
144        .iter()
145        .map(|c| {
146            let name = c.props.iter().map(|(k, v)| format!("{k}={}", json::to_js_string(v))).collect::<Vec<_>>().join(",");
147            let expected = control_geometry(theme, override_.component(), &c.props, &c.states);
148            let issues = check_geometry(&expected, &override_.measure(theme, &c.props, &c.states), DEFAULT_TOLERANCE);
149            GeometryFailure { name: if name.is_empty() { "default".into() } else { name }, issues }
150        })
151        .filter(|r| !r.issues.is_empty())
152        .collect()
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn check_geometry_tolerates_half_a_point() {
161        let expected: IndexMap<String, f64> = [("height".to_string(), 20.0)].into_iter().collect();
162        assert!(check_geometry(&expected, &MeasuredBox { height: Some(20.4), ..Default::default() }, DEFAULT_TOLERANCE).is_empty());
163        let min: IndexMap<String, f64> = [("minHeight".to_string(), 20.0)].into_iter().collect();
164        assert_eq!(check_geometry(&min, &MeasuredBox::default(), DEFAULT_TOLERANCE).len(), 1);
165    }
166}