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