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(rename = "barStyle", default, skip_serializing_if = "Option::is_none")]
413    pub bar_style: Option<ProgressStyle>,
414    #[serde(flatten)]
415    /// Shared visual style (padding, background, frame, …).
416    pub style: ElementStyle,
417}
418
419impl Default for ProgressElement {
420    fn default() -> Self {
421        Self {
422            value: 0.0,
423            total: 1.0,
424            label: None,
425            tint: None,
426            color: None,
427            bar_style: None,
428            style: ElementStyle::default(),
429        }
430    }
431}
432
433/// Circular or capacity-style gauge.
434#[derive(Debug, Clone, Serialize, Deserialize, Default)]
435#[cfg_attr(feature = "schema", derive(JsonSchema))]
436#[serde(rename_all = "camelCase")]
437pub struct GaugeElement {
438    /// Current value within `[min, max]`.
439    pub value: f64,
440    /// Lower bound. Default `0`.
441    #[serde(default, skip_serializing_if = "Option::is_none")]
442    pub min: Option<f64>,
443    /// Upper bound. Default `1`.
444    #[serde(default, skip_serializing_if = "Option::is_none")]
445    pub max: Option<f64>,
446    /// Optional caption.
447    #[serde(default, skip_serializing_if = "Option::is_none")]
448    pub label: Option<String>,
449    /// Text shown for the current value (e.g. `"72%"`).
450    #[serde(
451        rename = "currentValueLabel",
452        default,
453        skip_serializing_if = "Option::is_none"
454    )]
455    pub current_value_label: Option<String>,
456    /// Accent color.
457    #[serde(default, skip_serializing_if = "Option::is_none")]
458    pub tint: Option<ColorValue>,
459    /// Secondary / track color.
460    #[serde(default, skip_serializing_if = "Option::is_none")]
461    pub color: Option<ColorValue>,
462    /// Visual style (e.g. `circular`).
463    #[serde(
464        rename = "gaugeStyle",
465        default,
466        skip_serializing_if = "Option::is_none"
467    )]
468    pub gauge_style: Option<GaugeStyle>,
469    #[serde(flatten)]
470    /// Shared visual style (padding, background, frame, …).
471    pub style: ElementStyle,
472}
473
474/// Tappable button that opens a URL or emits `widget-action`.
475#[derive(Debug, Clone, Serialize, Deserialize, Default)]
476#[cfg_attr(feature = "schema", derive(JsonSchema))]
477#[serde(rename_all = "camelCase")]
478pub struct ButtonElement {
479    /// Button label text.
480    pub label: String,
481    /// Deep link URL to open the app (used when no action is set).
482    #[serde(default, skip_serializing_if = "Option::is_none")]
483    pub url: Option<String>,
484    /// Action identifier — emits a `widget-action` Tauri event when tapped.
485    #[serde(default, skip_serializing_if = "Option::is_none")]
486    pub action: Option<String>,
487    /// Label text color.
488    #[serde(default, skip_serializing_if = "Option::is_none")]
489    pub color: Option<ColorValue>,
490    /// Button background color.
491    #[serde(
492        rename = "backgroundColor",
493        default,
494        skip_serializing_if = "Option::is_none"
495    )]
496    pub background_color: Option<ColorValue>,
497    /// Label font size in points.
498    #[serde(rename = "fontSize", default, skip_serializing_if = "Option::is_none")]
499    pub font_size: Option<f64>,
500    /// Label text alignment.
501    #[serde(
502        rename = "textAlignment",
503        default,
504        skip_serializing_if = "Option::is_none"
505    )]
506    pub text_alignment: Option<TextAlignment>,
507    #[serde(flatten)]
508    /// Shared visual style (padding, background, frame, …).
509    pub style: ElementStyle,
510}
511
512/// On/off toggle control.
513#[derive(Debug, Clone, Serialize, Deserialize, Default)]
514#[cfg_attr(feature = "schema", derive(JsonSchema))]
515#[serde(rename_all = "camelCase")]
516pub struct ToggleElement {
517    /// Whether the toggle is on.
518    #[serde(rename = "isOn")]
519    pub is_on: bool,
520    /// Optional label beside the control.
521    #[serde(default, skip_serializing_if = "Option::is_none")]
522    pub label: Option<String>,
523    /// Accent color when on.
524    #[serde(default, skip_serializing_if = "Option::is_none")]
525    pub tint: Option<ColorValue>,
526    /// Action identifier sent back to the app.
527    #[serde(default, skip_serializing_if = "Option::is_none")]
528    pub action: Option<String>,
529    #[serde(flatten)]
530    /// Shared visual style (padding, background, frame, …).
531    pub style: ElementStyle,
532}
533
534/// Horizontal or vertical rule.
535#[derive(Debug, Clone, Serialize, Deserialize, Default)]
536#[cfg_attr(feature = "schema", derive(JsonSchema))]
537#[serde(rename_all = "camelCase")]
538pub struct DividerElement {
539    /// Line color.
540    #[serde(default, skip_serializing_if = "Option::is_none")]
541    pub color: Option<ColorValue>,
542    /// Line thickness in points.
543    #[serde(default, skip_serializing_if = "Option::is_none")]
544    pub thickness: Option<f64>,
545    #[serde(flatten)]
546    /// Shared visual style (padding, background, frame, …).
547    pub style: ElementStyle,
548}
549
550/// Flexible empty space.
551#[derive(Debug, Clone, Serialize, Deserialize, Default)]
552#[cfg_attr(feature = "schema", derive(JsonSchema))]
553#[serde(rename_all = "camelCase")]
554pub struct SpacerElement {
555    /// Minimum length along the parent axis (points).
556    #[serde(rename = "minLength", default, skip_serializing_if = "Option::is_none")]
557    pub min_length: Option<f64>,
558}
559
560/// Formatted date / relative time display.
561#[derive(Debug, Clone, Serialize, Deserialize, Default)]
562#[cfg_attr(feature = "schema", derive(JsonSchema))]
563#[serde(rename_all = "camelCase")]
564pub struct DateElement {
565    /// ISO 8601 date string.
566    pub date: String,
567    /// Display style (`time`, `date`, `relative`, `offset`, `timer`).
568    #[serde(rename = "dateStyle", default, skip_serializing_if = "Option::is_none")]
569    pub date_style: Option<DateStyle>,
570    /// Font size in points.
571    #[serde(rename = "fontSize", default, skip_serializing_if = "Option::is_none")]
572    pub font_size: Option<f64>,
573    /// Text color.
574    #[serde(default, skip_serializing_if = "Option::is_none")]
575    pub color: Option<ColorValue>,
576    #[serde(flatten)]
577    /// Shared visual style (padding, background, frame, …).
578    pub style: ElementStyle,
579}
580
581/// Bar, line, area, or pie chart.
582#[derive(Debug, Clone, Serialize, Deserialize, Default)]
583#[cfg_attr(feature = "schema", derive(JsonSchema))]
584#[serde(rename_all = "camelCase")]
585pub struct ChartElement {
586    /// Chart kind: `bar`, `line`, `area`, or `pie`.
587    #[serde(rename = "chartType")]
588    pub chart_type: ChartType,
589    /// Data points (`label` + `value`, optional per-point `color`).
590    #[serde(default, rename = "chartData", skip_serializing_if = "Vec::is_empty")]
591    pub chart_data: Vec<ChartDataPoint>,
592    /// Default series tint.
593    #[serde(default, skip_serializing_if = "Option::is_none")]
594    pub tint: Option<ColorValue>,
595    #[serde(flatten)]
596    /// Shared visual style (padding, background, frame, …).
597    pub style: ElementStyle,
598}
599
600/// Collection list of rows (text, optional checked marker and action).
601#[derive(Debug, Clone, Serialize, Deserialize, Default)]
602#[cfg_attr(feature = "schema", derive(JsonSchema))]
603#[serde(rename_all = "camelCase")]
604pub struct ListElement {
605    /// Row items.
606    #[serde(default, skip_serializing_if = "Vec::is_empty")]
607    pub items: Vec<ListItem>,
608    /// Rich rows as nested elements (alternative to typed [`ListItem`]s).
609    #[serde(default, skip_serializing_if = "Vec::is_empty")]
610    pub children: Vec<WidgetElement>,
611    /// Space between rows (points).
612    #[serde(default, skip_serializing_if = "Option::is_none")]
613    pub spacing: Option<f64>,
614    /// Row text font size.
615    #[serde(rename = "fontSize", default, skip_serializing_if = "Option::is_none")]
616    pub font_size: Option<f64>,
617    /// Row text color.
618    #[serde(default, skip_serializing_if = "Option::is_none")]
619    pub color: Option<ColorValue>,
620    #[serde(flatten)]
621    /// Shared visual style (padding, background, frame, …).
622    pub style: ElementStyle,
623}
624
625/// Tappable wrapper — makes nested content clickable.
626#[derive(Debug, Clone, Serialize, Deserialize, Default)]
627#[cfg_attr(feature = "schema", derive(JsonSchema))]
628#[serde(rename_all = "camelCase")]
629pub struct LinkElement {
630    /// Nested content to wrap.
631    #[serde(default, skip_serializing_if = "Vec::is_empty")]
632    pub children: Vec<WidgetElement>,
633    /// Deep-link URL to open.
634    #[serde(default, skip_serializing_if = "Option::is_none")]
635    pub url: Option<String>,
636    /// Action identifier — emits `widget-action` event.
637    #[serde(default, skip_serializing_if = "Option::is_none")]
638    pub action: Option<String>,
639    #[serde(flatten)]
640    /// Shared visual style (padding, background, frame, …).
641    pub style: ElementStyle,
642}
643
644/// Colored shape — circle, capsule, or rectangle.
645#[derive(Debug, Clone, Serialize, Deserialize, Default)]
646#[cfg_attr(feature = "schema", derive(JsonSchema))]
647#[serde(rename_all = "camelCase")]
648pub struct ShapeElement {
649    /// Shape kind.
650    #[serde(rename = "shapeType")]
651    pub shape_type: ShapeType,
652    /// Fill color.
653    #[serde(default, skip_serializing_if = "Option::is_none")]
654    pub fill: Option<ColorValue>,
655    /// Stroke color.
656    #[serde(default, skip_serializing_if = "Option::is_none")]
657    pub stroke: Option<ColorValue>,
658    /// Stroke width in points.
659    #[serde(
660        rename = "strokeWidth",
661        default,
662        skip_serializing_if = "Option::is_none"
663    )]
664    pub stroke_width: Option<f64>,
665    /// Bounding size in points.
666    #[serde(default, skip_serializing_if = "Option::is_none")]
667    pub size: Option<f64>,
668    #[serde(flatten)]
669    /// Shared visual style (padding, background, frame, …).
670    pub style: ElementStyle,
671}
672
673/// Live countdown/countup timer that updates without timeline refresh.
674#[derive(Debug, Clone, Serialize, Deserialize, Default)]
675#[cfg_attr(feature = "schema", derive(JsonSchema))]
676#[serde(rename_all = "camelCase")]
677pub struct TimerElement {
678    /// ISO 8601 target date.
679    #[serde(rename = "targetDate")]
680    pub target_date: String,
681    /// Count direction. Default: `down`.
682    #[serde(default, skip_serializing_if = "Option::is_none")]
683    pub counting: Option<TimerCounting>,
684    /// Font size in points.
685    #[serde(rename = "fontSize", default, skip_serializing_if = "Option::is_none")]
686    pub font_size: Option<f64>,
687    /// Font weight.
688    #[serde(
689        rename = "fontWeight",
690        default,
691        skip_serializing_if = "Option::is_none"
692    )]
693    pub font_weight: Option<FontWeight>,
694    /// Text color.
695    #[serde(default, skip_serializing_if = "Option::is_none")]
696    pub color: Option<ColorValue>,
697    #[serde(flatten)]
698    /// Shared visual style (padding, background, frame, …).
699    pub style: ElementStyle,
700}
701
702/// Declarative canvas — draw arbitrary shapes via JSON commands.
703#[derive(Debug, Clone, Serialize, Deserialize, Default)]
704#[cfg_attr(feature = "schema", derive(JsonSchema))]
705#[serde(rename_all = "camelCase")]
706pub struct CanvasElement {
707    /// Canvas width in points.
708    pub width: f64,
709    /// Canvas height in points.
710    pub height: f64,
711    /// Draw commands (`circle`, `line`, `rect`, `arc`, `text`, `path`).
712    #[serde(default, skip_serializing_if = "Vec::is_empty")]
713    pub elements: Vec<CanvasDrawCommand>,
714    #[serde(flatten)]
715    /// Shared visual style (padding, background, frame, …).
716    pub style: ElementStyle,
717}
718
719/// Convenience element combining an SF Symbol / icon with text.
720#[derive(Debug, Clone, Serialize, Deserialize, Default)]
721#[cfg_attr(feature = "schema", derive(JsonSchema))]
722#[serde(rename_all = "camelCase")]
723pub struct LabelElement {
724    /// Label text.
725    pub text: String,
726    /// SF Symbol or platform icon name.
727    #[serde(rename = "systemName")]
728    pub system_name: String,
729    /// Icon tint color.
730    #[serde(rename = "iconColor", default, skip_serializing_if = "Option::is_none")]
731    pub icon_color: Option<ColorValue>,
732    /// Text font size.
733    #[serde(rename = "fontSize", default, skip_serializing_if = "Option::is_none")]
734    pub font_size: Option<f64>,
735    /// Text font weight.
736    #[serde(
737        rename = "fontWeight",
738        default,
739        skip_serializing_if = "Option::is_none"
740    )]
741    pub font_weight: Option<FontWeight>,
742    /// Text color.
743    #[serde(default, skip_serializing_if = "Option::is_none")]
744    pub color: Option<ColorValue>,
745    /// Space between icon and text.
746    #[serde(default, skip_serializing_if = "Option::is_none")]
747    pub spacing: Option<f64>,
748    #[serde(flatten)]
749    /// Shared visual style (padding, background, frame, …).
750    pub style: ElementStyle,
751}
752
753impl From<VStackElement> for WidgetElement {
754    fn from(value: VStackElement) -> Self {
755        WidgetElement::VStack(value)
756    }
757}
758
759impl From<HStackElement> for WidgetElement {
760    fn from(value: HStackElement) -> Self {
761        WidgetElement::HStack(value)
762    }
763}
764
765impl From<ZStackElement> for WidgetElement {
766    fn from(value: ZStackElement) -> Self {
767        WidgetElement::ZStack(value)
768    }
769}
770
771impl From<GridElement> for WidgetElement {
772    fn from(value: GridElement) -> Self {
773        WidgetElement::Grid(value)
774    }
775}
776
777impl From<ContainerElement> for WidgetElement {
778    fn from(value: ContainerElement) -> Self {
779        WidgetElement::Container(value)
780    }
781}
782
783impl From<TextElement> for WidgetElement {
784    fn from(value: TextElement) -> Self {
785        WidgetElement::Text(value)
786    }
787}
788
789impl From<ImageElement> for WidgetElement {
790    fn from(value: ImageElement) -> Self {
791        WidgetElement::Image(value)
792    }
793}
794
795impl From<ProgressElement> for WidgetElement {
796    fn from(value: ProgressElement) -> Self {
797        WidgetElement::Progress(value)
798    }
799}
800
801impl From<GaugeElement> for WidgetElement {
802    fn from(value: GaugeElement) -> Self {
803        WidgetElement::Gauge(value)
804    }
805}
806
807impl From<ButtonElement> for WidgetElement {
808    fn from(value: ButtonElement) -> Self {
809        WidgetElement::Button(value)
810    }
811}
812
813impl From<ToggleElement> for WidgetElement {
814    fn from(value: ToggleElement) -> Self {
815        WidgetElement::Toggle(value)
816    }
817}
818
819impl From<DividerElement> for WidgetElement {
820    fn from(value: DividerElement) -> Self {
821        WidgetElement::Divider(value)
822    }
823}
824
825impl From<SpacerElement> for WidgetElement {
826    fn from(value: SpacerElement) -> Self {
827        WidgetElement::Spacer(value)
828    }
829}
830
831impl From<DateElement> for WidgetElement {
832    fn from(value: DateElement) -> Self {
833        WidgetElement::Date(value)
834    }
835}
836
837impl From<ChartElement> for WidgetElement {
838    fn from(value: ChartElement) -> Self {
839        WidgetElement::Chart(value)
840    }
841}
842
843impl From<ListElement> for WidgetElement {
844    fn from(value: ListElement) -> Self {
845        WidgetElement::List(value)
846    }
847}
848
849impl From<LinkElement> for WidgetElement {
850    fn from(value: LinkElement) -> Self {
851        WidgetElement::Link(value)
852    }
853}
854
855impl From<ShapeElement> for WidgetElement {
856    fn from(value: ShapeElement) -> Self {
857        WidgetElement::Shape(value)
858    }
859}
860
861impl From<TimerElement> for WidgetElement {
862    fn from(value: TimerElement) -> Self {
863        WidgetElement::Timer(value)
864    }
865}
866
867impl From<CanvasElement> for WidgetElement {
868    fn from(value: CanvasElement) -> Self {
869        WidgetElement::Canvas(value)
870    }
871}
872
873impl From<LabelElement> for WidgetElement {
874    fn from(value: LabelElement) -> Self {
875        WidgetElement::Label(value)
876    }
877}
878
879/// Build a [`TextElement`] with the given content.
880pub fn text(content: impl Into<String>) -> TextElement {
881    TextElement {
882        content: content.into(),
883        ..Default::default()
884    }
885}
886
887/// Build a [`VStackElement`] with the given children.
888pub fn vstack(children: Vec<WidgetElement>) -> VStackElement {
889    VStackElement {
890        children,
891        ..Default::default()
892    }
893}
894
895/// Build an [`HStackElement`] with the given children.
896pub fn hstack(children: Vec<WidgetElement>) -> HStackElement {
897    HStackElement {
898        children,
899        ..Default::default()
900    }
901}
902
903fn default_columns() -> u32 {
904    2
905}
906fn default_total() -> f64 {
907    1.0
908}
909
910#[cfg(test)]
911mod tests {
912    use super::*;
913    use std::path::PathBuf;
914
915    #[test]
916    fn option_none_omitted_from_json() {
917        let cfg = WidgetConfig::small(TextElement {
918            content: "hi".into(),
919            font_size: Some(12.0),
920            ..Default::default()
921        });
922        let json = serde_json::to_string(&cfg).unwrap();
923        assert!(!json.contains("null"), "JSON must not contain null: {json}");
924        assert!(!json.contains("\"medium\""));
925        assert!(!json.contains("\"fontWeight\""));
926        let back: WidgetConfig = serde_json::from_str(&json).unwrap();
927        assert!(back.medium.is_none());
928        match back.small.unwrap() {
929            WidgetElement::Text(TextElement {
930                font_weight,
931                content,
932                ..
933            }) => {
934                assert!(font_weight.is_none());
935                assert_eq!(content, "hi");
936            }
937            other => panic!("expected text, got {other:?}"),
938        }
939    }
940
941    #[test]
942    fn ergonomic_builders_serialize() {
943        let cfg = WidgetConfig::small(vstack(vec![text("72°")
944            .font_size(36.0)
945            .font_weight(FontWeight::Bold)
946            .color("#fff")
947            .into()]));
948        let v = serde_json::to_value(&cfg).unwrap();
949        assert_eq!(v["small"]["type"], "vstack");
950        assert_eq!(v["small"]["children"][0]["type"], "text");
951        assert_eq!(v["small"]["children"][0]["content"], "72°");
952        assert_eq!(v["small"]["children"][0]["fontSize"], 36.0);
953        assert_eq!(v["small"]["children"][0]["fontWeight"], "bold");
954        assert_eq!(v["small"]["children"][0]["color"], "#fff");
955    }
956
957    fn walk_json_files(dir: &std::path::Path, out: &mut Vec<PathBuf>) {
958        let Ok(entries) = std::fs::read_dir(dir) else {
959            return;
960        };
961        for entry in entries.flatten() {
962            let path = entry.path();
963            if path.is_dir() {
964                walk_json_files(&path, out);
965            } else if path.extension().and_then(|e| e.to_str()) == Some("json") {
966                out.push(path);
967            }
968        }
969    }
970
971    /// Round-trip every fixture through `WidgetConfig` — catches wire drift after
972    /// struct-variant refactors (tag + flattened fields must stay identical).
973    #[test]
974    fn wire_format_unchanged() {
975        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
976        let mut files = Vec::new();
977        walk_json_files(&root, &mut files);
978        assert!(
979            !files.is_empty(),
980            "expected fixtures under {}",
981            root.display()
982        );
983        for path in files {
984            let raw = std::fs::read_to_string(&path).unwrap();
985            let cfg: WidgetConfig = serde_json::from_str(&raw).unwrap_or_else(|e| {
986                panic!("deserialize {}: {e}", path.display());
987            });
988            let encoded = serde_json::to_value(&cfg).unwrap();
989            let again: WidgetConfig = serde_json::from_value(encoded.clone()).unwrap();
990            assert_eq!(
991                encoded,
992                serde_json::to_value(&again).unwrap(),
993                "wire drift in {}",
994                path.display()
995            );
996            let out = serde_json::to_string(&cfg).unwrap();
997            assert!(
998                !out.contains("null"),
999                "{} serialized with null: {out}",
1000                path.display()
1001            );
1002        }
1003    }
1004}