Skip to main content

tauri_plugin_widgets/
normalize.rs

1//! Host-side IR normalization before store write.
2//!
3//! Renders must not guess defaults: `textStyle` becomes concrete `fontSize`,
4//! semantic colors become adaptive hex pairs, stack `spacing` is explicit.
5//! Layout (flex, Dynamic Type reflow) stays on the native renderer.
6
7use crate::capabilities::{support_for, Support, WidgetPlatform};
8use crate::models::{ColorValue, FontWeight, TextElement, TextStyle, WidgetConfig, WidgetElement};
9
10/// Result of [`normalize`]: config with resolved defaults + recorded degradations.
11#[derive(Debug, Clone)]
12pub struct NormalizedConfig {
13    /// Config ready to serialize into the store.
14    pub config: WidgetConfig,
15    /// Capability degradations applied / noted (`canvas.path`, `image.url`, …).
16    pub degraded: Vec<String>,
17}
18
19/// Expand semantic typography / colors / spacing so renderers do not guess.
20pub fn normalize(cfg: &WidgetConfig, target: WidgetPlatform) -> NormalizedConfig {
21    let mut degraded = Vec::new();
22    let mut config = cfg.clone();
23    if let Some(el) = config.small.as_mut() {
24        walk_element(el, target, &mut degraded);
25    }
26    if let Some(el) = config.medium.as_mut() {
27        walk_element(el, target, &mut degraded);
28    }
29    if let Some(el) = config.large.as_mut() {
30        walk_element(el, target, &mut degraded);
31    }
32    degraded.sort();
33    degraded.dedup();
34    NormalizedConfig { config, degraded }
35}
36
37fn walk_element(el: &mut WidgetElement, target: WidgetPlatform, degraded: &mut Vec<String>) {
38    let type_name = el.type_name();
39    match support_for(type_name, target).support {
40        Support::Unsupported => {
41            degraded.push(format!("{type_name}:unsupported"));
42        }
43        Support::Degraded => {
44            degraded.push(format!("{type_name}:degraded"));
45        }
46        Support::Full => {}
47    }
48
49    match el {
50        WidgetElement::VStack(v) => {
51            if v.spacing.is_none() {
52                v.spacing = Some(0.0);
53            }
54            for c in &mut v.children {
55                walk_element(c, target, degraded);
56            }
57        }
58        WidgetElement::HStack(v) => {
59            if v.spacing.is_none() {
60                v.spacing = Some(0.0);
61            }
62            for c in &mut v.children {
63                walk_element(c, target, degraded);
64            }
65        }
66        WidgetElement::ZStack(v) => {
67            for c in &mut v.children {
68                walk_element(c, target, degraded);
69            }
70        }
71        WidgetElement::Grid(v) => {
72            if v.spacing.is_none() {
73                v.spacing = Some(0.0);
74            }
75            for c in &mut v.children {
76                walk_element(c, target, degraded);
77            }
78        }
79        WidgetElement::Container(v) => {
80            for c in &mut v.children {
81                walk_element(c, target, degraded);
82            }
83        }
84        WidgetElement::Link(v) => {
85            for c in &mut v.children {
86                walk_element(c, target, degraded);
87            }
88        }
89        WidgetElement::Text(t) => normalize_text(t),
90        WidgetElement::Date(d) => {
91            if let Some(ref mut c) = d.color {
92                *c = expand_semantic_color(c);
93            }
94        }
95        WidgetElement::Timer(t) => {
96            if let Some(ref mut c) = t.color {
97                *c = expand_semantic_color(c);
98            }
99        }
100        WidgetElement::Label(l) => {
101            if let Some(ref mut c) = l.color {
102                *c = expand_semantic_color(c);
103            }
104            if let Some(ref mut c) = l.icon_color {
105                *c = expand_semantic_color(c);
106            }
107        }
108        WidgetElement::Image(i) => {
109            if let Some(ref mut c) = i.color {
110                *c = expand_semantic_color(c);
111            }
112            if i.url.is_some() && matches!(target, WidgetPlatform::Ios | WidgetPlatform::Macos) {
113                // URL images are prefetched host-side; note residual risk if data empty.
114                if i.data.as_ref().map(|s| s.is_empty()).unwrap_or(true) {
115                    degraded.push("image.url:prefetch-required".into());
116                }
117            }
118        }
119        WidgetElement::Button(b) => {
120            if let Some(ref mut c) = b.color {
121                *c = expand_semantic_color(c);
122            }
123            if let Some(ref mut c) = b.background_color {
124                *c = expand_semantic_color(c);
125            }
126        }
127        WidgetElement::Toggle(t) => {
128            if let Some(ref mut c) = t.tint {
129                *c = expand_semantic_color(c);
130            }
131        }
132        WidgetElement::Progress(p) => {
133            if let Some(ref mut c) = p.color {
134                *c = expand_semantic_color(c);
135            }
136            if let Some(ref mut c) = p.tint {
137                *c = expand_semantic_color(c);
138            }
139        }
140        WidgetElement::Gauge(g) => {
141            if let Some(ref mut c) = g.color {
142                *c = expand_semantic_color(c);
143            }
144            if let Some(ref mut c) = g.tint {
145                *c = expand_semantic_color(c);
146            }
147        }
148        WidgetElement::Divider(d) => {
149            if let Some(ref mut c) = d.color {
150                *c = expand_semantic_color(c);
151            }
152        }
153        WidgetElement::List(l) => {
154            if let Some(ref mut c) = l.color {
155                *c = expand_semantic_color(c);
156            }
157            if l.spacing.is_none() {
158                l.spacing = Some(4.0);
159            }
160        }
161        WidgetElement::Chart(c) => {
162            if let Some(ref mut t) = c.tint {
163                *t = expand_semantic_color(t);
164            }
165        }
166        WidgetElement::Shape(s) => {
167            if let Some(ref mut c) = s.fill {
168                *c = expand_semantic_color(c);
169            }
170            if let Some(ref mut c) = s.stroke {
171                *c = expand_semantic_color(c);
172            }
173        }
174        WidgetElement::Canvas(_) | WidgetElement::Spacer(_) => {}
175    }
176
177    // Shared ElementStyle colors on every variant that flattens style.
178    expand_style_colors(el);
179}
180
181fn expand_style_colors(el: &mut WidgetElement) {
182    let style = match el {
183        WidgetElement::VStack(v) => Some(&mut v.style),
184        WidgetElement::HStack(v) => Some(&mut v.style),
185        WidgetElement::ZStack(v) => Some(&mut v.style),
186        WidgetElement::Grid(v) => Some(&mut v.style),
187        WidgetElement::Container(v) => Some(&mut v.style),
188        WidgetElement::Text(v) => Some(&mut v.style),
189        WidgetElement::Image(v) => Some(&mut v.style),
190        WidgetElement::Progress(v) => Some(&mut v.style),
191        WidgetElement::Gauge(v) => Some(&mut v.style),
192        WidgetElement::Button(v) => Some(&mut v.style),
193        WidgetElement::Toggle(v) => Some(&mut v.style),
194        WidgetElement::Divider(v) => Some(&mut v.style),
195        WidgetElement::Spacer(_) => None,
196        WidgetElement::Date(v) => Some(&mut v.style),
197        WidgetElement::Chart(v) => Some(&mut v.style),
198        WidgetElement::List(v) => Some(&mut v.style),
199        WidgetElement::Link(v) => Some(&mut v.style),
200        WidgetElement::Shape(v) => Some(&mut v.style),
201        WidgetElement::Timer(v) => Some(&mut v.style),
202        WidgetElement::Label(v) => Some(&mut v.style),
203        WidgetElement::Canvas(v) => Some(&mut v.style),
204    };
205    if let Some(style) = style {
206        if let Some(ref mut bg) = style.background {
207            if let crate::models::BackgroundValue::Solid(s) = bg {
208                if let Some(pair) = semantic_pair(s) {
209                    *bg = crate::models::BackgroundValue::Adaptive {
210                        light: pair.0.into(),
211                        dark: pair.1.into(),
212                    };
213                }
214            }
215        }
216        if let Some(ref mut border) = style.border {
217            if let Some((light, dark)) = semantic_pair(&border.color) {
218                // Borders are single-color on wire; pick dark-friendly default hex.
219                let _ = light;
220                border.color = dark.to_string();
221            }
222        }
223    }
224}
225
226fn normalize_text(t: &mut TextElement) {
227    if let Some(ts) = t.text_style.take() {
228        let (size, weight) = text_style_metrics(&ts);
229        if t.font_size.is_none() {
230            t.font_size = Some(size);
231        }
232        if t.font_weight.is_none() {
233            t.font_weight = weight;
234        }
235    }
236    if let Some(ref mut c) = t.color {
237        *c = expand_semantic_color(c);
238    }
239}
240
241/// Approximate Dynamic Type → points (body = 17), matching Apple defaults.
242fn text_style_metrics(ts: &TextStyle) -> (f64, Option<FontWeight>) {
243    match ts {
244        TextStyle::LargeTitle => (34.0, Some(FontWeight::Regular)),
245        TextStyle::Title => (28.0, Some(FontWeight::Regular)),
246        TextStyle::Title2 => (22.0, Some(FontWeight::Regular)),
247        TextStyle::Title3 => (20.0, Some(FontWeight::Regular)),
248        TextStyle::Headline => (17.0, Some(FontWeight::Semibold)),
249        TextStyle::Body => (17.0, Some(FontWeight::Regular)),
250        TextStyle::Callout => (16.0, Some(FontWeight::Regular)),
251        TextStyle::Subheadline => (15.0, Some(FontWeight::Regular)),
252        TextStyle::Footnote => (13.0, Some(FontWeight::Regular)),
253        TextStyle::Caption => (12.0, Some(FontWeight::Regular)),
254        TextStyle::Caption2 => (11.0, Some(FontWeight::Regular)),
255    }
256}
257
258fn expand_semantic_color(c: &ColorValue) -> ColorValue {
259    match c {
260        ColorValue::Solid(s) => {
261            if let Some((light, dark)) = semantic_pair(s) {
262                ColorValue::Adaptive {
263                    light: light.to_string(),
264                    dark: dark.to_string(),
265                }
266            } else {
267                c.clone()
268            }
269        }
270        ColorValue::Adaptive { .. } => c.clone(),
271    }
272}
273
274fn semantic_pair(name: &str) -> Option<(&'static str, &'static str)> {
275    match name {
276        "label" => Some(("#000000", "#FFFFFF")),
277        "secondaryLabel" => Some(("#3C3C43", "#EBEBF5")),
278        "systemBackground" => Some(("#FFFFFF", "#000000")),
279        "secondarySystemBackground" => Some(("#F2F2F7", "#1C1C1E")),
280        "accent" => Some(("#007AFF", "#0A84FF")),
281        "separator" => Some(("#C6C6C8", "#545458")),
282        _ => None,
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use crate::models::{text, vstack, FontWeight, TextStyle, WidgetConfig};
290    use std::fs;
291    use std::path::PathBuf;
292
293    #[test]
294    fn text_style_becomes_font_size() {
295        let mut el = text("Hi");
296        el.text_style = Some(TextStyle::Title);
297        el.font_size = None;
298        let cfg = WidgetConfig::small(el);
299        let out = normalize(&cfg, WidgetPlatform::Desktop);
300        match out.config.small.unwrap() {
301            WidgetElement::Text(t) => {
302                assert!(t.text_style.is_none());
303                assert_eq!(t.font_size, Some(28.0));
304                assert!(matches!(t.font_weight, Some(FontWeight::Regular)));
305            }
306            other => panic!("expected text, got {other:?}"),
307        }
308    }
309
310    #[test]
311    fn stack_spacing_defaults_to_zero() {
312        let cfg = WidgetConfig::small(vstack(vec![text("a").into(), text("b").into()]));
313        let out = normalize(&cfg, WidgetPlatform::Ios);
314        match out.config.small.unwrap() {
315            WidgetElement::VStack(v) => assert_eq!(v.spacing, Some(0.0)),
316            other => panic!("expected vstack, got {other:?}"),
317        }
318    }
319
320    #[test]
321    fn semantic_label_becomes_adaptive() {
322        let mut el = text("Hi");
323        el.color = Some(ColorValue::Solid("label".into()));
324        let out = normalize(&WidgetConfig::small(el), WidgetPlatform::Desktop);
325        match out.config.small.unwrap() {
326            WidgetElement::Text(t) => match t.color.unwrap() {
327                ColorValue::Adaptive { light, dark } => {
328                    assert_eq!(light, "#000000");
329                    assert_eq!(dark, "#FFFFFF");
330                }
331                other => panic!("expected adaptive, got {other:?}"),
332            },
333            other => panic!("expected text, got {other:?}"),
334        }
335    }
336
337    #[test]
338    fn fixtures_normalize_without_panic() {
339        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
340        let mut n = 0;
341        for entry in fs::read_dir(&root).unwrap() {
342            let entry = entry.unwrap();
343            if entry.path().is_dir() {
344                for f in fs::read_dir(entry.path()).unwrap() {
345                    let f = f.unwrap().path();
346                    if f.extension().and_then(|e| e.to_str()) != Some("json") {
347                        continue;
348                    }
349                    let raw = fs::read_to_string(&f).unwrap();
350                    let cfg: WidgetConfig = match serde_json::from_str(&raw) {
351                        Ok(c) => c,
352                        Err(_) => continue, // event before/after blobs etc.
353                    };
354                    let _ = normalize(&cfg, WidgetPlatform::Desktop);
355                    let _ = normalize(&cfg, WidgetPlatform::Windows);
356                    n += 1;
357                }
358            }
359        }
360        assert!(n > 10, "expected to normalize many fixtures, got {n}");
361    }
362}