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    /// Rich rows as nested elements (alternative to typed [`ListItem`]s).
613    #[serde(default, skip_serializing_if = "Vec::is_empty")]
614    pub children: Vec<WidgetElement>,
615    /// Space between rows (points).
616    #[serde(default, skip_serializing_if = "Option::is_none")]
617    pub spacing: Option<f64>,
618    /// Row text font size.
619    #[serde(rename = "fontSize", default, skip_serializing_if = "Option::is_none")]
620    pub font_size: Option<f64>,
621    /// Row text color.
622    #[serde(default, skip_serializing_if = "Option::is_none")]
623    pub color: Option<ColorValue>,
624    #[serde(flatten)]
625    /// Shared visual style (padding, background, frame, …).
626    pub style: ElementStyle,
627}
628
629/// Tappable wrapper — makes nested content clickable.
630#[derive(Debug, Clone, Serialize, Deserialize, Default)]
631#[cfg_attr(feature = "schema", derive(JsonSchema))]
632#[serde(rename_all = "camelCase")]
633pub struct LinkElement {
634    /// Nested content to wrap.
635    #[serde(default, skip_serializing_if = "Vec::is_empty")]
636    pub children: Vec<WidgetElement>,
637    /// Deep-link URL to open.
638    #[serde(default, skip_serializing_if = "Option::is_none")]
639    pub url: Option<String>,
640    /// Action identifier — emits `widget-action` event.
641    #[serde(default, skip_serializing_if = "Option::is_none")]
642    pub action: Option<String>,
643    #[serde(flatten)]
644    /// Shared visual style (padding, background, frame, …).
645    pub style: ElementStyle,
646}
647
648/// Colored shape — circle, capsule, or rectangle.
649#[derive(Debug, Clone, Serialize, Deserialize, Default)]
650#[cfg_attr(feature = "schema", derive(JsonSchema))]
651#[serde(rename_all = "camelCase")]
652pub struct ShapeElement {
653    /// Shape kind.
654    #[serde(rename = "shapeType")]
655    pub shape_type: ShapeType,
656    /// Fill color.
657    #[serde(default, skip_serializing_if = "Option::is_none")]
658    pub fill: Option<ColorValue>,
659    /// Stroke color.
660    #[serde(default, skip_serializing_if = "Option::is_none")]
661    pub stroke: Option<ColorValue>,
662    /// Stroke width in points.
663    #[serde(
664        rename = "strokeWidth",
665        default,
666        skip_serializing_if = "Option::is_none"
667    )]
668    pub stroke_width: Option<f64>,
669    /// Bounding size in points.
670    #[serde(default, skip_serializing_if = "Option::is_none")]
671    pub size: Option<f64>,
672    #[serde(flatten)]
673    /// Shared visual style (padding, background, frame, …).
674    pub style: ElementStyle,
675}
676
677/// Live countdown/countup timer that updates without timeline refresh.
678#[derive(Debug, Clone, Serialize, Deserialize, Default)]
679#[cfg_attr(feature = "schema", derive(JsonSchema))]
680#[serde(rename_all = "camelCase")]
681pub struct TimerElement {
682    /// ISO 8601 target date.
683    #[serde(rename = "targetDate")]
684    pub target_date: String,
685    /// Count direction. Default: `down`.
686    #[serde(default, skip_serializing_if = "Option::is_none")]
687    pub counting: Option<TimerCounting>,
688    /// Font size in points.
689    #[serde(rename = "fontSize", default, skip_serializing_if = "Option::is_none")]
690    pub font_size: Option<f64>,
691    /// Font weight.
692    #[serde(
693        rename = "fontWeight",
694        default,
695        skip_serializing_if = "Option::is_none"
696    )]
697    pub font_weight: Option<FontWeight>,
698    /// Text color.
699    #[serde(default, skip_serializing_if = "Option::is_none")]
700    pub color: Option<ColorValue>,
701    #[serde(flatten)]
702    /// Shared visual style (padding, background, frame, …).
703    pub style: ElementStyle,
704}
705
706/// Declarative canvas — draw arbitrary shapes via JSON commands.
707#[derive(Debug, Clone, Serialize, Deserialize, Default)]
708#[cfg_attr(feature = "schema", derive(JsonSchema))]
709#[serde(rename_all = "camelCase")]
710pub struct CanvasElement {
711    /// Canvas width in points.
712    pub width: f64,
713    /// Canvas height in points.
714    pub height: f64,
715    /// Draw commands (`circle`, `line`, `rect`, `arc`, `text`, `path`).
716    #[serde(default, skip_serializing_if = "Vec::is_empty")]
717    pub elements: Vec<CanvasDrawCommand>,
718    #[serde(flatten)]
719    /// Shared visual style (padding, background, frame, …).
720    pub style: ElementStyle,
721}
722
723/// Convenience element combining an SF Symbol / icon with text.
724#[derive(Debug, Clone, Serialize, Deserialize, Default)]
725#[cfg_attr(feature = "schema", derive(JsonSchema))]
726#[serde(rename_all = "camelCase")]
727pub struct LabelElement {
728    /// Label text.
729    pub text: String,
730    /// SF Symbol or platform icon name.
731    #[serde(rename = "systemName")]
732    pub system_name: String,
733    /// Icon tint color.
734    #[serde(rename = "iconColor", default, skip_serializing_if = "Option::is_none")]
735    pub icon_color: Option<ColorValue>,
736    /// Text font size.
737    #[serde(rename = "fontSize", default, skip_serializing_if = "Option::is_none")]
738    pub font_size: Option<f64>,
739    /// Text font weight.
740    #[serde(
741        rename = "fontWeight",
742        default,
743        skip_serializing_if = "Option::is_none"
744    )]
745    pub font_weight: Option<FontWeight>,
746    /// Text color.
747    #[serde(default, skip_serializing_if = "Option::is_none")]
748    pub color: Option<ColorValue>,
749    /// Space between icon and text.
750    #[serde(default, skip_serializing_if = "Option::is_none")]
751    pub spacing: Option<f64>,
752    #[serde(flatten)]
753    /// Shared visual style (padding, background, frame, …).
754    pub style: ElementStyle,
755}
756
757impl From<VStackElement> for WidgetElement {
758    fn from(value: VStackElement) -> Self {
759        WidgetElement::VStack(value)
760    }
761}
762
763impl From<HStackElement> for WidgetElement {
764    fn from(value: HStackElement) -> Self {
765        WidgetElement::HStack(value)
766    }
767}
768
769impl From<ZStackElement> for WidgetElement {
770    fn from(value: ZStackElement) -> Self {
771        WidgetElement::ZStack(value)
772    }
773}
774
775impl From<GridElement> for WidgetElement {
776    fn from(value: GridElement) -> Self {
777        WidgetElement::Grid(value)
778    }
779}
780
781impl From<ContainerElement> for WidgetElement {
782    fn from(value: ContainerElement) -> Self {
783        WidgetElement::Container(value)
784    }
785}
786
787impl From<TextElement> for WidgetElement {
788    fn from(value: TextElement) -> Self {
789        WidgetElement::Text(value)
790    }
791}
792
793impl From<ImageElement> for WidgetElement {
794    fn from(value: ImageElement) -> Self {
795        WidgetElement::Image(value)
796    }
797}
798
799impl From<ProgressElement> for WidgetElement {
800    fn from(value: ProgressElement) -> Self {
801        WidgetElement::Progress(value)
802    }
803}
804
805impl From<GaugeElement> for WidgetElement {
806    fn from(value: GaugeElement) -> Self {
807        WidgetElement::Gauge(value)
808    }
809}
810
811impl From<ButtonElement> for WidgetElement {
812    fn from(value: ButtonElement) -> Self {
813        WidgetElement::Button(value)
814    }
815}
816
817impl From<ToggleElement> for WidgetElement {
818    fn from(value: ToggleElement) -> Self {
819        WidgetElement::Toggle(value)
820    }
821}
822
823impl From<DividerElement> for WidgetElement {
824    fn from(value: DividerElement) -> Self {
825        WidgetElement::Divider(value)
826    }
827}
828
829impl From<SpacerElement> for WidgetElement {
830    fn from(value: SpacerElement) -> Self {
831        WidgetElement::Spacer(value)
832    }
833}
834
835impl From<DateElement> for WidgetElement {
836    fn from(value: DateElement) -> Self {
837        WidgetElement::Date(value)
838    }
839}
840
841impl From<ChartElement> for WidgetElement {
842    fn from(value: ChartElement) -> Self {
843        WidgetElement::Chart(value)
844    }
845}
846
847impl From<ListElement> for WidgetElement {
848    fn from(value: ListElement) -> Self {
849        WidgetElement::List(value)
850    }
851}
852
853impl From<LinkElement> for WidgetElement {
854    fn from(value: LinkElement) -> Self {
855        WidgetElement::Link(value)
856    }
857}
858
859impl From<ShapeElement> for WidgetElement {
860    fn from(value: ShapeElement) -> Self {
861        WidgetElement::Shape(value)
862    }
863}
864
865impl From<TimerElement> for WidgetElement {
866    fn from(value: TimerElement) -> Self {
867        WidgetElement::Timer(value)
868    }
869}
870
871impl From<CanvasElement> for WidgetElement {
872    fn from(value: CanvasElement) -> Self {
873        WidgetElement::Canvas(value)
874    }
875}
876
877impl From<LabelElement> for WidgetElement {
878    fn from(value: LabelElement) -> Self {
879        WidgetElement::Label(value)
880    }
881}
882
883/// Build a [`TextElement`] with the given content.
884pub fn text(content: impl Into<String>) -> TextElement {
885    TextElement {
886        content: content.into(),
887        ..Default::default()
888    }
889}
890
891/// Build a [`VStackElement`] with the given children.
892pub fn vstack(children: Vec<WidgetElement>) -> VStackElement {
893    VStackElement {
894        children,
895        ..Default::default()
896    }
897}
898
899/// Build an [`HStackElement`] with the given children.
900pub fn hstack(children: Vec<WidgetElement>) -> HStackElement {
901    HStackElement {
902        children,
903        ..Default::default()
904    }
905}
906
907fn default_columns() -> u32 {
908    2
909}
910fn default_total() -> f64 {
911    1.0
912}
913
914#[cfg(test)]
915mod tests {
916    use super::*;
917    use std::path::PathBuf;
918
919    #[test]
920    fn option_none_omitted_from_json() {
921        let cfg = WidgetConfig::small(TextElement {
922            content: "hi".into(),
923            font_size: Some(12.0),
924            ..Default::default()
925        });
926        let json = serde_json::to_string(&cfg).unwrap();
927        assert!(!json.contains("null"), "JSON must not contain null: {json}");
928        assert!(!json.contains("\"medium\""));
929        assert!(!json.contains("\"fontWeight\""));
930        let back: WidgetConfig = serde_json::from_str(&json).unwrap();
931        assert!(back.medium.is_none());
932        match back.small.unwrap() {
933            WidgetElement::Text(TextElement {
934                font_weight,
935                content,
936                ..
937            }) => {
938                assert!(font_weight.is_none());
939                assert_eq!(content, "hi");
940            }
941            other => panic!("expected text, got {other:?}"),
942        }
943    }
944
945    #[test]
946    fn ergonomic_builders_serialize() {
947        let cfg = WidgetConfig::small(vstack(vec![text("72°")
948            .font_size(36.0)
949            .font_weight(FontWeight::Bold)
950            .color("#fff")
951            .into()]));
952        let v = serde_json::to_value(&cfg).unwrap();
953        assert_eq!(v["small"]["type"], "vstack");
954        assert_eq!(v["small"]["children"][0]["type"], "text");
955        assert_eq!(v["small"]["children"][0]["content"], "72°");
956        assert_eq!(v["small"]["children"][0]["fontSize"], 36.0);
957        assert_eq!(v["small"]["children"][0]["fontWeight"], "bold");
958        assert_eq!(v["small"]["children"][0]["color"], "#fff");
959    }
960
961    fn walk_json_files(dir: &std::path::Path, out: &mut Vec<PathBuf>) {
962        let Ok(entries) = std::fs::read_dir(dir) else {
963            return;
964        };
965        for entry in entries.flatten() {
966            let path = entry.path();
967            if path.is_dir() {
968                walk_json_files(&path, out);
969            } else if path.extension().and_then(|e| e.to_str()) == Some("json") {
970                out.push(path);
971            }
972        }
973    }
974
975    /// Round-trip every fixture through `WidgetConfig` — catches wire drift after
976    /// struct-variant refactors (tag + flattened fields must stay identical).
977    #[test]
978    fn wire_format_unchanged() {
979        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
980        let mut files = Vec::new();
981        walk_json_files(&root, &mut files);
982        assert!(
983            !files.is_empty(),
984            "expected fixtures under {}",
985            root.display()
986        );
987        for path in files {
988            let raw = std::fs::read_to_string(&path).unwrap();
989            let cfg: WidgetConfig = serde_json::from_str(&raw).unwrap_or_else(|e| {
990                panic!("deserialize {}: {e}", path.display());
991            });
992            let encoded = serde_json::to_value(&cfg).unwrap();
993            let again: WidgetConfig = serde_json::from_value(encoded.clone()).unwrap();
994            assert_eq!(
995                encoded,
996                serde_json::to_value(&again).unwrap(),
997                "wire drift in {}",
998                path.display()
999            );
1000            let out = serde_json::to_string(&cfg).unwrap();
1001            assert!(
1002                !out.contains("null"),
1003                "{} serialized with null: {out}",
1004                path.display()
1005            );
1006        }
1007    }
1008}