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    for el in config.layouts_mut() {
24        walk_element(el, target, &mut degraded);
25    }
26    degraded.sort();
27    degraded.dedup();
28    NormalizedConfig { config, degraded }
29}
30
31fn walk_element(el: &mut WidgetElement, target: WidgetPlatform, degraded: &mut Vec<String>) {
32    let type_name = el.type_name();
33    match support_for(type_name, target).support {
34        Support::Unsupported => {
35            degraded.push(format!("{type_name}:unsupported"));
36        }
37        Support::Degraded => {
38            degraded.push(format!("{type_name}:degraded"));
39        }
40        Support::Full => {}
41    }
42
43    match el {
44        WidgetElement::VStack(v) => {
45            if v.spacing.is_none() {
46                v.spacing = Some(0.0);
47            }
48            for c in &mut v.children {
49                walk_element(c, target, degraded);
50            }
51        }
52        WidgetElement::HStack(v) => {
53            if v.spacing.is_none() {
54                v.spacing = Some(0.0);
55            }
56            for c in &mut v.children {
57                walk_element(c, target, degraded);
58            }
59        }
60        WidgetElement::ZStack(v) => {
61            for c in &mut v.children {
62                walk_element(c, target, degraded);
63            }
64        }
65        WidgetElement::Grid(v) => {
66            if v.spacing.is_none() {
67                v.spacing = Some(0.0);
68            }
69            for c in &mut v.children {
70                walk_element(c, target, degraded);
71            }
72        }
73        WidgetElement::Container(v) => {
74            for c in &mut v.children {
75                walk_element(c, target, degraded);
76            }
77        }
78        WidgetElement::Link(v) => {
79            for c in &mut v.children {
80                walk_element(c, target, degraded);
81            }
82        }
83        WidgetElement::Text(t) => normalize_text(t),
84        WidgetElement::Date(d) => {
85            if let Some(ref mut c) = d.color {
86                *c = expand_semantic_color(c);
87            }
88        }
89        WidgetElement::Timer(t) => {
90            if let Some(ref mut c) = t.color {
91                *c = expand_semantic_color(c);
92            }
93        }
94        WidgetElement::Repeat(r) => walk_element(&mut r.template, target, degraded),
95        WidgetElement::Use(_) => {}
96        WidgetElement::Label(l) => {
97            if let Some(ref mut c) = l.color {
98                *c = expand_semantic_color(c);
99            }
100            if let Some(ref mut c) = l.icon_color {
101                *c = expand_semantic_color(c);
102            }
103        }
104        WidgetElement::Image(i) => {
105            if let Some(ref mut c) = i.color {
106                *c = expand_semantic_color(c);
107            }
108            if i.url.is_some() && matches!(target, WidgetPlatform::Ios | WidgetPlatform::Macos) {
109                // URL images are prefetched host-side; note residual risk if data empty.
110                if i.data.as_ref().map(|s| s.is_empty()).unwrap_or(true) {
111                    degraded.push("image.url:prefetch-required".into());
112                }
113            }
114        }
115        WidgetElement::Button(b) => {
116            if let Some(ref mut c) = b.color {
117                *c = expand_semantic_color(c);
118            }
119            if let Some(ref mut c) = b.background_color {
120                *c = expand_semantic_color(c);
121            }
122        }
123        WidgetElement::Toggle(t) => {
124            if let Some(ref mut c) = t.tint {
125                *c = expand_semantic_color(c);
126            }
127        }
128        WidgetElement::Progress(p) => {
129            if let Some(ref mut c) = p.color {
130                *c = expand_semantic_color(c);
131            }
132            if let Some(ref mut c) = p.tint {
133                *c = expand_semantic_color(c);
134            }
135        }
136        WidgetElement::Gauge(g) => {
137            if let Some(ref mut c) = g.color {
138                *c = expand_semantic_color(c);
139            }
140            if let Some(ref mut c) = g.tint {
141                *c = expand_semantic_color(c);
142            }
143        }
144        WidgetElement::Divider(d) => {
145            if let Some(ref mut c) = d.color {
146                *c = expand_semantic_color(c);
147            }
148        }
149        WidgetElement::List(l) => {
150            if let Some(ref mut c) = l.color {
151                *c = expand_semantic_color(c);
152            }
153            if l.spacing.is_none() {
154                l.spacing = Some(4.0);
155            }
156        }
157        WidgetElement::Chart(c) => {
158            if let Some(ref mut t) = c.tint {
159                *t = expand_semantic_color(t);
160            }
161        }
162        WidgetElement::Shape(s) => {
163            if let Some(ref mut c) = s.fill {
164                *c = expand_semantic_color(c);
165            }
166            if let Some(ref mut c) = s.stroke {
167                *c = expand_semantic_color(c);
168            }
169        }
170        WidgetElement::Canvas(_) | WidgetElement::Spacer(_) => {}
171    }
172
173    // Shared ElementStyle colors on every variant that flattens style.
174    expand_style_colors(el);
175}
176
177fn expand_style_colors(el: &mut WidgetElement) {
178    let style = match el {
179        WidgetElement::VStack(v) => Some(&mut v.style),
180        WidgetElement::HStack(v) => Some(&mut v.style),
181        WidgetElement::ZStack(v) => Some(&mut v.style),
182        WidgetElement::Grid(v) => Some(&mut v.style),
183        WidgetElement::Container(v) => Some(&mut v.style),
184        WidgetElement::Text(v) => Some(&mut v.style),
185        WidgetElement::Image(v) => Some(&mut v.style),
186        WidgetElement::Progress(v) => Some(&mut v.style),
187        WidgetElement::Gauge(v) => Some(&mut v.style),
188        WidgetElement::Button(v) => Some(&mut v.style),
189        WidgetElement::Toggle(v) => Some(&mut v.style),
190        WidgetElement::Divider(v) => Some(&mut v.style),
191        WidgetElement::Spacer(_) => None,
192        WidgetElement::Date(v) => Some(&mut v.style),
193        WidgetElement::Chart(v) => Some(&mut v.style),
194        WidgetElement::List(v) => Some(&mut v.style),
195        WidgetElement::Link(v) => Some(&mut v.style),
196        WidgetElement::Shape(v) => Some(&mut v.style),
197        WidgetElement::Timer(v) => Some(&mut v.style),
198        WidgetElement::Label(v) => Some(&mut v.style),
199        WidgetElement::Repeat(v) => Some(&mut v.style),
200        WidgetElement::Use(v) => Some(&mut v.style),
201        WidgetElement::Canvas(v) => Some(&mut v.style),
202    };
203    if let Some(style) = style {
204        if let Some(ref mut bg) = style.background {
205            if let crate::models::BackgroundValue::Solid(s) = bg {
206                if let Some(pair) = semantic_pair(s) {
207                    *bg = crate::models::BackgroundValue::Adaptive {
208                        light: pair.0.into(),
209                        dark: pair.1.into(),
210                    };
211                }
212            }
213        }
214        if let Some(ref mut border) = style.border {
215            if let Some((light, dark)) = semantic_pair(&border.color) {
216                // Borders are single-color on wire; pick dark-friendly default hex.
217                let _ = light;
218                border.color = dark.to_string();
219            }
220        }
221    }
222}
223
224fn normalize_text(t: &mut TextElement) {
225    if let Some(ts) = t.text_style.take() {
226        let (size, weight) = text_style_metrics(&ts);
227        if t.font_size.is_none() {
228            t.font_size = Some(size);
229        }
230        if t.font_weight.is_none() {
231            t.font_weight = weight;
232        }
233    }
234    if let Some(ref mut c) = t.color {
235        *c = expand_semantic_color(c);
236    }
237}
238
239/// Approximate Dynamic Type → points (body = 17), matching Apple defaults.
240fn text_style_metrics(ts: &TextStyle) -> (f64, Option<FontWeight>) {
241    match ts {
242        TextStyle::LargeTitle => (34.0, Some(FontWeight::Regular)),
243        TextStyle::Title => (28.0, Some(FontWeight::Regular)),
244        TextStyle::Title2 => (22.0, Some(FontWeight::Regular)),
245        TextStyle::Title3 => (20.0, Some(FontWeight::Regular)),
246        TextStyle::Headline => (17.0, Some(FontWeight::Semibold)),
247        TextStyle::Body => (17.0, Some(FontWeight::Regular)),
248        TextStyle::Callout => (16.0, Some(FontWeight::Regular)),
249        TextStyle::Subheadline => (15.0, Some(FontWeight::Regular)),
250        TextStyle::Footnote => (13.0, Some(FontWeight::Regular)),
251        TextStyle::Caption => (12.0, Some(FontWeight::Regular)),
252        TextStyle::Caption2 => (11.0, Some(FontWeight::Regular)),
253    }
254}
255
256fn expand_semantic_color(c: &ColorValue) -> ColorValue {
257    match c {
258        ColorValue::Solid(s) => {
259            if let Some((light, dark)) = semantic_pair(s) {
260                ColorValue::Adaptive {
261                    light: light.to_string(),
262                    dark: dark.to_string(),
263                }
264            } else {
265                c.clone()
266            }
267        }
268        ColorValue::Adaptive { .. } => c.clone(),
269    }
270}
271
272fn semantic_pair(name: &str) -> Option<(&'static str, &'static str)> {
273    match name {
274        "label" => Some(("#000000", "#FFFFFF")),
275        "secondaryLabel" => Some(("#3C3C43", "#EBEBF5")),
276        "systemBackground" => Some(("#FFFFFF", "#000000")),
277        "secondarySystemBackground" => Some(("#F2F2F7", "#1C1C1E")),
278        "accent" => Some(("#007AFF", "#0A84FF")),
279        "separator" => Some(("#C6C6C8", "#545458")),
280        _ => None,
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use crate::models::{text, vstack, BackgroundValue};
288    use std::fs;
289    use std::path::PathBuf;
290
291    fn cfg(v: serde_json::Value) -> WidgetConfig {
292        serde_json::from_value(v).unwrap()
293    }
294
295    fn adaptive(c: Option<ColorValue>) -> (String, String) {
296        match c {
297            Some(ColorValue::Adaptive { light, dark }) => (light, dark),
298            other => panic!("expected adaptive, got {other:?}"),
299        }
300    }
301
302    /// Semantic colour names: the lookup table → a text colour → style background and
303    /// border → layouts inside timeline `entries`; hex values are left alone.
304    #[test]
305    fn semantic_colors_expand_through_every_layout() {
306        // 1. The table (light, dark) per name; a hex is not a semantic name.
307        for (name, light, dark) in [
308            ("label", "#000000", "#FFFFFF"),
309            ("secondaryLabel", "#3C3C43", "#EBEBF5"),
310            ("systemBackground", "#FFFFFF", "#000000"),
311            ("secondarySystemBackground", "#F2F2F7", "#1C1C1E"),
312            ("accent", "#007AFF", "#0A84FF"),
313            ("separator", "#C6C6C8", "#545458"),
314        ] {
315            assert_eq!(semantic_pair(name), Some((light, dark)), "{name}");
316        }
317        assert_eq!(semantic_pair("#123456"), None);
318
319        // 2. A text colour becomes an adaptive pair.
320        let mut el = text("Hi");
321        el.color = Some(ColorValue::Solid("label".into()));
322        let out = normalize(&WidgetConfig::small(el), WidgetPlatform::Desktop);
323        let WidgetElement::Text(t) = out.config.small.unwrap() else {
324            panic!("expected text")
325        };
326        assert_eq!(
327            adaptive(t.color),
328            ("#000000".to_string(), "#FFFFFF".to_string())
329        );
330
331        // 3. Style background → adaptive; a border colour has one slot, so it takes the dark one.
332        let out = normalize(
333            &cfg(
334                serde_json::json!({"small": {"type": "vstack", "background": "systemBackground",
335                "border": {"color": "separator", "width": 1}}}),
336            ),
337            WidgetPlatform::Desktop,
338        );
339        let WidgetElement::VStack(v) = out.config.small.unwrap() else {
340            panic!("expected vstack")
341        };
342        match v.style.background {
343            Some(BackgroundValue::Adaptive { light, dark }) => {
344                assert_eq!((light.as_str(), dark.as_str()), ("#FFFFFF", "#000000"))
345            }
346            other => panic!("{other:?}"),
347        }
348        assert_eq!(v.style.border.unwrap().color, "#545458");
349
350        // 4. Timeline entries are layouts too.
351        let out = normalize(
352            &cfg(
353                serde_json::json!({"entries": [{"at": 1, "small": {"type": "text", "content": "x", "color": "label"}}]}),
354            ),
355            WidgetPlatform::Desktop,
356        );
357        let WidgetElement::Text(t) = out.config.entries[0].small.clone().unwrap() else {
358            panic!("expected text")
359        };
360        assert!(matches!(t.color, Some(ColorValue::Adaptive { .. })));
361    }
362
363    /// Typography and stack defaults become explicit, so renderers never guess them.
364    #[test]
365    fn text_style_and_stack_spacing_become_explicit() {
366        // 1. `textStyle: title` → fontSize 28 + regular weight, the style itself is dropped.
367        let mut el = text("Hi");
368        el.text_style = Some(TextStyle::Title);
369        el.font_size = None;
370        let out = normalize(&WidgetConfig::small(el), WidgetPlatform::Desktop);
371        let WidgetElement::Text(t) = out.config.small.unwrap() else {
372            panic!("expected text")
373        };
374        assert!(t.text_style.is_none());
375        assert_eq!(t.font_size, Some(28.0));
376        assert!(matches!(t.font_weight, Some(FontWeight::Regular)));
377
378        // 2. Stack spacing defaults to 0 instead of the platform's own default.
379        let cfg = WidgetConfig::small(vstack(vec![text("a").into(), text("b").into()]));
380        let WidgetElement::VStack(v) = normalize(&cfg, WidgetPlatform::Ios).config.small.unwrap()
381        else {
382            panic!("expected vstack")
383        };
384        assert_eq!(v.spacing, Some(0.0));
385    }
386
387    /// `image.url` needs a host prefetch only where the widget cannot fetch (Apple), and
388    /// only until `data` is filled.
389    #[test]
390    fn image_url_prefetch_degradation_by_platform_and_data() {
391        let url_only =
392            cfg(serde_json::json!({"small": {"type": "image", "url": "https://x/a.png"}}));
393        assert_eq!(
394            normalize(&url_only, WidgetPlatform::Ios).degraded,
395            ["image.url:prefetch-required"]
396        );
397        assert!(normalize(&url_only, WidgetPlatform::Android)
398            .degraded
399            .is_empty());
400        let with_data = cfg(
401            serde_json::json!({"small": {"type": "image", "url": "https://x/a.png", "data": "QQ=="}}),
402        );
403        assert!(normalize(&with_data, WidgetPlatform::Ios)
404            .degraded
405            .is_empty());
406        let no_url = cfg(serde_json::json!({"small": {"type": "image", "systemName": "star"}}));
407        assert!(!normalize(&no_url, WidgetPlatform::Ios)
408            .degraded
409            .iter()
410            .any(|d| d.contains("prefetch")));
411    }
412
413    /// One config through the host pipeline: parse → normalize per platform → capability
414    /// warnings → Adaptive Card transpile of the normalized result.
415    #[test]
416    fn config_pipeline_parse_normalize_validate_transpile() {
417        use crate::adaptive_card::{to_adaptive_card_for_size, validate_card_structure};
418        use crate::capabilities::validate_config;
419
420        // 1. Parse.
421        let raw = cfg(serde_json::json!({
422            "small": {"type": "vstack", "children": [
423                {"type": "text", "content": "Now", "textStyle": "title", "color": "label"},
424                {"type": "image", "url": "https://x/a.png"},
425                {"type": "timer", "targetDate": "2030-01-01T00:00:00Z"}
426            ]}
427        }));
428        // 2. Normalize: Apple needs the image prefetched; Windows degrades the live timer.
429        let ios = normalize(&raw, WidgetPlatform::Ios);
430        assert!(ios
431            .degraded
432            .contains(&"image.url:prefetch-required".to_string()));
433        let win = normalize(&raw, WidgetPlatform::Windows);
434        assert!(
435            win.degraded.contains(&"timer:degraded".to_string()),
436            "{:?}",
437            win.degraded
438        );
439        assert!(!win.degraded.iter().any(|d| d.contains("prefetch")));
440        let WidgetElement::VStack(v) = win.config.small.clone().unwrap() else {
441            panic!("expected vstack")
442        };
443        assert_eq!(v.spacing, Some(0.0));
444        // 3. Capability warnings agree with the degradations above.
445        let warns = validate_config(&win.config, WidgetPlatform::Windows);
446        assert!(warns.iter().any(|w| w.element == "timer.live"), "{warns:?}");
447        // 4. The normalized config still transpiles to a valid AC 1.5 card with the
448        //    concrete font size / colour.
449        let card = to_adaptive_card_for_size(&win.config, "small")
450            .unwrap()
451            .card;
452        validate_card_structure(&card).unwrap();
453        assert!(card.to_string().contains("Now"), "{card}");
454    }
455
456    /// Every fixture normalizes on the platforms without a native renderer (no panics).
457    #[test]
458    fn fixtures_normalize_without_panic() {
459        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
460        let mut n = 0;
461        for entry in fs::read_dir(&root).unwrap() {
462            let entry = entry.unwrap();
463            if entry.path().is_dir() {
464                for f in fs::read_dir(entry.path()).unwrap() {
465                    let f = f.unwrap().path();
466                    if f.extension().and_then(|e| e.to_str()) != Some("json") {
467                        continue;
468                    }
469                    let raw = fs::read_to_string(&f).unwrap();
470                    let cfg: WidgetConfig = match serde_json::from_str(&raw) {
471                        Ok(c) => c,
472                        Err(_) => continue, // event before/after blobs etc.
473                    };
474                    let _ = normalize(&cfg, WidgetPlatform::Desktop);
475                    let _ = normalize(&cfg, WidgetPlatform::Windows);
476                    n += 1;
477                }
478            }
479        }
480        assert!(n > 10, "expected to normalize many fixtures, got {n}");
481    }
482}