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