Skip to main content

tauri_plugin_widgets/
models.rs

1//! Widget IR types: [`WidgetConfig`], [`WidgetElement`], and shared style values.
2
3use serde::{Deserialize, Serialize};
4
5#[cfg(feature = "schema")]
6use schemars::JsonSchema;
7
8/// Configuration for creating a desktop widget window.
9///
10/// When `url` is omitted the plugin serves its built-in renderer
11/// automatically (via a custom URI-scheme protocol).  In that case
12/// `group` tells the renderer which config to load, and `size`
13/// selects the layout family (`"small"`, `"medium"`, or `"large"`).
14#[derive(Debug, Clone, Serialize, Deserialize)]
15#[cfg_attr(feature = "schema", derive(JsonSchema))]
16#[serde(rename_all = "camelCase")]
17pub struct WidgetWindowConfig {
18    /// Tauri window label (must be unique).
19    pub label: String,
20    /// Frontend route or URL.  Leave empty / omit to use the built-in
21    /// widget renderer that ships with the plugin.
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub url: Option<String>,
24    /// Window width in logical pixels.
25    pub width: f64,
26    /// Window height in logical pixels.
27    pub height: f64,
28    /// Optional X position.
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub x: Option<f64>,
31    /// Optional Y position.
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub y: Option<f64>,
34    /// Keep the widget above other windows.
35    #[serde(default)]
36    pub always_on_top: bool,
37    /// Hide from the taskbar / dock.
38    #[serde(default = "default_true")]
39    pub skip_taskbar: bool,
40    /// Widget group identifier — passed to the built-in renderer so it
41    /// knows which config to load via `get_widget_config`.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub group: Option<String>,
44    /// Widget identity within the group (required for built-in renderer).
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub widget_id: Option<String>,
47    /// Size family the renderer should display: `"small"`, `"medium"`,
48    /// or `"large"`.  Defaults to `"small"` when omitted.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub size: Option<String>,
51}
52
53fn default_true() -> bool {
54    true
55}
56
57// ─── Widget UI Configuration ─────────────────────────────────────────────────
58
59/// Top-level widget config with layouts per size family.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61#[cfg_attr(feature = "schema", derive(JsonSchema))]
62#[serde(rename_all = "camelCase")]
63pub struct WidgetConfig {
64    /// Schema version. Defaults to `1`.
65    #[serde(default = "default_version")]
66    pub version: u32,
67    /// Layout for the small size family.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub small: Option<WidgetElement>,
70    /// Layout for the medium size family.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub medium: Option<WidgetElement>,
73    /// Layout for the large size family.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub large: Option<WidgetElement>,
76}
77
78impl Default for WidgetConfig {
79    fn default() -> Self {
80        Self {
81            version: 1,
82            small: None,
83            medium: None,
84            large: None,
85        }
86    }
87}
88
89impl WidgetConfig {
90    /// Config with only the `small` size family set.
91    pub fn small(el: impl Into<WidgetElement>) -> Self {
92        Self {
93            small: Some(el.into()),
94            ..Default::default()
95        }
96    }
97
98    /// Set the `medium` size family.
99    pub fn with_medium(mut self, el: impl Into<WidgetElement>) -> Self {
100        self.medium = Some(el.into());
101        self
102    }
103
104    /// Set the `large` size family.
105    pub fn with_large(mut self, el: impl Into<WidgetElement>) -> Self {
106        self.large = Some(el.into());
107        self
108    }
109}
110
111fn default_version() -> u32 {
112    1
113}
114
115/// A UI element that can be a layout container or a leaf widget.
116#[derive(Debug, Clone, Serialize, Deserialize)]
117#[cfg_attr(feature = "schema", derive(JsonSchema))]
118#[serde(tag = "type", rename_all = "camelCase")]
119pub enum WidgetElement {
120    /// Vertical stack of children.
121    #[serde(rename = "vstack")]
122    VStack(VStackElement),
123    /// Horizontal stack of children.
124    #[serde(rename = "hstack")]
125    HStack(HStackElement),
126    /// Overlay stack — children layered on top of each other.
127    #[serde(rename = "zstack")]
128    ZStack(ZStackElement),
129    /// Fixed-column grid of children.
130    #[serde(rename = "grid")]
131    Grid(GridElement),
132    /// Single-child wrapper for cards, badges, and overlays.
133    #[serde(rename = "container")]
134    Container(ContainerElement),
135    /// Text label with optional semantic typography.
136    #[serde(rename = "text")]
137    Text(TextElement),
138    /// Image from SF Symbol / drawable name, base64 data, or URL.
139    #[serde(rename = "image")]
140    Image(ImageElement),
141    /// Linear or circular progress indicator.
142    #[serde(rename = "progress")]
143    Progress(ProgressElement),
144    /// Circular or capacity-style gauge.
145    #[serde(rename = "gauge")]
146    Gauge(GaugeElement),
147    /// Tappable button that opens a URL or emits `widget-action`.
148    #[serde(rename = "button")]
149    Button(ButtonElement),
150    /// On/off toggle control.
151    #[serde(rename = "toggle")]
152    Toggle(ToggleElement),
153    /// Horizontal or vertical rule.
154    #[serde(rename = "divider")]
155    Divider(DividerElement),
156    /// Flexible empty space.
157    #[serde(rename = "spacer")]
158    Spacer(SpacerElement),
159    /// Formatted date / relative time display.
160    #[serde(rename = "date")]
161    Date(DateElement),
162    /// Bar, line, area, or pie chart.
163    #[serde(rename = "chart")]
164    Chart(ChartElement),
165    /// Collection list of rows (text, optional checked marker and action).
166    #[serde(rename = "list")]
167    List(ListElement),
168    /// Tappable wrapper — makes nested content clickable.
169    #[serde(rename = "link")]
170    Link(LinkElement),
171    /// Colored shape — circle, capsule, or rectangle.
172    #[serde(rename = "shape")]
173    Shape(ShapeElement),
174    /// Live countdown/countup timer that updates without timeline refresh.
175    #[serde(rename = "timer")]
176    Timer(TimerElement),
177    /// Declarative canvas — draw arbitrary shapes via JSON commands.
178    #[serde(rename = "canvas")]
179    Canvas(CanvasElement),
180    /// Convenience element combining an SF Symbol / icon with text.
181    #[serde(rename = "label")]
182    Label(LabelElement),
183}
184
185/// Vertical stack of children.
186#[derive(Debug, Clone, Serialize, Deserialize, Default)]
187#[cfg_attr(feature = "schema", derive(JsonSchema))]
188#[serde(rename_all = "camelCase")]
189pub struct VStackElement {
190    /// Child elements, top to bottom.
191    #[serde(default, skip_serializing_if = "Vec::is_empty")]
192    pub children: Vec<WidgetElement>,
193    /// Space between children (points).
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub spacing: Option<f64>,
196    /// Horizontal alignment of children.
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub alignment: Option<HorizontalAlignment>,
199    #[serde(flatten)]
200    /// Shared visual style (padding, background, frame, …).
201    pub style: ElementStyle,
202}
203
204/// Horizontal stack of children.
205#[derive(Debug, Clone, Serialize, Deserialize, Default)]
206#[cfg_attr(feature = "schema", derive(JsonSchema))]
207#[serde(rename_all = "camelCase")]
208pub struct HStackElement {
209    /// Child elements, leading to trailing.
210    #[serde(default, skip_serializing_if = "Vec::is_empty")]
211    pub children: Vec<WidgetElement>,
212    /// Space between children (points).
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub spacing: Option<f64>,
215    /// Vertical alignment of children.
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub alignment: Option<VerticalAlignment>,
218    #[serde(flatten)]
219    /// Shared visual style (padding, background, frame, …).
220    pub style: ElementStyle,
221}
222
223/// Overlay stack — children layered on top of each other.
224#[derive(Debug, Clone, Serialize, Deserialize, Default)]
225#[cfg_attr(feature = "schema", derive(JsonSchema))]
226#[serde(rename_all = "camelCase")]
227pub struct ZStackElement {
228    /// Layered children (later draw on top).
229    #[serde(default, skip_serializing_if = "Vec::is_empty")]
230    pub children: Vec<WidgetElement>,
231    /// Alignment of layers within the stack (e.g. `center`, `topLeading`).
232    #[serde(default, skip_serializing_if = "Option::is_none")]
233    pub alignment: Option<String>,
234    #[serde(flatten)]
235    /// Shared visual style (padding, background, frame, …).
236    pub style: ElementStyle,
237}
238
239/// Fixed-column grid of children.
240#[derive(Debug, Clone, Serialize, Deserialize, Default)]
241#[cfg_attr(feature = "schema", derive(JsonSchema))]
242#[serde(rename_all = "camelCase")]
243pub struct GridElement {
244    /// Grid cells in row-major order.
245    #[serde(default, skip_serializing_if = "Vec::is_empty")]
246    pub children: Vec<WidgetElement>,
247    /// Number of columns. Default `2`.
248    #[serde(default = "default_columns")]
249    pub columns: u32,
250    /// Column spacing (points).
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    pub spacing: Option<f64>,
253    /// Row spacing (points).
254    #[serde(
255        rename = "rowSpacing",
256        default,
257        skip_serializing_if = "Option::is_none"
258    )]
259    pub row_spacing: Option<f64>,
260    #[serde(flatten)]
261    /// Shared visual style (padding, background, frame, …).
262    pub style: ElementStyle,
263}
264
265/// Single-child wrapper for cards, badges, and overlays.
266#[derive(Debug, Clone, Serialize, Deserialize, Default)]
267#[cfg_attr(feature = "schema", derive(JsonSchema))]
268#[serde(rename_all = "camelCase")]
269pub struct ContainerElement {
270    /// Nested content (typically one child).
271    #[serde(default, skip_serializing_if = "Vec::is_empty")]
272    pub children: Vec<WidgetElement>,
273    /// Content alignment inside the box (e.g. `center`, `topLeading`).
274    #[serde(
275        rename = "contentAlignment",
276        default,
277        skip_serializing_if = "Option::is_none"
278    )]
279    pub content_alignment: Option<String>,
280    #[serde(flatten)]
281    /// Shared visual style (padding, background, frame, …).
282    pub style: ElementStyle,
283}
284
285/// Text label with optional semantic typography.
286#[derive(Debug, Clone, Serialize, Deserialize, Default)]
287#[cfg_attr(feature = "schema", derive(JsonSchema))]
288#[serde(rename_all = "camelCase")]
289pub struct TextElement {
290    /// String to display.
291    pub content: String,
292    /// Font size in points (overridden by `textStyle` when set).
293    #[serde(rename = "fontSize", default, skip_serializing_if = "Option::is_none")]
294    pub font_size: Option<f64>,
295    /// Font weight.
296    #[serde(
297        rename = "fontWeight",
298        default,
299        skip_serializing_if = "Option::is_none"
300    )]
301    pub font_weight: Option<FontWeight>,
302    /// Font design (default, monospaced, rounded, serif).
303    #[serde(
304        rename = "fontDesign",
305        default,
306        skip_serializing_if = "Option::is_none"
307    )]
308    pub font_design: Option<FontDesign>,
309    /// Semantic text style (uses Dynamic Type on Apple, sp on Android).
310    /// Overrides `fontSize` when set.
311    #[serde(rename = "textStyle", default, skip_serializing_if = "Option::is_none")]
312    pub text_style: Option<TextStyle>,
313    /// Text color.
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub color: Option<ColorValue>,
316    /// Text alignment within the line.
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub alignment: Option<TextAlignment>,
319    /// Maximum number of lines before truncation.
320    #[serde(rename = "lineLimit", default, skip_serializing_if = "Option::is_none")]
321    pub line_limit: Option<u32>,
322    #[serde(flatten)]
323    /// Shared visual style (padding, background, frame, …).
324    pub style: ElementStyle,
325}
326
327impl TextElement {
328    /// Set explicit point size (ignored when `text_style` is set).
329    pub fn font_size(mut self, size: f64) -> Self {
330        self.font_size = Some(size);
331        self
332    }
333
334    /// Set font weight.
335    pub fn font_weight(mut self, weight: FontWeight) -> Self {
336        self.font_weight = Some(weight);
337        self
338    }
339
340    /// Set text color.
341    pub fn color(mut self, color: impl Into<ColorValue>) -> Self {
342        self.color = Some(color.into());
343        self
344    }
345}
346
347/// Image from SF Symbol / drawable name, base64 data, or URL.
348#[derive(Debug, Clone, Serialize, Deserialize, Default)]
349#[cfg_attr(feature = "schema", derive(JsonSchema))]
350#[serde(rename_all = "camelCase")]
351pub struct ImageElement {
352    /// SF Symbol name (Apple) or Material / drawable name (Android).
353    #[serde(
354        rename = "systemName",
355        default,
356        skip_serializing_if = "Option::is_none"
357    )]
358    pub system_name: Option<String>,
359    /// Base64-encoded image data (with or without `data:image/...;base64,` prefix).
360    #[serde(default, skip_serializing_if = "Option::is_none")]
361    pub data: Option<String>,
362    /// Remote image URL (platform support varies — see capability matrix).
363    #[serde(default, skip_serializing_if = "Option::is_none")]
364    pub url: Option<String>,
365    /// Display size in points.
366    #[serde(default, skip_serializing_if = "Option::is_none")]
367    pub size: Option<f64>,
368    /// Tint color for template / symbol images.
369    #[serde(default, skip_serializing_if = "Option::is_none")]
370    pub color: Option<ColorValue>,
371    /// How the image fills its frame (`fit` or `fill`).
372    #[serde(
373        rename = "contentMode",
374        default,
375        skip_serializing_if = "Option::is_none"
376    )]
377    pub content_mode: Option<ContentMode>,
378    #[serde(flatten)]
379    /// Shared visual style (padding, background, frame, …).
380    pub style: ElementStyle,
381}
382
383/// Linear or circular progress indicator.
384#[derive(Debug, Clone, Serialize, Deserialize)]
385#[cfg_attr(feature = "schema", derive(JsonSchema))]
386#[serde(rename_all = "camelCase")]
387pub struct ProgressElement {
388    /// Current value. Clamped to `0..=total` by renderers.
389    pub value: f64,
390    /// Denominator for the ratio. Default `1.0`.
391    #[serde(default = "default_total")]
392    pub total: f64,
393    /// Caption above the bar. Always set on Android so hosts never show null.
394    #[serde(default, skip_serializing_if = "Option::is_none")]
395    pub label: Option<String>,
396    /// Accent / fill color for the completed portion.
397    #[serde(default, skip_serializing_if = "Option::is_none")]
398    pub tint: Option<ColorValue>,
399    /// Track / label color.
400    #[serde(default, skip_serializing_if = "Option::is_none")]
401    pub color: Option<ColorValue>,
402    /// `linear` (default) or `circular`.
403    #[serde(
404        rename = "barStyle",
405        default,
406        skip_serializing_if = "Option::is_none"
407    )]
408    pub bar_style: Option<ProgressStyle>,
409    #[serde(flatten)]
410    /// Shared visual style (padding, background, frame, …).
411    pub style: ElementStyle,
412}
413
414impl Default for ProgressElement {
415    fn default() -> Self {
416        Self {
417            value: 0.0,
418            total: 1.0,
419            label: None,
420            tint: None,
421            color: None,
422            bar_style: None,
423            style: ElementStyle::default(),
424        }
425    }
426}
427
428/// Circular or capacity-style gauge.
429#[derive(Debug, Clone, Serialize, Deserialize, Default)]
430#[cfg_attr(feature = "schema", derive(JsonSchema))]
431#[serde(rename_all = "camelCase")]
432pub struct GaugeElement {
433    /// Current value within `[min, max]`.
434    pub value: f64,
435    /// Lower bound. Default `0`.
436    #[serde(default, skip_serializing_if = "Option::is_none")]
437    pub min: Option<f64>,
438    /// Upper bound. Default `1`.
439    #[serde(default, skip_serializing_if = "Option::is_none")]
440    pub max: Option<f64>,
441    /// Optional caption.
442    #[serde(default, skip_serializing_if = "Option::is_none")]
443    pub label: Option<String>,
444    /// Text shown for the current value (e.g. `"72%"`).
445    #[serde(
446        rename = "currentValueLabel",
447        default,
448        skip_serializing_if = "Option::is_none"
449    )]
450    pub current_value_label: Option<String>,
451    /// Accent color.
452    #[serde(default, skip_serializing_if = "Option::is_none")]
453    pub tint: Option<ColorValue>,
454    /// Secondary / track color.
455    #[serde(default, skip_serializing_if = "Option::is_none")]
456    pub color: Option<ColorValue>,
457    /// Visual style (e.g. `circular`).
458    #[serde(
459        rename = "gaugeStyle",
460        default,
461        skip_serializing_if = "Option::is_none"
462    )]
463    pub gauge_style: Option<GaugeStyle>,
464    #[serde(flatten)]
465    /// Shared visual style (padding, background, frame, …).
466    pub style: ElementStyle,
467}
468
469/// Tappable button that opens a URL or emits `widget-action`.
470#[derive(Debug, Clone, Serialize, Deserialize, Default)]
471#[cfg_attr(feature = "schema", derive(JsonSchema))]
472#[serde(rename_all = "camelCase")]
473pub struct ButtonElement {
474    /// Button label text.
475    pub label: String,
476    /// Deep link URL to open the app (used when no action is set).
477    #[serde(default, skip_serializing_if = "Option::is_none")]
478    pub url: Option<String>,
479    /// Action identifier — emits a `widget-action` Tauri event when tapped.
480    #[serde(default, skip_serializing_if = "Option::is_none")]
481    pub action: Option<String>,
482    /// Label text color.
483    #[serde(default, skip_serializing_if = "Option::is_none")]
484    pub color: Option<ColorValue>,
485    /// Button background color.
486    #[serde(
487        rename = "backgroundColor",
488        default,
489        skip_serializing_if = "Option::is_none"
490    )]
491    pub background_color: Option<ColorValue>,
492    /// Label font size in points.
493    #[serde(rename = "fontSize", default, skip_serializing_if = "Option::is_none")]
494    pub font_size: Option<f64>,
495    /// Label text alignment.
496    #[serde(
497        rename = "textAlignment",
498        default,
499        skip_serializing_if = "Option::is_none"
500    )]
501    pub text_alignment: Option<TextAlignment>,
502    #[serde(flatten)]
503    /// Shared visual style (padding, background, frame, …).
504    pub style: ElementStyle,
505}
506
507/// On/off toggle control.
508#[derive(Debug, Clone, Serialize, Deserialize, Default)]
509#[cfg_attr(feature = "schema", derive(JsonSchema))]
510#[serde(rename_all = "camelCase")]
511pub struct ToggleElement {
512    /// Whether the toggle is on.
513    #[serde(rename = "isOn")]
514    pub is_on: bool,
515    /// Optional label beside the control.
516    #[serde(default, skip_serializing_if = "Option::is_none")]
517    pub label: Option<String>,
518    /// Accent color when on.
519    #[serde(default, skip_serializing_if = "Option::is_none")]
520    pub tint: Option<ColorValue>,
521    /// Action identifier sent back to the app.
522    #[serde(default, skip_serializing_if = "Option::is_none")]
523    pub action: Option<String>,
524    #[serde(flatten)]
525    /// Shared visual style (padding, background, frame, …).
526    pub style: ElementStyle,
527}
528
529/// Horizontal or vertical rule.
530#[derive(Debug, Clone, Serialize, Deserialize, Default)]
531#[cfg_attr(feature = "schema", derive(JsonSchema))]
532#[serde(rename_all = "camelCase")]
533pub struct DividerElement {
534    /// Line color.
535    #[serde(default, skip_serializing_if = "Option::is_none")]
536    pub color: Option<ColorValue>,
537    /// Line thickness in points.
538    #[serde(default, skip_serializing_if = "Option::is_none")]
539    pub thickness: Option<f64>,
540    #[serde(flatten)]
541    /// Shared visual style (padding, background, frame, …).
542    pub style: ElementStyle,
543}
544
545/// Flexible empty space.
546#[derive(Debug, Clone, Serialize, Deserialize, Default)]
547#[cfg_attr(feature = "schema", derive(JsonSchema))]
548#[serde(rename_all = "camelCase")]
549pub struct SpacerElement {
550    /// Minimum length along the parent axis (points).
551    #[serde(rename = "minLength", default, skip_serializing_if = "Option::is_none")]
552    pub min_length: Option<f64>,
553}
554
555/// Formatted date / relative time display.
556#[derive(Debug, Clone, Serialize, Deserialize, Default)]
557#[cfg_attr(feature = "schema", derive(JsonSchema))]
558#[serde(rename_all = "camelCase")]
559pub struct DateElement {
560    /// ISO 8601 date string.
561    pub date: String,
562    /// Display style (`time`, `date`, `relative`, `offset`, `timer`).
563    #[serde(rename = "dateStyle", default, skip_serializing_if = "Option::is_none")]
564    pub date_style: Option<DateStyle>,
565    /// Font size in points.
566    #[serde(rename = "fontSize", default, skip_serializing_if = "Option::is_none")]
567    pub font_size: Option<f64>,
568    /// Text color.
569    #[serde(default, skip_serializing_if = "Option::is_none")]
570    pub color: Option<ColorValue>,
571    #[serde(flatten)]
572    /// Shared visual style (padding, background, frame, …).
573    pub style: ElementStyle,
574}
575
576/// Bar, line, area, or pie chart.
577#[derive(Debug, Clone, Serialize, Deserialize, Default)]
578#[cfg_attr(feature = "schema", derive(JsonSchema))]
579#[serde(rename_all = "camelCase")]
580pub struct ChartElement {
581    /// Chart kind: `bar`, `line`, `area`, or `pie`.
582    #[serde(rename = "chartType")]
583    pub chart_type: ChartType,
584    /// Data points (`label` + `value`, optional per-point `color`).
585    #[serde(default, rename = "chartData", skip_serializing_if = "Vec::is_empty")]
586    pub chart_data: Vec<ChartDataPoint>,
587    /// Default series tint.
588    #[serde(default, skip_serializing_if = "Option::is_none")]
589    pub tint: Option<ColorValue>,
590    #[serde(flatten)]
591    /// Shared visual style (padding, background, frame, …).
592    pub style: ElementStyle,
593}
594
595/// Collection list of rows (text, optional checked marker and action).
596#[derive(Debug, Clone, Serialize, Deserialize, Default)]
597#[cfg_attr(feature = "schema", derive(JsonSchema))]
598#[serde(rename_all = "camelCase")]
599pub struct ListElement {
600    /// Row items.
601    #[serde(default, skip_serializing_if = "Vec::is_empty")]
602    pub items: Vec<ListItem>,
603    /// Space between rows (points).
604    #[serde(default, skip_serializing_if = "Option::is_none")]
605    pub spacing: Option<f64>,
606    /// Row text font size.
607    #[serde(rename = "fontSize", default, skip_serializing_if = "Option::is_none")]
608    pub font_size: Option<f64>,
609    /// Row text color.
610    #[serde(default, skip_serializing_if = "Option::is_none")]
611    pub color: Option<ColorValue>,
612    #[serde(flatten)]
613    /// Shared visual style (padding, background, frame, …).
614    pub style: ElementStyle,
615}
616
617/// Tappable wrapper — makes nested content clickable.
618#[derive(Debug, Clone, Serialize, Deserialize, Default)]
619#[cfg_attr(feature = "schema", derive(JsonSchema))]
620#[serde(rename_all = "camelCase")]
621pub struct LinkElement {
622    /// Nested content to wrap.
623    #[serde(default, skip_serializing_if = "Vec::is_empty")]
624    pub children: Vec<WidgetElement>,
625    /// Deep-link URL to open.
626    #[serde(default, skip_serializing_if = "Option::is_none")]
627    pub url: Option<String>,
628    /// Action identifier — emits `widget-action` event.
629    #[serde(default, skip_serializing_if = "Option::is_none")]
630    pub action: Option<String>,
631    #[serde(flatten)]
632    /// Shared visual style (padding, background, frame, …).
633    pub style: ElementStyle,
634}
635
636/// Colored shape — circle, capsule, or rectangle.
637#[derive(Debug, Clone, Serialize, Deserialize, Default)]
638#[cfg_attr(feature = "schema", derive(JsonSchema))]
639#[serde(rename_all = "camelCase")]
640pub struct ShapeElement {
641    /// Shape kind.
642    #[serde(rename = "shapeType")]
643    pub shape_type: ShapeType,
644    /// Fill color.
645    #[serde(default, skip_serializing_if = "Option::is_none")]
646    pub fill: Option<ColorValue>,
647    /// Stroke color.
648    #[serde(default, skip_serializing_if = "Option::is_none")]
649    pub stroke: Option<ColorValue>,
650    /// Stroke width in points.
651    #[serde(
652        rename = "strokeWidth",
653        default,
654        skip_serializing_if = "Option::is_none"
655    )]
656    pub stroke_width: Option<f64>,
657    /// Bounding size in points.
658    #[serde(default, skip_serializing_if = "Option::is_none")]
659    pub size: Option<f64>,
660    #[serde(flatten)]
661    /// Shared visual style (padding, background, frame, …).
662    pub style: ElementStyle,
663}
664
665/// Live countdown/countup timer that updates without timeline refresh.
666#[derive(Debug, Clone, Serialize, Deserialize, Default)]
667#[cfg_attr(feature = "schema", derive(JsonSchema))]
668#[serde(rename_all = "camelCase")]
669pub struct TimerElement {
670    /// ISO 8601 target date.
671    #[serde(rename = "targetDate")]
672    pub target_date: String,
673    /// Count direction. Default: `down`.
674    #[serde(default, skip_serializing_if = "Option::is_none")]
675    pub counting: Option<TimerCounting>,
676    /// Font size in points.
677    #[serde(rename = "fontSize", default, skip_serializing_if = "Option::is_none")]
678    pub font_size: Option<f64>,
679    /// Font weight.
680    #[serde(
681        rename = "fontWeight",
682        default,
683        skip_serializing_if = "Option::is_none"
684    )]
685    pub font_weight: Option<FontWeight>,
686    /// Text color.
687    #[serde(default, skip_serializing_if = "Option::is_none")]
688    pub color: Option<ColorValue>,
689    #[serde(flatten)]
690    /// Shared visual style (padding, background, frame, …).
691    pub style: ElementStyle,
692}
693
694/// Declarative canvas — draw arbitrary shapes via JSON commands.
695#[derive(Debug, Clone, Serialize, Deserialize, Default)]
696#[cfg_attr(feature = "schema", derive(JsonSchema))]
697#[serde(rename_all = "camelCase")]
698pub struct CanvasElement {
699    /// Canvas width in points.
700    pub width: f64,
701    /// Canvas height in points.
702    pub height: f64,
703    /// Draw commands (`circle`, `line`, `rect`, `arc`, `text`, `path`).
704    #[serde(default, skip_serializing_if = "Vec::is_empty")]
705    pub elements: Vec<CanvasDrawCommand>,
706    #[serde(flatten)]
707    /// Shared visual style (padding, background, frame, …).
708    pub style: ElementStyle,
709}
710
711/// Convenience element combining an SF Symbol / icon with text.
712#[derive(Debug, Clone, Serialize, Deserialize, Default)]
713#[cfg_attr(feature = "schema", derive(JsonSchema))]
714#[serde(rename_all = "camelCase")]
715pub struct LabelElement {
716    /// Label text.
717    pub text: String,
718    /// SF Symbol or platform icon name.
719    #[serde(rename = "systemName")]
720    pub system_name: String,
721    /// Icon tint color.
722    #[serde(rename = "iconColor", default, skip_serializing_if = "Option::is_none")]
723    pub icon_color: Option<ColorValue>,
724    /// Text font size.
725    #[serde(rename = "fontSize", default, skip_serializing_if = "Option::is_none")]
726    pub font_size: Option<f64>,
727    /// Text font weight.
728    #[serde(
729        rename = "fontWeight",
730        default,
731        skip_serializing_if = "Option::is_none"
732    )]
733    pub font_weight: Option<FontWeight>,
734    /// Text color.
735    #[serde(default, skip_serializing_if = "Option::is_none")]
736    pub color: Option<ColorValue>,
737    /// Space between icon and text.
738    #[serde(default, skip_serializing_if = "Option::is_none")]
739    pub spacing: Option<f64>,
740    #[serde(flatten)]
741    /// Shared visual style (padding, background, frame, …).
742    pub style: ElementStyle,
743}
744
745impl From<VStackElement> for WidgetElement {
746    fn from(value: VStackElement) -> Self {
747        WidgetElement::VStack(value)
748    }
749}
750
751impl From<HStackElement> for WidgetElement {
752    fn from(value: HStackElement) -> Self {
753        WidgetElement::HStack(value)
754    }
755}
756
757impl From<ZStackElement> for WidgetElement {
758    fn from(value: ZStackElement) -> Self {
759        WidgetElement::ZStack(value)
760    }
761}
762
763impl From<GridElement> for WidgetElement {
764    fn from(value: GridElement) -> Self {
765        WidgetElement::Grid(value)
766    }
767}
768
769impl From<ContainerElement> for WidgetElement {
770    fn from(value: ContainerElement) -> Self {
771        WidgetElement::Container(value)
772    }
773}
774
775impl From<TextElement> for WidgetElement {
776    fn from(value: TextElement) -> Self {
777        WidgetElement::Text(value)
778    }
779}
780
781impl From<ImageElement> for WidgetElement {
782    fn from(value: ImageElement) -> Self {
783        WidgetElement::Image(value)
784    }
785}
786
787impl From<ProgressElement> for WidgetElement {
788    fn from(value: ProgressElement) -> Self {
789        WidgetElement::Progress(value)
790    }
791}
792
793impl From<GaugeElement> for WidgetElement {
794    fn from(value: GaugeElement) -> Self {
795        WidgetElement::Gauge(value)
796    }
797}
798
799impl From<ButtonElement> for WidgetElement {
800    fn from(value: ButtonElement) -> Self {
801        WidgetElement::Button(value)
802    }
803}
804
805impl From<ToggleElement> for WidgetElement {
806    fn from(value: ToggleElement) -> Self {
807        WidgetElement::Toggle(value)
808    }
809}
810
811impl From<DividerElement> for WidgetElement {
812    fn from(value: DividerElement) -> Self {
813        WidgetElement::Divider(value)
814    }
815}
816
817impl From<SpacerElement> for WidgetElement {
818    fn from(value: SpacerElement) -> Self {
819        WidgetElement::Spacer(value)
820    }
821}
822
823impl From<DateElement> for WidgetElement {
824    fn from(value: DateElement) -> Self {
825        WidgetElement::Date(value)
826    }
827}
828
829impl From<ChartElement> for WidgetElement {
830    fn from(value: ChartElement) -> Self {
831        WidgetElement::Chart(value)
832    }
833}
834
835impl From<ListElement> for WidgetElement {
836    fn from(value: ListElement) -> Self {
837        WidgetElement::List(value)
838    }
839}
840
841impl From<LinkElement> for WidgetElement {
842    fn from(value: LinkElement) -> Self {
843        WidgetElement::Link(value)
844    }
845}
846
847impl From<ShapeElement> for WidgetElement {
848    fn from(value: ShapeElement) -> Self {
849        WidgetElement::Shape(value)
850    }
851}
852
853impl From<TimerElement> for WidgetElement {
854    fn from(value: TimerElement) -> Self {
855        WidgetElement::Timer(value)
856    }
857}
858
859impl From<CanvasElement> for WidgetElement {
860    fn from(value: CanvasElement) -> Self {
861        WidgetElement::Canvas(value)
862    }
863}
864
865impl From<LabelElement> for WidgetElement {
866    fn from(value: LabelElement) -> Self {
867        WidgetElement::Label(value)
868    }
869}
870
871/// Build a [`TextElement`] with the given content.
872pub fn text(content: impl Into<String>) -> TextElement {
873    TextElement {
874        content: content.into(),
875        ..Default::default()
876    }
877}
878
879/// Build a [`VStackElement`] with the given children.
880pub fn vstack(children: Vec<WidgetElement>) -> VStackElement {
881    VStackElement {
882        children,
883        ..Default::default()
884    }
885}
886
887/// Build an [`HStackElement`] with the given children.
888pub fn hstack(children: Vec<WidgetElement>) -> HStackElement {
889    HStackElement {
890        children,
891        ..Default::default()
892    }
893}
894
895fn default_columns() -> u32 {
896    2
897}
898fn default_total() -> f64 {
899    1.0
900}
901
902// ─── Shared style applied to any element ─────────────────────────────────────
903
904/// Shared visual style applied to any element (padding, background, frame, …).
905#[derive(Debug, Clone, Serialize, Deserialize, Default)]
906#[cfg_attr(feature = "schema", derive(JsonSchema))]
907#[serde(rename_all = "camelCase")]
908pub struct ElementStyle {
909    /// Inset padding (number or per-edge object).
910    #[serde(default, skip_serializing_if = "Option::is_none")]
911    pub padding: Option<PaddingValue>,
912    /// Solid, adaptive, or gradient background.
913    #[serde(default, skip_serializing_if = "Option::is_none")]
914    pub background: Option<BackgroundValue>,
915    /// Corner radius in points.
916    #[serde(
917        rename = "cornerRadius",
918        default,
919        skip_serializing_if = "Option::is_none"
920    )]
921    pub corner_radius: Option<f64>,
922    /// Opacity from `0` (invisible) to `1` (opaque).
923    #[serde(default, skip_serializing_if = "Option::is_none")]
924    pub opacity: Option<f64>,
925    /// Explicit width / height / max constraints.
926    #[serde(default, skip_serializing_if = "Option::is_none")]
927    pub frame: Option<FrameConfig>,
928    /// Border color and width.
929    #[serde(default, skip_serializing_if = "Option::is_none")]
930    pub border: Option<BorderConfig>,
931    /// Drop shadow.
932    #[serde(default, skip_serializing_if = "Option::is_none")]
933    pub shadow: Option<ShadowConfig>,
934    /// Clip content to a shape (e.g. circle avatar from square image).
935    #[serde(rename = "clipShape", default, skip_serializing_if = "Option::is_none")]
936    pub clip_shape: Option<ClipShape>,
937    /// Layout weight for flexible sizing inside stacks (like Android `layout_weight`).
938    #[serde(default, skip_serializing_if = "Option::is_none")]
939    pub flex: Option<f64>,
940}
941
942/// Color value — hex string, semantic name, or adaptive `{ light, dark }` pair.
943///
944/// Semantic names: `"label"`, `"secondaryLabel"`, `"systemBackground"`,
945/// `"secondarySystemBackground"`, `"accent"`, `"separator"`.
946#[derive(Debug, Clone, Serialize, Deserialize)]
947#[cfg_attr(feature = "schema", derive(JsonSchema))]
948#[serde(untagged)]
949pub enum ColorValue {
950    Solid(String),
951    Adaptive { light: String, dark: String },
952}
953
954impl From<&str> for ColorValue {
955    fn from(value: &str) -> Self {
956        ColorValue::Solid(value.to_string())
957    }
958}
959
960impl From<String> for ColorValue {
961    fn from(value: String) -> Self {
962        ColorValue::Solid(value)
963    }
964}
965
966/// Clip shape for content masking.
967#[derive(Debug, Clone, Serialize, Deserialize)]
968#[cfg_attr(feature = "schema", derive(JsonSchema))]
969#[serde(rename_all = "camelCase")]
970pub enum ClipShape {
971    /// `circle`.
972    Circle,
973    /// `capsule`.
974    Capsule,
975    /// `rectangle`.
976    Rectangle,
977}
978
979/// Semantic text style — respects Dynamic Type / accessibility settings.
980#[derive(Debug, Clone, Serialize, Deserialize)]
981#[cfg_attr(feature = "schema", derive(JsonSchema))]
982#[serde(rename_all = "camelCase")]
983pub enum TextStyle {
984    /// `large title`.
985    LargeTitle,
986    /// `title`.
987    Title,
988    /// `title2`.
989    Title2,
990    /// `title3`.
991    Title3,
992    /// `headline`.
993    Headline,
994    /// `subheadline`.
995    Subheadline,
996    /// `body`.
997    Body,
998    /// `callout`.
999    Callout,
1000    /// `footnote`.
1001    Footnote,
1002    /// `caption`.
1003    Caption,
1004    /// `caption2`.
1005    Caption2,
1006}
1007
1008/// Background: solid color string, adaptive pair, gradient, or material blur.
1009#[derive(Debug, Clone, Serialize, Deserialize)]
1010#[cfg_attr(feature = "schema", derive(JsonSchema))]
1011#[serde(untagged)]
1012pub enum BackgroundValue {
1013    Solid(String),
1014    Gradient(GradientConfig),
1015    Adaptive { light: String, dark: String },
1016}
1017
1018impl From<&str> for BackgroundValue {
1019    fn from(value: &str) -> Self {
1020        BackgroundValue::Solid(value.to_string())
1021    }
1022}
1023
1024impl From<String> for BackgroundValue {
1025    fn from(value: String) -> Self {
1026        BackgroundValue::Solid(value)
1027    }
1028}
1029
1030#[derive(Debug, Clone, Serialize, Deserialize)]
1031#[cfg_attr(feature = "schema", derive(JsonSchema))]
1032#[serde(rename_all = "camelCase")]
1033pub struct GradientConfig {
1034    /// `"linear"`, `"radial"`, or `"angular"`
1035    #[serde(rename = "gradientType")]
1036    pub gradient_type: GradientType,
1037    pub colors: Vec<String>,
1038    /// Direction for linear gradients
1039    #[serde(default, skip_serializing_if = "Option::is_none")]
1040    pub direction: Option<GradientDirection>,
1041}
1042
1043#[derive(Debug, Clone, Serialize, Deserialize)]
1044#[cfg_attr(feature = "schema", derive(JsonSchema))]
1045#[serde(rename_all = "camelCase")]
1046pub enum GradientType {
1047    /// `linear`.
1048    Linear,
1049    /// `radial`.
1050    Radial,
1051    /// `angular`.
1052    Angular,
1053}
1054
1055#[derive(Debug, Clone, Serialize, Deserialize)]
1056#[cfg_attr(feature = "schema", derive(JsonSchema))]
1057#[serde(rename_all = "camelCase")]
1058pub enum GradientDirection {
1059    /// `top to bottom`.
1060    TopToBottom,
1061    /// `bottom to top`.
1062    BottomToTop,
1063    /// `leading to trailing`.
1064    LeadingToTrailing,
1065    /// `trailing to leading`.
1066    TrailingToLeading,
1067    /// `top leading to bottom trailing`.
1068    TopLeadingToBottomTrailing,
1069    /// `top trailing to bottom leading`.
1070    TopTrailingToBottomLeading,
1071}
1072
1073#[derive(Debug, Clone, Serialize, Deserialize)]
1074#[cfg_attr(feature = "schema", derive(JsonSchema))]
1075#[serde(rename_all = "camelCase")]
1076pub struct ShadowConfig {
1077    #[serde(default, skip_serializing_if = "Option::is_none")]
1078    pub color: Option<String>,
1079    #[serde(default, skip_serializing_if = "Option::is_none")]
1080    pub radius: Option<f64>,
1081    #[serde(default, skip_serializing_if = "Option::is_none")]
1082    pub x: Option<f64>,
1083    #[serde(default, skip_serializing_if = "Option::is_none")]
1084    pub y: Option<f64>,
1085}
1086
1087#[derive(Debug, Clone, Serialize, Deserialize)]
1088#[cfg_attr(feature = "schema", derive(JsonSchema))]
1089#[serde(untagged)]
1090pub enum PaddingValue {
1091    Uniform(f64),
1092    Edges {
1093        #[serde(default, skip_serializing_if = "Option::is_none")]
1094        top: Option<f64>,
1095        #[serde(default, skip_serializing_if = "Option::is_none")]
1096        bottom: Option<f64>,
1097        #[serde(default, skip_serializing_if = "Option::is_none")]
1098        leading: Option<f64>,
1099        #[serde(default, skip_serializing_if = "Option::is_none")]
1100        trailing: Option<f64>,
1101    },
1102}
1103
1104impl From<f64> for PaddingValue {
1105    fn from(value: f64) -> Self {
1106        PaddingValue::Uniform(value)
1107    }
1108}
1109
1110impl From<f32> for PaddingValue {
1111    fn from(value: f32) -> Self {
1112        PaddingValue::Uniform(f64::from(value))
1113    }
1114}
1115
1116impl From<i32> for PaddingValue {
1117    fn from(value: i32) -> Self {
1118        PaddingValue::Uniform(f64::from(value))
1119    }
1120}
1121
1122#[derive(Debug, Clone, Serialize, Deserialize)]
1123#[cfg_attr(feature = "schema", derive(JsonSchema))]
1124#[serde(rename_all = "camelCase")]
1125pub struct FrameConfig {
1126    #[serde(default, skip_serializing_if = "Option::is_none")]
1127    pub width: Option<f64>,
1128    #[serde(default, skip_serializing_if = "Option::is_none")]
1129    pub height: Option<f64>,
1130    #[serde(rename = "maxWidth", default, skip_serializing_if = "Option::is_none")]
1131    pub max_width: Option<FrameDimension>,
1132    #[serde(rename = "maxHeight", default, skip_serializing_if = "Option::is_none")]
1133    pub max_height: Option<FrameDimension>,
1134}
1135
1136#[derive(Debug, Clone, Serialize, Deserialize)]
1137#[cfg_attr(feature = "schema", derive(JsonSchema))]
1138#[serde(untagged)]
1139pub enum FrameDimension {
1140    Fixed(f64),
1141    Keyword(String),
1142}
1143
1144#[derive(Debug, Clone, Serialize, Deserialize)]
1145#[cfg_attr(feature = "schema", derive(JsonSchema))]
1146#[serde(rename_all = "camelCase")]
1147pub struct BorderConfig {
1148    pub color: String,
1149    #[serde(default = "default_border_width")]
1150    pub width: f64,
1151}
1152
1153fn default_border_width() -> f64 {
1154    1.0
1155}
1156
1157// ─── Enums ───────────────────────────────────────────────────────────────────
1158
1159#[derive(Debug, Clone, Serialize, Deserialize)]
1160#[cfg_attr(feature = "schema", derive(JsonSchema))]
1161#[serde(rename_all = "camelCase")]
1162pub enum FontWeight {
1163    /// `ultralight`.
1164    Ultralight,
1165    /// `thin`.
1166    Thin,
1167    /// `light`.
1168    Light,
1169    /// `regular`.
1170    Regular,
1171    /// `medium`.
1172    Medium,
1173    /// `semibold`.
1174    Semibold,
1175    /// `bold`.
1176    Bold,
1177    /// `heavy`.
1178    Heavy,
1179    /// `black`.
1180    Black,
1181}
1182
1183#[derive(Debug, Clone, Serialize, Deserialize)]
1184#[cfg_attr(feature = "schema", derive(JsonSchema))]
1185#[serde(rename_all = "camelCase")]
1186pub enum FontDesign {
1187    /// `default`.
1188    Default,
1189    /// `monospaced`.
1190    Monospaced,
1191    /// `rounded`.
1192    Rounded,
1193    /// `serif`.
1194    Serif,
1195}
1196
1197#[derive(Debug, Clone, Serialize, Deserialize)]
1198#[cfg_attr(feature = "schema", derive(JsonSchema))]
1199#[serde(rename_all = "camelCase")]
1200pub enum TextAlignment {
1201    /// `leading`.
1202    Leading,
1203    /// `center`.
1204    Center,
1205    /// `trailing`.
1206    Trailing,
1207}
1208
1209#[derive(Debug, Clone, Serialize, Deserialize)]
1210#[cfg_attr(feature = "schema", derive(JsonSchema))]
1211#[serde(rename_all = "camelCase")]
1212pub enum HorizontalAlignment {
1213    /// `leading`.
1214    Leading,
1215    /// `center`.
1216    Center,
1217    /// `trailing`.
1218    Trailing,
1219}
1220
1221#[derive(Debug, Clone, Serialize, Deserialize)]
1222#[cfg_attr(feature = "schema", derive(JsonSchema))]
1223#[serde(rename_all = "camelCase")]
1224pub enum VerticalAlignment {
1225    /// `top`.
1226    Top,
1227    /// `center`.
1228    Center,
1229    /// `bottom`.
1230    Bottom,
1231}
1232
1233#[derive(Debug, Clone, Serialize, Deserialize)]
1234#[cfg_attr(feature = "schema", derive(JsonSchema))]
1235#[serde(rename_all = "camelCase")]
1236pub enum ContentMode {
1237    /// `fit`.
1238    Fit,
1239    /// `fill`.
1240    Fill,
1241}
1242
1243#[derive(Debug, Clone, Serialize, Deserialize)]
1244#[cfg_attr(feature = "schema", derive(JsonSchema))]
1245#[serde(rename_all = "camelCase")]
1246pub enum ProgressStyle {
1247    /// `linear`.
1248    Linear,
1249    /// `circular`.
1250    Circular,
1251}
1252
1253#[derive(Debug, Clone, Serialize, Deserialize)]
1254#[cfg_attr(feature = "schema", derive(JsonSchema))]
1255#[serde(rename_all = "camelCase")]
1256pub enum GaugeStyle {
1257    /// `circular`.
1258    Circular,
1259    /// `linear`.
1260    Linear,
1261}
1262
1263#[derive(Debug, Clone, Serialize, Deserialize)]
1264#[cfg_attr(feature = "schema", derive(JsonSchema))]
1265#[serde(rename_all = "camelCase")]
1266pub enum DateStyle {
1267    /// `time`.
1268    Time,
1269    /// `date`.
1270    Date,
1271    /// `relative`.
1272    Relative,
1273    /// `offset`.
1274    Offset,
1275    /// `timer`.
1276    Timer,
1277}
1278
1279#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1280#[cfg_attr(feature = "schema", derive(JsonSchema))]
1281#[serde(rename_all = "camelCase")]
1282pub enum ChartType {
1283    #[default]
1284    Bar,
1285    /// `line`.
1286    Line,
1287    /// `area`.
1288    Area,
1289    /// `pie`.
1290    Pie,
1291}
1292
1293#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1294#[cfg_attr(feature = "schema", derive(JsonSchema))]
1295#[serde(rename_all = "camelCase")]
1296pub enum ShapeType {
1297    #[default]
1298    Circle,
1299    /// `capsule`.
1300    Capsule,
1301    /// `rectangle`.
1302    Rectangle,
1303}
1304
1305#[derive(Debug, Clone, Serialize, Deserialize)]
1306#[cfg_attr(feature = "schema", derive(JsonSchema))]
1307#[serde(rename_all = "camelCase")]
1308pub enum TimerCounting {
1309    /// `up`.
1310    Up,
1311    /// `down`.
1312    Down,
1313}
1314
1315#[derive(Debug, Clone, Serialize, Deserialize)]
1316#[cfg_attr(feature = "schema", derive(JsonSchema))]
1317#[serde(rename_all = "camelCase")]
1318pub struct ChartDataPoint {
1319    pub label: String,
1320    pub value: f64,
1321    #[serde(default, skip_serializing_if = "Option::is_none")]
1322    pub color: Option<ColorValue>,
1323}
1324
1325#[derive(Debug, Clone, Serialize, Deserialize)]
1326#[cfg_attr(feature = "schema", derive(JsonSchema))]
1327#[serde(rename_all = "camelCase")]
1328pub struct ListItem {
1329    pub text: String,
1330    #[serde(default, skip_serializing_if = "Option::is_none")]
1331    pub checked: Option<bool>,
1332    #[serde(default, skip_serializing_if = "Option::is_none")]
1333    pub action: Option<String>,
1334    #[serde(default, skip_serializing_if = "Option::is_none")]
1335    pub payload: Option<String>,
1336}
1337
1338// ─── Canvas drawing commands ────────────────────────────────────────────────
1339
1340#[derive(Debug, Clone, Serialize, Deserialize)]
1341#[cfg_attr(feature = "schema", derive(JsonSchema))]
1342#[serde(tag = "draw", rename_all = "camelCase")]
1343pub enum CanvasDrawCommand {
1344    #[serde(rename = "circle")]
1345    Circle {
1346        cx: f64,
1347        cy: f64,
1348        r: f64,
1349        #[serde(default, skip_serializing_if = "Option::is_none")]
1350        fill: Option<ColorValue>,
1351        #[serde(default, skip_serializing_if = "Option::is_none")]
1352        stroke: Option<ColorValue>,
1353        #[serde(
1354            rename = "strokeWidth",
1355            default,
1356            skip_serializing_if = "Option::is_none"
1357        )]
1358        stroke_width: Option<f64>,
1359    },
1360    #[serde(rename = "line")]
1361    Line {
1362        x1: f64,
1363        y1: f64,
1364        x2: f64,
1365        y2: f64,
1366        #[serde(default, skip_serializing_if = "Option::is_none")]
1367        stroke: Option<ColorValue>,
1368        #[serde(
1369            rename = "strokeWidth",
1370            default,
1371            skip_serializing_if = "Option::is_none"
1372        )]
1373        stroke_width: Option<f64>,
1374        #[serde(rename = "lineCap", default, skip_serializing_if = "Option::is_none")]
1375        line_cap: Option<String>,
1376    },
1377    #[serde(rename = "rect")]
1378    Rect {
1379        x: f64,
1380        y: f64,
1381        width: f64,
1382        height: f64,
1383        #[serde(default, skip_serializing_if = "Option::is_none")]
1384        fill: Option<ColorValue>,
1385        #[serde(default, skip_serializing_if = "Option::is_none")]
1386        stroke: Option<ColorValue>,
1387        #[serde(
1388            rename = "strokeWidth",
1389            default,
1390            skip_serializing_if = "Option::is_none"
1391        )]
1392        stroke_width: Option<f64>,
1393        #[serde(
1394            rename = "cornerRadius",
1395            default,
1396            skip_serializing_if = "Option::is_none"
1397        )]
1398        corner_radius: Option<f64>,
1399    },
1400    #[serde(rename = "arc")]
1401    Arc {
1402        cx: f64,
1403        cy: f64,
1404        r: f64,
1405        #[serde(rename = "startAngle")]
1406        start_angle: f64,
1407        #[serde(rename = "endAngle")]
1408        end_angle: f64,
1409        #[serde(default, skip_serializing_if = "Option::is_none")]
1410        fill: Option<ColorValue>,
1411        #[serde(default, skip_serializing_if = "Option::is_none")]
1412        stroke: Option<ColorValue>,
1413        #[serde(
1414            rename = "strokeWidth",
1415            default,
1416            skip_serializing_if = "Option::is_none"
1417        )]
1418        stroke_width: Option<f64>,
1419    },
1420    #[serde(rename = "text")]
1421    Text {
1422        x: f64,
1423        y: f64,
1424        content: String,
1425        #[serde(rename = "fontSize", default, skip_serializing_if = "Option::is_none")]
1426        font_size: Option<f64>,
1427        #[serde(default, skip_serializing_if = "Option::is_none")]
1428        color: Option<ColorValue>,
1429        #[serde(default, skip_serializing_if = "Option::is_none")]
1430        anchor: Option<String>,
1431    },
1432    #[serde(rename = "path")]
1433    Path {
1434        /// SVG path data (e.g. `"M10 10 L90 90"`)
1435        d: String,
1436        #[serde(default, skip_serializing_if = "Option::is_none")]
1437        fill: Option<ColorValue>,
1438        #[serde(default, skip_serializing_if = "Option::is_none")]
1439        stroke: Option<ColorValue>,
1440        #[serde(
1441            rename = "strokeWidth",
1442            default,
1443            skip_serializing_if = "Option::is_none"
1444        )]
1445        stroke_width: Option<f64>,
1446    },
1447}
1448
1449#[cfg(test)]
1450mod tests {
1451    use super::*;
1452    use std::path::PathBuf;
1453
1454    #[test]
1455    fn option_none_omitted_from_json() {
1456        let cfg = WidgetConfig::small(TextElement {
1457            content: "hi".into(),
1458            font_size: Some(12.0),
1459            ..Default::default()
1460        });
1461        let json = serde_json::to_string(&cfg).unwrap();
1462        assert!(!json.contains("null"), "JSON must not contain null: {json}");
1463        assert!(!json.contains("\"medium\""));
1464        assert!(!json.contains("\"fontWeight\""));
1465        let back: WidgetConfig = serde_json::from_str(&json).unwrap();
1466        assert!(back.medium.is_none());
1467        match back.small.unwrap() {
1468            WidgetElement::Text(TextElement {
1469                font_weight,
1470                content,
1471                ..
1472            }) => {
1473                assert!(font_weight.is_none());
1474                assert_eq!(content, "hi");
1475            }
1476            other => panic!("expected text, got {other:?}"),
1477        }
1478    }
1479
1480    #[test]
1481    fn ergonomic_builders_serialize() {
1482        let cfg = WidgetConfig::small(vstack(vec![text("72°")
1483            .font_size(36.0)
1484            .font_weight(FontWeight::Bold)
1485            .color("#fff")
1486            .into()]));
1487        let v = serde_json::to_value(&cfg).unwrap();
1488        assert_eq!(v["small"]["type"], "vstack");
1489        assert_eq!(v["small"]["children"][0]["type"], "text");
1490        assert_eq!(v["small"]["children"][0]["content"], "72°");
1491        assert_eq!(v["small"]["children"][0]["fontSize"], 36.0);
1492        assert_eq!(v["small"]["children"][0]["fontWeight"], "bold");
1493        assert_eq!(v["small"]["children"][0]["color"], "#fff");
1494    }
1495
1496    fn walk_json_files(dir: &std::path::Path, out: &mut Vec<PathBuf>) {
1497        let Ok(entries) = std::fs::read_dir(dir) else {
1498            return;
1499        };
1500        for entry in entries.flatten() {
1501            let path = entry.path();
1502            if path.is_dir() {
1503                walk_json_files(&path, out);
1504            } else if path.extension().and_then(|e| e.to_str()) == Some("json") {
1505                out.push(path);
1506            }
1507        }
1508    }
1509
1510    /// Round-trip every fixture through `WidgetConfig` — catches wire drift after
1511    /// struct-variant refactors (tag + flattened fields must stay identical).
1512    #[test]
1513    fn wire_format_unchanged() {
1514        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
1515        let mut files = Vec::new();
1516        walk_json_files(&root, &mut files);
1517        assert!(
1518            !files.is_empty(),
1519            "expected fixtures under {}",
1520            root.display()
1521        );
1522        for path in files {
1523            let raw = std::fs::read_to_string(&path).unwrap();
1524            let cfg: WidgetConfig = serde_json::from_str(&raw).unwrap_or_else(|e| {
1525                panic!("deserialize {}: {e}", path.display());
1526            });
1527            let encoded = serde_json::to_value(&cfg).unwrap();
1528            let again: WidgetConfig = serde_json::from_value(encoded.clone()).unwrap();
1529            assert_eq!(
1530                encoded,
1531                serde_json::to_value(&again).unwrap(),
1532                "wire drift in {}",
1533                path.display()
1534            );
1535            let out = serde_json::to_string(&cfg).unwrap();
1536            assert!(
1537                !out.contains("null"),
1538                "{} serialized with null: {out}",
1539                path.display()
1540            );
1541        }
1542    }
1543}