Skip to main content

tauri_plugin_widgets/
snapshot.rs

1//! Canonical layout dump for core WidgetConfig snapshot tests.
2
3use crate::models::{WidgetConfig, WidgetElement, VStackElement, HStackElement, ZStackElement, GridElement, ContainerElement, TextElement, ImageElement, ProgressElement, ButtonElement, DividerElement, SpacerElement, LinkElement, ShapeElement};
4use serde_json::{json, Value};
5
6/// Core elements present on every platform with preserved IR semantics.
7/// Pixel-identical rendering is not guaranteed — see degraded cells in the
8/// capability matrix (`image.systemName`, Windows `zstack` / `shape`, …).
9pub const CORE_ELEMENTS: &[&str] = &[
10    "vstack",
11    "hstack",
12    "zstack",
13    "container",
14    "grid",
15    "text",
16    "image",
17    "spacer",
18    "divider",
19    "progress",
20    "button",
21    "link",
22    "shape",
23];
24
25/// Extended / best-effort elements (platform-dependent).
26pub const EXTENDED_ELEMENTS: &[&str] = &[
27    "gauge", "toggle", "date", "chart", "list", "timer", "canvas", "label",
28];
29
30/// `true` when `ty` is in [`CORE_ELEMENTS`].
31pub fn is_core_element(ty: &str) -> bool {
32    CORE_ELEMENTS.contains(&ty)
33}
34
35/// Dump a config to a stable JSON tree (nulls omitted, keys sorted via serde_json Map order
36/// of insertion — we insert in a fixed order).
37pub fn dump_config(config: &WidgetConfig) -> Value {
38    let mut obj = serde_json::Map::new();
39    obj.insert("version".into(), json!(config.version));
40    if let Some(el) = &config.small {
41        obj.insert("small".into(), dump_element(el, ParentAxis::Vertical));
42    }
43    if let Some(el) = &config.medium {
44        obj.insert("medium".into(), dump_element(el, ParentAxis::Vertical));
45    }
46    if let Some(el) = &config.large {
47        obj.insert("large".into(), dump_element(el, ParentAxis::Vertical));
48    }
49    Value::Object(obj)
50}
51
52#[derive(Clone, Copy)]
53enum ParentAxis {
54    Vertical,
55    Horizontal,
56    Overlay,
57}
58
59fn dump_element(el: &WidgetElement, parent: ParentAxis) -> Value {
60    let ty = el.type_name();
61    let mut obj = serde_json::Map::new();
62    obj.insert("type".into(), json!(ty));
63    obj.insert(
64        "tier".into(),
65        json!(if is_core_element(ty) {
66            "core"
67        } else {
68            "extended"
69        }),
70    );
71
72    match el {
73        WidgetElement::VStack(VStackElement {
74            children,
75            spacing,
76            alignment,
77            style,
78        }) => {
79            put_opt_f64(&mut obj, "spacing", *spacing);
80            if let Some(a) = alignment {
81                obj.insert("alignment".into(), json!(format!("{a:?}").to_lowercase()));
82            }
83            dump_style(&mut obj, style);
84            obj.insert(
85                "children".into(),
86                Value::Array(
87                    children
88                        .iter()
89                        .map(|c| dump_element(c, ParentAxis::Vertical))
90                        .collect(),
91                ),
92            );
93        }
94        WidgetElement::HStack(HStackElement {
95            children,
96            spacing,
97            alignment,
98            style,
99        }) => {
100            put_opt_f64(&mut obj, "spacing", *spacing);
101            if let Some(a) = alignment {
102                obj.insert("alignment".into(), json!(format!("{a:?}").to_lowercase()));
103            }
104            dump_style(&mut obj, style);
105            obj.insert(
106                "children".into(),
107                Value::Array(
108                    children
109                        .iter()
110                        .map(|c| dump_element(c, ParentAxis::Horizontal))
111                        .collect(),
112                ),
113            );
114        }
115        WidgetElement::ZStack(ZStackElement {
116            children,
117            alignment,
118            style,
119        }) => {
120            if let Some(a) = alignment {
121                obj.insert("alignment".into(), json!(a));
122            } else {
123                obj.insert("alignment".into(), json!("center"));
124            }
125            dump_style(&mut obj, style);
126            obj.insert(
127                "children".into(),
128                Value::Array(
129                    children
130                        .iter()
131                        .map(|c| dump_element(c, ParentAxis::Overlay))
132                        .collect(),
133                ),
134            );
135        }
136        WidgetElement::Grid(GridElement {
137            children,
138            columns,
139            spacing,
140            row_spacing,
141            style,
142        }) => {
143            obj.insert("columns".into(), json!(columns));
144            put_opt_f64(&mut obj, "spacing", *spacing);
145            put_opt_f64(&mut obj, "rowSpacing", *row_spacing);
146            dump_style(&mut obj, style);
147            obj.insert(
148                "children".into(),
149                Value::Array(
150                    children
151                        .iter()
152                        .map(|c| dump_element(c, ParentAxis::Vertical))
153                        .collect(),
154                ),
155            );
156        }
157        WidgetElement::Container(ContainerElement {
158            children,
159            content_alignment,
160            style,
161        }) => {
162            if let Some(a) = content_alignment {
163                obj.insert("contentAlignment".into(), json!(a));
164            }
165            dump_style(&mut obj, style);
166            obj.insert(
167                "children".into(),
168                Value::Array(
169                    children
170                        .iter()
171                        .map(|c| dump_element(c, ParentAxis::Overlay))
172                        .collect(),
173                ),
174            );
175        }
176        WidgetElement::Text(TextElement {
177            content,
178            font_size,
179            font_weight,
180            text_style,
181            color,
182            alignment,
183            line_limit,
184            style,
185            ..
186        }) => {
187            obj.insert("content".into(), json!(content));
188            put_opt_f64(&mut obj, "fontSize", *font_size);
189            if let Some(w) = font_weight {
190                obj.insert("fontWeight".into(), json!(format!("{w:?}").to_lowercase()));
191            }
192            if let Some(ts) = text_style {
193                obj.insert("textStyle".into(), json!(format!("{ts:?}")));
194            }
195            if let Some(c) = color {
196                obj.insert("color".into(), color_json(c));
197            }
198            if let Some(a) = alignment {
199                obj.insert("alignment".into(), json!(format!("{a:?}").to_lowercase()));
200            }
201            if let Some(n) = line_limit {
202                obj.insert("lineLimit".into(), json!(n));
203            }
204            dump_style(&mut obj, style);
205        }
206        WidgetElement::Image(ImageElement {
207            system_name,
208            url,
209            size,
210            color,
211            content_mode,
212            style,
213            ..
214        }) => {
215            if let Some(s) = system_name {
216                obj.insert("systemName".into(), json!(s));
217            }
218            if let Some(u) = url {
219                obj.insert("url".into(), json!(u));
220            }
221            put_opt_f64(&mut obj, "size", *size);
222            if let Some(c) = color {
223                obj.insert("color".into(), color_json(c));
224            }
225            if let Some(m) = content_mode {
226                obj.insert("contentMode".into(), json!(format!("{m:?}").to_lowercase()));
227            }
228            dump_style(&mut obj, style);
229        }
230        WidgetElement::Spacer(SpacerElement { min_length }) => {
231            put_opt_f64(&mut obj, "minLength", *min_length);
232            obj.insert("flex".into(), json!(true));
233        }
234        WidgetElement::Divider(DividerElement {
235            color,
236            thickness,
237            style,
238        }) => {
239            let axis = match parent {
240                ParentAxis::Horizontal => "vertical",
241                _ => "horizontal",
242            };
243            obj.insert("axis".into(), json!(axis));
244            put_opt_f64(&mut obj, "thickness", *thickness);
245            if let Some(c) = color {
246                obj.insert("color".into(), color_json(c));
247            }
248            dump_style(&mut obj, style);
249        }
250        WidgetElement::Progress(ProgressElement {
251            value,
252            total,
253            label,
254            tint,
255            bar_style,
256            style,
257            ..
258        }) => {
259            obj.insert("value".into(), json!(value));
260            obj.insert("total".into(), json!(total));
261            if let Some(l) = label {
262                obj.insert("label".into(), json!(l));
263            }
264            if let Some(t) = tint {
265                obj.insert("tint".into(), color_json(t));
266            }
267            if let Some(s) = bar_style {
268                obj.insert("barStyle".into(), json!(format!("{s:?}").to_lowercase()));
269            }
270            dump_style(&mut obj, style);
271        }
272        WidgetElement::Button(ButtonElement {
273            label,
274            url,
275            action,
276            color,
277            background_color,
278            font_size,
279            style,
280            ..
281        }) => {
282            obj.insert("label".into(), json!(label));
283            if let Some(u) = url {
284                obj.insert("url".into(), json!(u));
285            }
286            if let Some(a) = action {
287                obj.insert("action".into(), json!(a));
288            }
289            if let Some(c) = color {
290                obj.insert("color".into(), color_json(c));
291            }
292            if let Some(c) = background_color {
293                obj.insert("backgroundColor".into(), color_json(c));
294            }
295            put_opt_f64(&mut obj, "fontSize", *font_size);
296            dump_style(&mut obj, style);
297        }
298        WidgetElement::Link(LinkElement {
299            children,
300            url,
301            action,
302            style,
303        }) => {
304            if let Some(u) = url {
305                obj.insert("url".into(), json!(u));
306            }
307            if let Some(a) = action {
308                obj.insert("action".into(), json!(a));
309            }
310            dump_style(&mut obj, style);
311            obj.insert(
312                "children".into(),
313                Value::Array(children.iter().map(|c| dump_element(c, parent)).collect()),
314            );
315        }
316        WidgetElement::Shape(ShapeElement {
317            shape_type,
318            fill,
319            stroke,
320            stroke_width,
321            size,
322            style,
323        }) => {
324            obj.insert(
325                "shapeType".into(),
326                json!(format!("{shape_type:?}").to_lowercase()),
327            );
328            // Capsule contract: width = 2*size, height = size when size set.
329            if let Some(s) = size {
330                obj.insert("size".into(), json!(s));
331                if matches!(shape_type, crate::models::ShapeType::Capsule) {
332                    obj.insert("width".into(), json!(s * 2.0));
333                    obj.insert("height".into(), json!(s));
334                } else {
335                    obj.insert("width".into(), json!(s));
336                    obj.insert("height".into(), json!(s));
337                }
338            }
339            if let Some(f) = fill {
340                obj.insert("fill".into(), color_json(f));
341            }
342            if let Some(s) = stroke {
343                obj.insert("stroke".into(), color_json(s));
344            }
345            put_opt_f64(&mut obj, "strokeWidth", *stroke_width);
346            dump_style(&mut obj, style);
347        }
348        // Extended — minimal dump
349        other => {
350            obj.insert("note".into(), json!("extended best-effort"));
351            let _ = other;
352        }
353    }
354
355    Value::Object(obj)
356}
357
358fn put_opt_f64(obj: &mut serde_json::Map<String, Value>, key: &str, v: Option<f64>) {
359    if let Some(n) = v {
360        obj.insert(key.into(), json!(n));
361    }
362}
363
364fn color_json(c: &crate::models::ColorValue) -> Value {
365    match c {
366        crate::models::ColorValue::Solid(s) => json!(s),
367        crate::models::ColorValue::Adaptive { light, dark } => {
368            json!({ "light": light, "dark": dark })
369        }
370    }
371}
372
373fn dump_style(obj: &mut serde_json::Map<String, Value>, style: &crate::models::ElementStyle) {
374    if let Some(p) = &style.padding {
375        obj.insert(
376            "padding".into(),
377            serde_json::to_value(p).unwrap_or(Value::Null),
378        );
379    }
380    if let Some(b) = &style.background {
381        obj.insert(
382            "background".into(),
383            serde_json::to_value(b).unwrap_or(Value::Null),
384        );
385    }
386    put_opt_f64(obj, "cornerRadius", style.corner_radius);
387    put_opt_f64(obj, "opacity", style.opacity);
388    put_opt_f64(obj, "flex", style.flex);
389    if let Some(f) = &style.frame {
390        obj.insert(
391            "frame".into(),
392            serde_json::to_value(f).unwrap_or(Value::Null),
393        );
394    }
395    if let Some(b) = &style.border {
396        obj.insert(
397            "border".into(),
398            serde_json::to_value(b).unwrap_or(Value::Null),
399        );
400    }
401    if let Some(s) = &style.shadow {
402        obj.insert(
403            "shadow".into(),
404            serde_json::to_value(s).unwrap_or(Value::Null),
405        );
406    }
407    if let Some(c) = &style.clip_shape {
408        obj.insert(
409            "clipShape".into(),
410            serde_json::to_value(c).unwrap_or(Value::Null),
411        );
412    }
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418    use std::fs;
419    use std::path::PathBuf;
420
421    fn snap_dir() -> PathBuf {
422        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/snapshots/core")
423    }
424
425    fn fixture_dir() -> PathBuf {
426        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/core")
427    }
428
429    #[test]
430    fn core_and_extended_partition_element_types() {
431        use crate::capabilities::ELEMENT_TYPES;
432        let mut all: Vec<&str> = CORE_ELEMENTS
433            .iter()
434            .chain(EXTENDED_ELEMENTS.iter())
435            .copied()
436            .collect();
437        all.sort();
438        let mut expected: Vec<&str> = ELEMENT_TYPES.to_vec();
439        expected.sort();
440        assert_eq!(all, expected);
441    }
442
443    #[test]
444    fn core_layout_snapshot() {
445        let path = fixture_dir().join("layout.json");
446        if !path.exists() {
447            fs::create_dir_all(path.parent().unwrap()).unwrap();
448            let sample = r##"{
449  "version": 1,
450  "small": {
451    "type": "vstack",
452    "spacing": 8,
453    "padding": 12,
454    "background": "#1a1a2e",
455    "children": [
456      { "type": "text", "content": "Hello", "fontSize": 18, "fontWeight": "bold", "color": "#fff", "alignment": "center" },
457      { "type": "hstack", "spacing": 4, "children": [
458        { "type": "shape", "shapeType": "capsule", "size": 8, "fill": "#4CAF50" },
459        { "type": "divider" },
460        { "type": "spacer" },
461        { "type": "progress", "value": 0.5, "tint": "#4CAF50", "label": "OK" }
462      ]},
463      { "type": "button", "label": "Go", "action": "go", "backgroundColor": "#2196F3", "color": "#fff" }
464    ]
465  }
466}"##;
467            fs::write(&path, sample).unwrap();
468        }
469        let cfg: WidgetConfig = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
470        let dump = dump_config(&cfg);
471        let pretty = serde_json::to_string_pretty(&dump).unwrap() + "\n";
472
473        let snap = snap_dir().join("layout.snap.json");
474        fs::create_dir_all(snap.parent().unwrap()).unwrap();
475        if !snap.exists() {
476            fs::write(&snap, &pretty).unwrap();
477        }
478        let expected = fs::read_to_string(&snap).unwrap();
479        assert_eq!(pretty, expected, "core layout snapshot drifted");
480
481        // Divider inside hstack must be vertical axis in dump.
482        let hstack = &dump["small"]["children"][1];
483        assert_eq!(hstack["type"], "hstack");
484        assert_eq!(hstack["children"][1]["type"], "divider");
485        assert_eq!(hstack["children"][1]["axis"], "vertical");
486        // Capsule size contract
487        assert_eq!(hstack["children"][0]["width"], 16.0);
488        assert_eq!(hstack["children"][0]["height"], 8.0);
489    }
490}