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] = &[
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
25pub const EXTENDED_ELEMENTS: &[&str] = &[
27 "gauge", "toggle", "date", "chart", "list", "timer", "canvas", "label",
28];
29
30pub fn is_core_element(ty: &str) -> bool {
32 CORE_ELEMENTS.contains(&ty)
33}
34
35pub 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 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 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 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 assert_eq!(hstack["children"][0]["width"], 16.0);
488 assert_eq!(hstack["children"][0]["height"], 8.0);
489 }
490}