1use indexmap::IndexMap;
9use serde::{Deserialize, Serialize};
10
11use crate::json;
12use crate::theme::{resolve_recipe, Mode, RecipeQuery, ResolvedTheme};
13use crate::types::Props;
14
15pub 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 ("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
43pub fn control_part(component: &str) -> &'static str {
45 CONTROL_PARTS.iter().find(|(c, _)| *c == component).map(|(_, p)| *p).unwrap_or("root")
46}
47
48pub 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#[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
80pub 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
94pub 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 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
122pub const DEFAULT_TOLERANCE: f64 = 0.5;
124
125#[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
140pub 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}