Skip to main content

tauri_plugin_widgets/models/
mod.rs

1//! Widget IR types: [`WidgetConfig`], [`WidgetElement`], and shared style values.
2//!
3//! Element structs, [`WidgetConfig`]/[`WidgetElement`], builders, and tests
4//! live here; shared style values are in [`style`] and data payloads
5//! ([`ChartDataPoint`], [`ListItem`], [`CanvasDrawCommand`]) are in [`data`].
6
7mod data;
8mod style;
9pub use data::*;
10pub use style::*;
11
12use serde::{Deserialize, Serialize};
13
14#[cfg(feature = "schema")]
15use schemars::JsonSchema;
16
17/// Configuration for creating a desktop widget window.
18///
19/// When `url` is omitted the plugin serves its built-in renderer
20/// automatically (via a custom URI-scheme protocol).  In that case
21/// `group` tells the renderer which config to load, and `size`
22/// selects the layout family (`"small"`, `"medium"`, or `"large"`).
23#[derive(Debug, Clone, Serialize, Deserialize)]
24#[cfg_attr(feature = "schema", derive(JsonSchema))]
25#[serde(rename_all = "camelCase")]
26pub struct WidgetWindowConfig {
27    /// Tauri window label (must be unique).
28    pub label: String,
29    /// Frontend route or URL.  Leave empty / omit to use the built-in
30    /// widget renderer that ships with the plugin.
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub url: Option<String>,
33    /// Window width in logical pixels.
34    pub width: f64,
35    /// Window height in logical pixels.
36    pub height: f64,
37    /// Optional X position.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub x: Option<f64>,
40    /// Optional Y position.
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub y: Option<f64>,
43    /// Keep the widget above other windows.
44    #[serde(default)]
45    pub always_on_top: bool,
46    /// Hide from the taskbar / dock.
47    #[serde(default = "default_true")]
48    pub skip_taskbar: bool,
49    /// Widget group identifier — passed to the built-in renderer so it
50    /// knows which config to load via `get_widget_config`.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub group: Option<String>,
53    /// Widget identity within the group (required for built-in renderer).
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub widget_id: Option<String>,
56    /// Size family the renderer should display: `"small"`, `"medium"`,
57    /// or `"large"`.  Defaults to `"small"` when omitted.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub size: Option<String>,
60}
61
62fn default_true() -> bool {
63    true
64}
65
66// ─── Widget UI Configuration ─────────────────────────────────────────────────
67
68/// Top-level widget config with layouts per size family.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70#[cfg_attr(feature = "schema", derive(JsonSchema))]
71#[serde(rename_all = "camelCase")]
72pub struct WidgetConfig {
73    /// Schema version. Defaults to `1`.
74    #[serde(default = "default_version")]
75    pub version: u32,
76    /// Layout for the small size family.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub small: Option<WidgetElement>,
79    /// Layout for the medium size family.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub medium: Option<WidgetElement>,
82    /// Layout for the large size family.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub large: Option<WidgetElement>,
85}
86
87impl Default for WidgetConfig {
88    fn default() -> Self {
89        Self {
90            version: 1,
91            small: None,
92            medium: None,
93            large: None,
94        }
95    }
96}
97
98impl WidgetConfig {
99    /// Config with only the `small` size family set.
100    pub fn small(el: impl Into<WidgetElement>) -> Self {
101        Self {
102            small: Some(el.into()),
103            ..Default::default()
104        }
105    }
106
107    /// Set the `medium` size family.
108    pub fn with_medium(mut self, el: impl Into<WidgetElement>) -> Self {
109        self.medium = Some(el.into());
110        self
111    }
112
113    /// Set the `large` size family.
114    pub fn with_large(mut self, el: impl Into<WidgetElement>) -> Self {
115        self.large = Some(el.into());
116        self
117    }
118}
119
120fn default_version() -> u32 {
121    1
122}
123
124/// A UI element that can be a layout container or a leaf widget.
125#[derive(Debug, Clone, Serialize, Deserialize)]
126#[cfg_attr(feature = "schema", derive(JsonSchema))]
127#[serde(tag = "type", rename_all = "camelCase")]
128pub enum WidgetElement {
129    /// Vertical stack of children.
130    #[serde(rename = "vstack")]
131    VStack(VStackElement),
132    /// Horizontal stack of children.
133    #[serde(rename = "hstack")]
134    HStack(HStackElement),
135    /// Overlay stack — children layered on top of each other.
136    #[serde(rename = "zstack")]
137    ZStack(ZStackElement),
138    /// Fixed-column grid of children.
139    #[serde(rename = "grid")]
140    Grid(GridElement),
141    /// Single-child wrapper for cards, badges, and overlays.
142    #[serde(rename = "container")]
143    Container(ContainerElement),
144    /// Text label with optional semantic typography.
145    #[serde(rename = "text")]
146    Text(TextElement),
147    /// Image from SF Symbol / drawable name, base64 data, or URL.
148    #[serde(rename = "image")]
149    Image(ImageElement),
150    /// Linear or circular progress indicator.
151    #[serde(rename = "progress")]
152    Progress(ProgressElement),
153    /// Circular or capacity-style gauge.
154    #[serde(rename = "gauge")]
155    Gauge(GaugeElement),
156    /// Tappable button that opens a URL or emits `widget-action`.
157    #[serde(rename = "button")]
158    Button(ButtonElement),
159    /// On/off toggle control.
160    #[serde(rename = "toggle")]
161    Toggle(ToggleElement),
162    /// Horizontal or vertical rule.
163    #[serde(rename = "divider")]
164    Divider(DividerElement),
165    /// Flexible empty space.
166    #[serde(rename = "spacer")]
167    Spacer(SpacerElement),
168    /// Formatted date / relative time display.
169    #[serde(rename = "date")]
170    Date(DateElement),
171    /// Bar, line, area, or pie chart.
172    #[serde(rename = "chart")]
173    Chart(ChartElement),
174    /// Collection list of rows (text, optional checked marker and action).
175    #[serde(rename = "list")]
176    List(ListElement),
177    /// Tappable wrapper — makes nested content clickable.
178    #[serde(rename = "link")]
179    Link(LinkElement),
180    /// Colored shape — circle, capsule, or rectangle.
181    #[serde(rename = "shape")]
182    Shape(ShapeElement),
183    /// Live countdown/countup timer that updates without timeline refresh.
184    #[serde(rename = "timer")]
185    Timer(TimerElement),
186    /// Declarative canvas — draw arbitrary shapes via JSON commands.
187    #[serde(rename = "canvas")]
188    Canvas(CanvasElement),
189    /// Convenience element combining an SF Symbol / icon with text.
190    #[serde(rename = "label")]
191    Label(LabelElement),
192}
193
194/// Vertical stack of children.
195#[derive(Debug, Clone, Serialize, Deserialize, Default)]
196#[cfg_attr(feature = "schema", derive(JsonSchema))]
197#[serde(rename_all = "camelCase")]
198pub struct VStackElement {
199    /// Child elements, top to bottom.
200    #[serde(default, skip_serializing_if = "Vec::is_empty")]
201    pub children: Vec<WidgetElement>,
202    /// Space between children (points).
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub spacing: Option<f64>,
205    /// Horizontal alignment of children.
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub alignment: Option<HorizontalAlignment>,
208    #[serde(flatten)]
209    /// Shared visual style (padding, background, frame, …).
210    pub style: ElementStyle,
211}
212
213/// Horizontal stack of children.
214#[derive(Debug, Clone, Serialize, Deserialize, Default)]
215#[cfg_attr(feature = "schema", derive(JsonSchema))]
216#[serde(rename_all = "camelCase")]
217pub struct HStackElement {
218    /// Child elements, leading to trailing.
219    #[serde(default, skip_serializing_if = "Vec::is_empty")]
220    pub children: Vec<WidgetElement>,
221    /// Space between children (points).
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    pub spacing: Option<f64>,
224    /// Vertical alignment of children.
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub alignment: Option<VerticalAlignment>,
227    #[serde(flatten)]
228    /// Shared visual style (padding, background, frame, …).
229    pub style: ElementStyle,
230}
231
232/// Overlay stack — children layered on top of each other.
233#[derive(Debug, Clone, Serialize, Deserialize, Default)]
234#[cfg_attr(feature = "schema", derive(JsonSchema))]
235#[serde(rename_all = "camelCase")]
236pub struct ZStackElement {
237    /// Layered children (later draw on top).
238    #[serde(default, skip_serializing_if = "Vec::is_empty")]
239    pub children: Vec<WidgetElement>,
240    /// Alignment of layers within the stack (e.g. `center`, `topLeading`).
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub alignment: Option<String>,
243    #[serde(flatten)]
244    /// Shared visual style (padding, background, frame, …).
245    pub style: ElementStyle,
246}
247
248/// Fixed-column grid of children.
249#[derive(Debug, Clone, Serialize, Deserialize, Default)]
250#[cfg_attr(feature = "schema", derive(JsonSchema))]
251#[serde(rename_all = "camelCase")]
252pub struct GridElement {
253    /// Grid cells in row-major order.
254    #[serde(default, skip_serializing_if = "Vec::is_empty")]
255    pub children: Vec<WidgetElement>,
256    /// Number of columns. Default `2`.
257    #[serde(default = "default_columns")]
258    pub columns: u32,
259    /// Column spacing (points).
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub spacing: Option<f64>,
262    /// Row spacing (points).
263    #[serde(
264        rename = "rowSpacing",
265        default,
266        skip_serializing_if = "Option::is_none"
267    )]
268    pub row_spacing: Option<f64>,
269    #[serde(flatten)]
270    /// Shared visual style (padding, background, frame, …).
271    pub style: ElementStyle,
272}
273
274/// Single-child wrapper for cards, badges, and overlays.
275#[derive(Debug, Clone, Serialize, Deserialize, Default)]
276#[cfg_attr(feature = "schema", derive(JsonSchema))]
277#[serde(rename_all = "camelCase")]
278pub struct ContainerElement {
279    /// Nested content (typically one child).
280    #[serde(default, skip_serializing_if = "Vec::is_empty")]
281    pub children: Vec<WidgetElement>,
282    /// Content alignment inside the box (e.g. `center`, `topLeading`).
283    #[serde(
284        rename = "contentAlignment",
285        default,
286        skip_serializing_if = "Option::is_none"
287    )]
288    pub content_alignment: Option<String>,
289    #[serde(flatten)]
290    /// Shared visual style (padding, background, frame, …).
291    pub style: ElementStyle,
292}
293
294/// Text label with optional semantic typography.
295#[derive(Debug, Clone, Serialize, Deserialize, Default)]
296#[cfg_attr(feature = "schema", derive(JsonSchema))]
297#[serde(rename_all = "camelCase")]
298pub struct TextElement {
299    /// String to display.
300    pub content: String,
301    /// Font size in points (overridden by `textStyle` when set).
302    #[serde(rename = "fontSize", default, skip_serializing_if = "Option::is_none")]
303    pub font_size: Option<f64>,
304    /// Font weight.
305    #[serde(
306        rename = "fontWeight",
307        default,
308        skip_serializing_if = "Option::is_none"
309    )]
310    pub font_weight: Option<FontWeight>,
311    /// Font design (default, monospaced, rounded, serif).
312    #[serde(
313        rename = "fontDesign",
314        default,
315        skip_serializing_if = "Option::is_none"
316    )]
317    pub font_design: Option<FontDesign>,
318    /// Semantic text style (uses Dynamic Type on Apple, sp on Android).
319    /// Overrides `fontSize` when set.
320    #[serde(rename = "textStyle", default, skip_serializing_if = "Option::is_none")]
321    pub text_style: Option<TextStyle>,
322    /// Text color.
323    #[serde(default, skip_serializing_if = "Option::is_none")]
324    pub color: Option<ColorValue>,
325    /// Text alignment within the line.
326    #[serde(default, skip_serializing_if = "Option::is_none")]
327    pub alignment: Option<TextAlignment>,
328    /// Maximum number of lines before truncation.
329    #[serde(rename = "lineLimit", default, skip_serializing_if = "Option::is_none")]
330    pub line_limit: Option<u32>,
331    #[serde(flatten)]
332    /// Shared visual style (padding, background, frame, …).
333    pub style: ElementStyle,
334}
335
336impl TextElement {
337    /// Set explicit point size (ignored when `text_style` is set).
338    pub fn font_size(mut self, size: f64) -> Self {
339        self.font_size = Some(size);
340        self
341    }
342
343    /// Set font weight.
344    pub fn font_weight(mut self, weight: FontWeight) -> Self {
345        self.font_weight = Some(weight);
346        self
347    }
348
349    /// Set text color.
350    pub fn color(mut self, color: impl Into<ColorValue>) -> Self {
351        self.color = Some(color.into());
352        self
353    }
354}
355
356/// Image from SF Symbol / drawable name, base64 data, or URL.
357#[derive(Debug, Clone, Serialize, Deserialize, Default)]
358#[cfg_attr(feature = "schema", derive(JsonSchema))]
359#[serde(rename_all = "camelCase")]
360pub struct ImageElement {
361    /// SF Symbol name (Apple) or Material / drawable name (Android).
362    #[serde(
363        rename = "systemName",
364        default,
365        skip_serializing_if = "Option::is_none"
366    )]
367    pub system_name: Option<String>,
368    /// Base64-encoded image data (with or without `data:image/...;base64,` prefix).
369    #[serde(default, skip_serializing_if = "Option::is_none")]
370    pub data: Option<String>,
371    /// Remote image URL (platform support varies — see capability matrix).
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    pub url: Option<String>,
374    /// Display size in points.
375    #[serde(default, skip_serializing_if = "Option::is_none")]
376    pub size: Option<f64>,
377    /// Tint color for template / symbol images.
378    #[serde(default, skip_serializing_if = "Option::is_none")]
379    pub color: Option<ColorValue>,
380    /// How the image fills its frame (`fit` or `fill`).
381    #[serde(
382        rename = "contentMode",
383        default,
384        skip_serializing_if = "Option::is_none"
385    )]
386    pub content_mode: Option<ContentMode>,
387    #[serde(flatten)]
388    /// Shared visual style (padding, background, frame, …).
389    pub style: ElementStyle,
390}
391
392/// Linear or circular progress indicator.
393#[derive(Debug, Clone, Serialize, Deserialize)]
394#[cfg_attr(feature = "schema", derive(JsonSchema))]
395#[serde(rename_all = "camelCase")]
396pub struct ProgressElement {
397    /// Current value. Clamped to `0..=total` by renderers.
398    pub value: f64,
399    /// Denominator for the ratio. Default `1.0`.
400    #[serde(default = "default_total")]
401    pub total: f64,
402    /// Caption above the bar. Always set on Android so hosts never show null.
403    #[serde(default, skip_serializing_if = "Option::is_none")]
404    pub label: Option<String>,
405    /// Accent / fill color for the completed portion.
406    #[serde(default, skip_serializing_if = "Option::is_none")]
407    pub tint: Option<ColorValue>,
408    /// Track / label color.
409    #[serde(default, skip_serializing_if = "Option::is_none")]
410    pub color: Option<ColorValue>,
411    /// `linear` (default) or `circular`.
412    #[serde(
413        rename = "barStyle",
414        default,
415        skip_serializing_if = "Option::is_none"
416    )]
417    pub bar_style: Option<ProgressStyle>,
418    #[serde(flatten)]
419    /// Shared visual style (padding, background, frame, …).
420    pub style: ElementStyle,
421}
422
423impl Default for ProgressElement {
424    fn default() -> Self {
425        Self {
426            value: 0.0,
427            total: 1.0,
428            label: None,
429            tint: None,
430            color: None,
431            bar_style: None,
432            style: ElementStyle::default(),
433        }
434    }
435}
436
437/// Circular or capacity-style gauge.
438#[derive(Debug, Clone, Serialize, Deserialize, Default)]
439#[cfg_attr(feature = "schema", derive(JsonSchema))]
440#[serde(rename_all = "camelCase")]
441pub struct GaugeElement {
442    /// Current value within `[min, max]`.
443    pub value: f64,
444    /// Lower bound. Default `0`.
445    #[serde(default, skip_serializing_if = "Option::is_none")]
446    pub min: Option<f64>,
447    /// Upper bound. Default `1`.
448    #[serde(default, skip_serializing_if = "Option::is_none")]
449    pub max: Option<f64>,
450    /// Optional caption.
451    #[serde(default, skip_serializing_if = "Option::is_none")]
452    pub label: Option<String>,
453    /// Text shown for the current value (e.g. `"72%"`).
454    #[serde(
455        rename = "currentValueLabel",
456        default,
457        skip_serializing_if = "Option::is_none"
458    )]
459    pub current_value_label: Option<String>,
460    /// Accent color.
461    #[serde(default, skip_serializing_if = "Option::is_none")]
462    pub tint: Option<ColorValue>,
463    /// Secondary / track color.
464    #[serde(default, skip_serializing_if = "Option::is_none")]
465    pub color: Option<ColorValue>,
466    /// Visual style (e.g. `circular`).
467    #[serde(
468        rename = "gaugeStyle",
469        default,
470        skip_serializing_if = "Option::is_none"
471    )]
472    pub gauge_style: Option<GaugeStyle>,
473    #[serde(flatten)]
474    /// Shared visual style (padding, background, frame, …).
475    pub style: ElementStyle,
476}
477
478/// Tappable button that opens a URL or emits `widget-action`.
479#[derive(Debug, Clone, Serialize, Deserialize, Default)]
480#[cfg_attr(feature = "schema", derive(JsonSchema))]
481#[serde(rename_all = "camelCase")]
482pub struct ButtonElement {
483    /// Button label text.
484    pub label: String,
485    /// Deep link URL to open the app (used when no action is set).
486    #[serde(default, skip_serializing_if = "Option::is_none")]
487    pub url: Option<String>,
488    /// Action identifier — emits a `widget-action` Tauri event when tapped.
489    #[serde(default, skip_serializing_if = "Option::is_none")]
490    pub action: Option<String>,
491    /// Label text color.
492    #[serde(default, skip_serializing_if = "Option::is_none")]
493    pub color: Option<ColorValue>,
494    /// Button background color.
495    #[serde(
496        rename = "backgroundColor",
497        default,
498        skip_serializing_if = "Option::is_none"
499    )]
500    pub background_color: Option<ColorValue>,
501    /// Label font size in points.
502    #[serde(rename = "fontSize", default, skip_serializing_if = "Option::is_none")]
503    pub font_size: Option<f64>,
504    /// Label text alignment.
505    #[serde(
506        rename = "textAlignment",
507        default,
508        skip_serializing_if = "Option::is_none"
509    )]
510    pub text_alignment: Option<TextAlignment>,
511    #[serde(flatten)]
512    /// Shared visual style (padding, background, frame, …).
513    pub style: ElementStyle,
514}
515
516/// On/off toggle control.
517#[derive(Debug, Clone, Serialize, Deserialize, Default)]
518#[cfg_attr(feature = "schema", derive(JsonSchema))]
519#[serde(rename_all = "camelCase")]
520pub struct ToggleElement {
521    /// Whether the toggle is on.
522    #[serde(rename = "isOn")]
523    pub is_on: bool,
524    /// Optional label beside the control.
525    #[serde(default, skip_serializing_if = "Option::is_none")]
526    pub label: Option<String>,
527    /// Accent color when on.
528    #[serde(default, skip_serializing_if = "Option::is_none")]
529    pub tint: Option<ColorValue>,
530    /// Action identifier sent back to the app.
531    #[serde(default, skip_serializing_if = "Option::is_none")]
532    pub action: Option<String>,
533    #[serde(flatten)]
534    /// Shared visual style (padding, background, frame, …).
535    pub style: ElementStyle,
536}
537
538/// Horizontal or vertical rule.
539#[derive(Debug, Clone, Serialize, Deserialize, Default)]
540#[cfg_attr(feature = "schema", derive(JsonSchema))]
541#[serde(rename_all = "camelCase")]
542pub struct DividerElement {
543    /// Line color.
544    #[serde(default, skip_serializing_if = "Option::is_none")]
545    pub color: Option<ColorValue>,
546    /// Line thickness in points.
547    #[serde(default, skip_serializing_if = "Option::is_none")]
548    pub thickness: Option<f64>,
549    #[serde(flatten)]
550    /// Shared visual style (padding, background, frame, …).
551    pub style: ElementStyle,
552}
553
554/// Flexible empty space.
555#[derive(Debug, Clone, Serialize, Deserialize, Default)]
556#[cfg_attr(feature = "schema", derive(JsonSchema))]
557#[serde(rename_all = "camelCase")]
558pub struct SpacerElement {
559    /// Minimum length along the parent axis (points).
560    #[serde(rename = "minLength", default, skip_serializing_if = "Option::is_none")]
561    pub min_length: Option<f64>,
562}
563
564/// Formatted date / relative time display.
565#[derive(Debug, Clone, Serialize, Deserialize, Default)]
566#[cfg_attr(feature = "schema", derive(JsonSchema))]
567#[serde(rename_all = "camelCase")]
568pub struct DateElement {
569    /// ISO 8601 date string.
570    pub date: String,
571    /// Display style (`time`, `date`, `relative`, `offset`, `timer`).
572    #[serde(rename = "dateStyle", default, skip_serializing_if = "Option::is_none")]
573    pub date_style: Option<DateStyle>,
574    /// Font size in points.
575    #[serde(rename = "fontSize", default, skip_serializing_if = "Option::is_none")]
576    pub font_size: Option<f64>,
577    /// Text color.
578    #[serde(default, skip_serializing_if = "Option::is_none")]
579    pub color: Option<ColorValue>,
580    #[serde(flatten)]
581    /// Shared visual style (padding, background, frame, …).
582    pub style: ElementStyle,
583}
584
585/// Bar, line, area, or pie chart.
586#[derive(Debug, Clone, Serialize, Deserialize, Default)]
587#[cfg_attr(feature = "schema", derive(JsonSchema))]
588#[serde(rename_all = "camelCase")]
589pub struct ChartElement {
590    /// Chart kind: `bar`, `line`, `area`, or `pie`.
591    #[serde(rename = "chartType")]
592    pub chart_type: ChartType,
593    /// Data points (`label` + `value`, optional per-point `color`).
594    #[serde(default, rename = "chartData", skip_serializing_if = "Vec::is_empty")]
595    pub chart_data: Vec<ChartDataPoint>,
596    /// Default series tint.
597    #[serde(default, skip_serializing_if = "Option::is_none")]
598    pub tint: Option<ColorValue>,
599    #[serde(flatten)]
600    /// Shared visual style (padding, background, frame, …).
601    pub style: ElementStyle,
602}
603
604/// Collection list of rows (text, optional checked marker and action).
605#[derive(Debug, Clone, Serialize, Deserialize, Default)]
606#[cfg_attr(feature = "schema", derive(JsonSchema))]
607#[serde(rename_all = "camelCase")]
608pub struct ListElement {
609    /// Row items.
610    #[serde(default, skip_serializing_if = "Vec::is_empty")]
611    pub items: Vec<ListItem>,
612    /// Space between rows (points).
613    #[serde(default, skip_serializing_if = "Option::is_none")]
614    pub spacing: Option<f64>,
615    /// Row text font size.
616    #[serde(rename = "fontSize", default, skip_serializing_if = "Option::is_none")]
617    pub font_size: Option<f64>,
618    /// Row text color.
619    #[serde(default, skip_serializing_if = "Option::is_none")]
620    pub color: Option<ColorValue>,
621    #[serde(flatten)]
622    /// Shared visual style (padding, background, frame, …).
623    pub style: ElementStyle,
624}
625
626/// Tappable wrapper — makes nested content clickable.
627#[derive(Debug, Clone, Serialize, Deserialize, Default)]
628#[cfg_attr(feature = "schema", derive(JsonSchema))]
629#[serde(rename_all = "camelCase")]
630pub struct LinkElement {
631    /// Nested content to wrap.
632    #[serde(default, skip_serializing_if = "Vec::is_empty")]
633    pub children: Vec<WidgetElement>,
634    /// Deep-link URL to open.
635    #[serde(default, skip_serializing_if = "Option::is_none")]
636    pub url: Option<String>,
637    /// Action identifier — emits `widget-action` event.
638    #[serde(default, skip_serializing_if = "Option::is_none")]
639    pub action: Option<String>,
640    #[serde(flatten)]
641    /// Shared visual style (padding, background, frame, …).
642    pub style: ElementStyle,
643}
644
645/// Colored shape — circle, capsule, or rectangle.
646#[derive(Debug, Clone, Serialize, Deserialize, Default)]
647#[cfg_attr(feature = "schema", derive(JsonSchema))]
648#[serde(rename_all = "camelCase")]
649pub struct ShapeElement {
650    /// Shape kind.
651    #[serde(rename = "shapeType")]
652    pub shape_type: ShapeType,
653    /// Fill color.
654    #[serde(default, skip_serializing_if = "Option::is_none")]
655    pub fill: Option<ColorValue>,
656    /// Stroke color.
657    #[serde(default, skip_serializing_if = "Option::is_none")]
658    pub stroke: Option<ColorValue>,
659    /// Stroke width in points.
660    #[serde(
661        rename = "strokeWidth",
662        default,
663        skip_serializing_if = "Option::is_none"
664    )]
665    pub stroke_width: Option<f64>,
666    /// Bounding size in points.
667    #[serde(default, skip_serializing_if = "Option::is_none")]
668    pub size: Option<f64>,
669    #[serde(flatten)]
670    /// Shared visual style (padding, background, frame, …).
671    pub style: ElementStyle,
672}
673
674/// Live countdown/countup timer that updates without timeline refresh.
675#[derive(Debug, Clone, Serialize, Deserialize, Default)]
676#[cfg_attr(feature = "schema", derive(JsonSchema))]
677#[serde(rename_all = "camelCase")]
678pub struct TimerElement {
679    /// ISO 8601 target date.
680    #[serde(rename = "targetDate")]
681    pub target_date: String,
682    /// Count direction. Default: `down`.
683    #[serde(default, skip_serializing_if = "Option::is_none")]
684    pub counting: Option<TimerCounting>,
685    /// Font size in points.
686    #[serde(rename = "fontSize", default, skip_serializing_if = "Option::is_none")]
687    pub font_size: Option<f64>,
688    /// Font weight.
689    #[serde(
690        rename = "fontWeight",
691        default,
692        skip_serializing_if = "Option::is_none"
693    )]
694    pub font_weight: Option<FontWeight>,
695    /// Text color.
696    #[serde(default, skip_serializing_if = "Option::is_none")]
697    pub color: Option<ColorValue>,
698    #[serde(flatten)]
699    /// Shared visual style (padding, background, frame, …).
700    pub style: ElementStyle,
701}
702
703/// Declarative canvas — draw arbitrary shapes via JSON commands.
704#[derive(Debug, Clone, Serialize, Deserialize, Default)]
705#[cfg_attr(feature = "schema", derive(JsonSchema))]
706#[serde(rename_all = "camelCase")]
707pub struct CanvasElement {
708    /// Canvas width in points.
709    pub width: f64,
710    /// Canvas height in points.
711    pub height: f64,
712    /// Draw commands (`circle`, `line`, `rect`, `arc`, `text`, `path`).
713    #[serde(default, skip_serializing_if = "Vec::is_empty")]
714    pub elements: Vec<CanvasDrawCommand>,
715    #[serde(flatten)]
716    /// Shared visual style (padding, background, frame, …).
717    pub style: ElementStyle,
718}
719
720/// Convenience element combining an SF Symbol / icon with text.
721#[derive(Debug, Clone, Serialize, Deserialize, Default)]
722#[cfg_attr(feature = "schema", derive(JsonSchema))]
723#[serde(rename_all = "camelCase")]
724pub struct LabelElement {
725    /// Label text.
726    pub text: String,
727    /// SF Symbol or platform icon name.
728    #[serde(rename = "systemName")]
729    pub system_name: String,
730    /// Icon tint color.
731    #[serde(rename = "iconColor", default, skip_serializing_if = "Option::is_none")]
732    pub icon_color: Option<ColorValue>,
733    /// Text font size.
734    #[serde(rename = "fontSize", default, skip_serializing_if = "Option::is_none")]
735    pub font_size: Option<f64>,
736    /// Text font weight.
737    #[serde(
738        rename = "fontWeight",
739        default,
740        skip_serializing_if = "Option::is_none"
741    )]
742    pub font_weight: Option<FontWeight>,
743    /// Text color.
744    #[serde(default, skip_serializing_if = "Option::is_none")]
745    pub color: Option<ColorValue>,
746    /// Space between icon and text.
747    #[serde(default, skip_serializing_if = "Option::is_none")]
748    pub spacing: Option<f64>,
749    #[serde(flatten)]
750    /// Shared visual style (padding, background, frame, …).
751    pub style: ElementStyle,
752}
753
754impl From<VStackElement> for WidgetElement {
755    fn from(value: VStackElement) -> Self {
756        WidgetElement::VStack(value)
757    }
758}
759
760impl From<HStackElement> for WidgetElement {
761    fn from(value: HStackElement) -> Self {
762        WidgetElement::HStack(value)
763    }
764}
765
766impl From<ZStackElement> for WidgetElement {
767    fn from(value: ZStackElement) -> Self {
768        WidgetElement::ZStack(value)
769    }
770}
771
772impl From<GridElement> for WidgetElement {
773    fn from(value: GridElement) -> Self {
774        WidgetElement::Grid(value)
775    }
776}
777
778impl From<ContainerElement> for WidgetElement {
779    fn from(value: ContainerElement) -> Self {
780        WidgetElement::Container(value)
781    }
782}
783
784impl From<TextElement> for WidgetElement {
785    fn from(value: TextElement) -> Self {
786        WidgetElement::Text(value)
787    }
788}
789
790impl From<ImageElement> for WidgetElement {
791    fn from(value: ImageElement) -> Self {
792        WidgetElement::Image(value)
793    }
794}
795
796impl From<ProgressElement> for WidgetElement {
797    fn from(value: ProgressElement) -> Self {
798        WidgetElement::Progress(value)
799    }
800}
801
802impl From<GaugeElement> for WidgetElement {
803    fn from(value: GaugeElement) -> Self {
804        WidgetElement::Gauge(value)
805    }
806}
807
808impl From<ButtonElement> for WidgetElement {
809    fn from(value: ButtonElement) -> Self {
810        WidgetElement::Button(value)
811    }
812}
813
814impl From<ToggleElement> for WidgetElement {
815    fn from(value: ToggleElement) -> Self {
816        WidgetElement::Toggle(value)
817    }
818}
819
820impl From<DividerElement> for WidgetElement {
821    fn from(value: DividerElement) -> Self {
822        WidgetElement::Divider(value)
823    }
824}
825
826impl From<SpacerElement> for WidgetElement {
827    fn from(value: SpacerElement) -> Self {
828        WidgetElement::Spacer(value)
829    }
830}
831
832impl From<DateElement> for WidgetElement {
833    fn from(value: DateElement) -> Self {
834        WidgetElement::Date(value)
835    }
836}
837
838impl From<ChartElement> for WidgetElement {
839    fn from(value: ChartElement) -> Self {
840        WidgetElement::Chart(value)
841    }
842}
843
844impl From<ListElement> for WidgetElement {
845    fn from(value: ListElement) -> Self {
846        WidgetElement::List(value)
847    }
848}
849
850impl From<LinkElement> for WidgetElement {
851    fn from(value: LinkElement) -> Self {
852        WidgetElement::Link(value)
853    }
854}
855
856impl From<ShapeElement> for WidgetElement {
857    fn from(value: ShapeElement) -> Self {
858        WidgetElement::Shape(value)
859    }
860}
861
862impl From<TimerElement> for WidgetElement {
863    fn from(value: TimerElement) -> Self {
864        WidgetElement::Timer(value)
865    }
866}
867
868impl From<CanvasElement> for WidgetElement {
869    fn from(value: CanvasElement) -> Self {
870        WidgetElement::Canvas(value)
871    }
872}
873
874impl From<LabelElement> for WidgetElement {
875    fn from(value: LabelElement) -> Self {
876        WidgetElement::Label(value)
877    }
878}
879
880/// Build a [`TextElement`] with the given content.
881pub fn text(content: impl Into<String>) -> TextElement {
882    TextElement {
883        content: content.into(),
884        ..Default::default()
885    }
886}
887
888/// Build a [`VStackElement`] with the given children.
889pub fn vstack(children: Vec<WidgetElement>) -> VStackElement {
890    VStackElement {
891        children,
892        ..Default::default()
893    }
894}
895
896/// Build an [`HStackElement`] with the given children.
897pub fn hstack(children: Vec<WidgetElement>) -> HStackElement {
898    HStackElement {
899        children,
900        ..Default::default()
901    }
902}
903
904fn default_columns() -> u32 {
905    2
906}
907fn default_total() -> f64 {
908    1.0
909}
910
911#[cfg(test)]
912mod tests {
913    use super::*;
914    use std::path::PathBuf;
915
916    #[test]
917    fn option_none_omitted_from_json() {
918        let cfg = WidgetConfig::small(TextElement {
919            content: "hi".into(),
920            font_size: Some(12.0),
921            ..Default::default()
922        });
923        let json = serde_json::to_string(&cfg).unwrap();
924        assert!(!json.contains("null"), "JSON must not contain null: {json}");
925        assert!(!json.contains("\"medium\""));
926        assert!(!json.contains("\"fontWeight\""));
927        let back: WidgetConfig = serde_json::from_str(&json).unwrap();
928        assert!(back.medium.is_none());
929        match back.small.unwrap() {
930            WidgetElement::Text(TextElement {
931                font_weight,
932                content,
933                ..
934            }) => {
935                assert!(font_weight.is_none());
936                assert_eq!(content, "hi");
937            }
938            other => panic!("expected text, got {other:?}"),
939        }
940    }
941
942    #[test]
943    fn ergonomic_builders_serialize() {
944        let cfg = WidgetConfig::small(vstack(vec![text("72°")
945            .font_size(36.0)
946            .font_weight(FontWeight::Bold)
947            .color("#fff")
948            .into()]));
949        let v = serde_json::to_value(&cfg).unwrap();
950        assert_eq!(v["small"]["type"], "vstack");
951        assert_eq!(v["small"]["children"][0]["type"], "text");
952        assert_eq!(v["small"]["children"][0]["content"], "72°");
953        assert_eq!(v["small"]["children"][0]["fontSize"], 36.0);
954        assert_eq!(v["small"]["children"][0]["fontWeight"], "bold");
955        assert_eq!(v["small"]["children"][0]["color"], "#fff");
956    }
957
958    fn walk_json_files(dir: &std::path::Path, out: &mut Vec<PathBuf>) {
959        let Ok(entries) = std::fs::read_dir(dir) else {
960            return;
961        };
962        for entry in entries.flatten() {
963            let path = entry.path();
964            if path.is_dir() {
965                walk_json_files(&path, out);
966            } else if path.extension().and_then(|e| e.to_str()) == Some("json") {
967                out.push(path);
968            }
969        }
970    }
971
972    /// Round-trip every fixture through `WidgetConfig` — catches wire drift after
973    /// struct-variant refactors (tag + flattened fields must stay identical).
974    #[test]
975    fn wire_format_unchanged() {
976        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
977        let mut files = Vec::new();
978        walk_json_files(&root, &mut files);
979        assert!(
980            !files.is_empty(),
981            "expected fixtures under {}",
982            root.display()
983        );
984        for path in files {
985            let raw = std::fs::read_to_string(&path).unwrap();
986            let cfg: WidgetConfig = serde_json::from_str(&raw).unwrap_or_else(|e| {
987                panic!("deserialize {}: {e}", path.display());
988            });
989            let encoded = serde_json::to_value(&cfg).unwrap();
990            let again: WidgetConfig = serde_json::from_value(encoded.clone()).unwrap();
991            assert_eq!(
992                encoded,
993                serde_json::to_value(&again).unwrap(),
994                "wire drift in {}",
995                path.display()
996            );
997            let out = serde_json::to_string(&cfg).unwrap();
998            assert!(
999                !out.contains("null"),
1000                "{} serialized with null: {out}",
1001                path.display()
1002            );
1003        }
1004    }
1005}