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 activity;
8mod data;
9mod style;
10pub use activity::*;
11pub use data::*;
12pub use style::*;
13
14use serde::{Deserialize, Serialize};
15use std::collections::BTreeMap;
16
17#[cfg(feature = "schema")]
18use schemars::JsonSchema;
19
20/// Configuration for creating a desktop widget window.
21///
22/// When `url` is omitted the plugin serves its built-in renderer
23/// automatically (via a custom URI-scheme protocol).  In that case
24/// `group` tells the renderer which config to load, and `size`
25/// selects the layout family (`"small"`, `"medium"`, or `"large"`).
26#[derive(Debug, Clone, Serialize, Deserialize)]
27#[cfg_attr(feature = "schema", derive(JsonSchema))]
28#[serde(rename_all = "camelCase")]
29pub struct WidgetWindowConfig {
30    /// Tauri window label (must be unique).
31    pub label: String,
32    /// Frontend route or URL.  Leave empty / omit to use the built-in
33    /// widget renderer that ships with the plugin.
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub url: Option<String>,
36    /// Window width in logical pixels.
37    pub width: f64,
38    /// Window height in logical pixels.
39    pub height: f64,
40    /// Optional X position.
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub x: Option<f64>,
43    /// Optional Y position.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub y: Option<f64>,
46    /// Keep the widget above other windows.
47    #[serde(default)]
48    pub always_on_top: bool,
49    /// Hide from the taskbar / dock.
50    #[serde(default = "default_true")]
51    pub skip_taskbar: bool,
52    /// Widget group identifier — passed to the built-in renderer so it
53    /// knows which config to load via `get_widget_config`.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub group: Option<String>,
56    /// Widget identity within the group (required for built-in renderer).
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub widget_id: Option<String>,
59    /// Placement id for the built-in renderer: reads `config:{widgetId}#{instance}`
60    /// first and fills `{{instance}}`. Defaults to none.
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub instance: Option<String>,
63    /// Size family the renderer should display: `"small"`, `"medium"`,
64    /// or `"large"`.  Defaults to `"small"` when omitted.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub size: Option<String>,
67    /// Stacking layer. Unset: `top` when `alwaysOnTop`, else `desktop` on Linux (the
68    /// historical DESKTOP pin) and `normal` on macOS / Windows.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub layer: Option<WindowLayer>,
71    /// Let clicks pass through to whatever is below. Default `false`.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub click_through: Option<bool>,
74    /// Whether the window can take keyboard focus. Default `true`.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub focusable: Option<bool>,
77    /// Show on every virtual desktop / Space. Default: `true` for the `desktop` layer.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub all_workspaces: Option<bool>,
80    /// System window shadow (macOS / Windows). Default: platform default.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub shadow: Option<bool>,
83    /// Backdrop material hint (macOS vibrancy, Windows 11 mica / acrylic). Needs a
84    /// transparent window; ignored where the platform has no such material (Linux).
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub effects: Option<WindowEffectHint>,
87    /// Place relative to a monitor's work area instead of absolute `x` / `y`.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub placement: Option<WidgetPlacement>,
90    /// Remember where the user moved the window (per label) and restore it next time.
91    /// Default `true`; a remembered position wins over `x` / `y` / `placement` while it
92    /// is still on a connected monitor.
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub remember_position: Option<bool>,
95}
96
97impl Default for WidgetWindowConfig {
98    fn default() -> Self {
99        Self {
100            label: String::new(),
101            url: None,
102            width: 0.0,
103            height: 0.0,
104            x: None,
105            y: None,
106            always_on_top: false,
107            skip_taskbar: true,
108            group: None,
109            widget_id: None,
110            instance: None,
111            size: None,
112            layer: None,
113            click_through: None,
114            focusable: None,
115            all_workspaces: None,
116            shadow: None,
117            effects: None,
118            placement: None,
119            remember_position: None,
120        }
121    }
122}
123
124/// Stacking layer of a desktop widget window.
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
126#[cfg_attr(feature = "schema", derive(JsonSchema))]
127#[serde(rename_all = "camelCase")]
128pub enum WindowLayer {
129    /// Ordinary window.
130    Normal,
131    /// Above other windows (always on top; Wayland layer-shell `Top`).
132    Top,
133    /// Below other windows but above the desktop (always on bottom; Wayland `Bottom`).
134    Bottom,
135    /// Part of the desktop: X11 `_NET_WM_WINDOW_TYPE_DESKTOP`, Wayland `Background`,
136    /// macOS desktop window level. Windows: parented behind the desktop icons (WorkerW,
137    /// or a Progman child on 24H2), re-attached after an Explorer restart.
138    Desktop,
139}
140
141/// Backdrop material hint for [`WidgetWindowConfig::effects`].
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
143#[cfg_attr(feature = "schema", derive(JsonSchema))]
144#[serde(rename_all = "camelCase")]
145pub enum WindowEffectHint {
146    /// macOS HUD vibrancy; Windows acrylic.
147    Vibrancy,
148    /// Windows 11 mica; macOS under-window vibrancy.
149    Mica,
150    /// Windows acrylic; macOS HUD vibrancy.
151    Acrylic,
152    /// Windows blur; macOS under-window vibrancy.
153    Blur,
154}
155
156/// Monitor-relative placement for [`WidgetWindowConfig::placement`].
157#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
158#[cfg_attr(feature = "schema", derive(JsonSchema))]
159#[serde(rename_all = "camelCase")]
160pub struct WidgetPlacement {
161    /// Monitor index (in `availableMonitors` order) or monitor name. Default: primary.
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub monitor: Option<MonitorSelector>,
164    /// Corner / edge of the work area to stick to. Default `top-left`.
165    #[serde(default)]
166    pub anchor: PlacementAnchor,
167    /// Logical px inward from the anchored vertical edge (ignored for centered axes,
168    /// where it shifts right).
169    #[serde(default)]
170    pub offset_x: f64,
171    /// Logical px inward from the anchored horizontal edge (shifts down when centered).
172    #[serde(default)]
173    pub offset_y: f64,
174}
175
176/// Monitor by index or by name.
177#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
178#[cfg_attr(feature = "schema", derive(JsonSchema))]
179#[serde(untagged)]
180pub enum MonitorSelector {
181    /// Index in the list of available monitors.
182    Index(u32),
183    /// Monitor name as reported by the OS.
184    Name(String),
185}
186
187/// Work-area anchor for [`WidgetPlacement`].
188#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
189#[cfg_attr(feature = "schema", derive(JsonSchema))]
190#[serde(rename_all = "kebab-case")]
191pub enum PlacementAnchor {
192    /// Top-left corner.
193    #[default]
194    TopLeft,
195    /// Top edge, centered.
196    Top,
197    /// Top-right corner.
198    TopRight,
199    /// Left edge, centered.
200    Left,
201    /// Center.
202    Center,
203    /// Right edge, centered.
204    Right,
205    /// Bottom-left corner.
206    BottomLeft,
207    /// Bottom edge, centered.
208    Bottom,
209    /// Bottom-right corner.
210    BottomRight,
211}
212
213fn default_true() -> bool {
214    true
215}
216
217// ─── Widget UI Configuration ─────────────────────────────────────────────────
218
219/// Top-level widget config with layouts per size family.
220#[derive(Debug, Clone, Serialize, Deserialize)]
221#[cfg_attr(feature = "schema", derive(JsonSchema))]
222#[serde(rename_all = "camelCase")]
223pub struct WidgetConfig {
224    /// Schema version. Defaults to `1`.
225    #[serde(default = "default_version")]
226    pub version: u32,
227    /// Layout for the small size family.
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub small: Option<WidgetElement>,
230    /// Layout for the medium size family.
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub medium: Option<WidgetElement>,
233    /// Layout for the large size family.
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub large: Option<WidgetElement>,
236    /// iOS / watchOS Lock Screen accessory families (circular, rectangular, inline).
237    /// Falls back to `small`. Ignored elsewhere. Not a resolve family: `{{…}}`, `when`,
238    /// `repeat` and `entries` do not apply to it.
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub accessory: Option<WidgetElement>,
241    /// Scheduled states: from `at` on, an entry's layouts replace the base ones.
242    /// The OS switches them while the app is closed (WidgetKit timeline, Android alarm,
243    /// desktop timer). See [`crate::resolve`].
244    #[serde(default, skip_serializing_if = "Vec::is_empty")]
245    pub entries: Vec<WidgetEntry>,
246    /// Localized strings per locale (`"en"`, `"pt-BR"`), used as `{{t.key}}` in any text.
247    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
248    pub strings: BTreeMap<String, BTreeMap<String, String>>,
249    /// Locale used when the device locale has no `strings` table. Default: first table.
250    #[serde(
251        rename = "defaultLocale",
252        default,
253        skip_serializing_if = "Option::is_none"
254    )]
255    pub default_locale: Option<String>,
256    /// JSON the widget fetches by itself on a schedule; its fields are `{{data.path}}`.
257    #[serde(default, skip_serializing_if = "Option::is_none")]
258    pub source: Option<DataSource>,
259    /// Default values for `{{data.path}}` (overridden by `setWidgetData` and `source`).
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    #[cfg_attr(
262        feature = "schema",
263        schemars(with = "Option<BTreeMap<String, serde_json::Value>>")
264    )]
265    pub data: Option<serde_json::Value>,
266    /// Defaults of the widget's local state, read as `{{state.key}}` / `when` /
267    /// `bind` / `repeat.each: "state.list"`. Taps on elements with `set` change it
268    /// in-process (no app needed); `setWidgetState` changes it from the app. The stored
269    /// state (`state:{widgetId}`, `state:{widgetId}#{instance}`) is merged over these per
270    /// top-level key.
271    #[serde(default, skip_serializing_if = "Option::is_none")]
272    #[cfg_attr(
273        feature = "schema",
274        schemars(with = "Option<serde_json::Map<String, serde_json::Value>>")
275    )]
276    pub state: Option<serde_json::Map<String, serde_json::Value>>,
277    /// How content changes between timeline entries / redraws (a hint):
278    /// `numeric` animates digits, `opacity` cross-fades, `slide` pushes from the bottom.
279    /// Apple and desktop animate; Android and Adaptive Cards ignore it.
280    #[serde(default, skip_serializing_if = "Option::is_none")]
281    pub transition: Option<Transition>,
282    /// Reusable elements expanded by `{"type": "use", "template": "<key>"}`.
283    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
284    pub templates: BTreeMap<String, WidgetElement>,
285    /// Minimum renderer contract; older renderers show `fallback` instead.
286    #[serde(default, skip_serializing_if = "Option::is_none")]
287    pub requires: Option<Requires>,
288    /// Layouts shown by renderers that do not meet `requires`.
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub fallback: Option<FallbackLayouts>,
291}
292
293/// Renderer requirements of a config.
294#[derive(Debug, Clone, Serialize, Deserialize, Default)]
295#[cfg_attr(feature = "schema", derive(JsonSchema))]
296#[serde(rename_all = "camelCase")]
297pub struct Requires {
298    /// Render-time contract version ([`crate::resolve::CONTRACT_VERSION`]).
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub resolve: Option<u64>,
301}
302
303/// Layouts for renderers below `requires`.
304#[derive(Debug, Clone, Serialize, Deserialize, Default)]
305#[cfg_attr(feature = "schema", derive(JsonSchema))]
306#[serde(rename_all = "camelCase")]
307pub struct FallbackLayouts {
308    /// Small layout.
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub small: Option<WidgetElement>,
311    /// Medium layout.
312    #[serde(default, skip_serializing_if = "Option::is_none")]
313    pub medium: Option<WidgetElement>,
314    /// Large layout.
315    #[serde(default, skip_serializing_if = "Option::is_none")]
316    pub large: Option<WidgetElement>,
317}
318
319impl Default for WidgetConfig {
320    fn default() -> Self {
321        Self {
322            version: 1,
323            small: None,
324            medium: None,
325            large: None,
326            accessory: None,
327            entries: Vec::new(),
328            strings: BTreeMap::new(),
329            default_locale: None,
330            source: None,
331            data: None,
332            state: None,
333            transition: None,
334            templates: BTreeMap::new(),
335            requires: None,
336            fallback: None,
337        }
338    }
339}
340
341/// One scheduled state of a widget ([`WidgetConfig::entries`]).
342#[derive(Debug, Clone, Serialize, Deserialize, Default)]
343#[cfg_attr(feature = "schema", derive(JsonSchema))]
344#[serde(rename_all = "camelCase")]
345pub struct WidgetEntry {
346    /// Unix epoch milliseconds from which this entry is shown.
347    pub at: u64,
348    /// Small layout from `at` on (base layout when omitted).
349    #[serde(default, skip_serializing_if = "Option::is_none")]
350    pub small: Option<WidgetElement>,
351    /// Medium layout from `at` on (base layout when omitted).
352    #[serde(default, skip_serializing_if = "Option::is_none")]
353    pub medium: Option<WidgetElement>,
354    /// Large layout from `at` on (base layout when omitted).
355    #[serde(default, skip_serializing_if = "Option::is_none")]
356    pub large: Option<WidgetElement>,
357    /// Lock Screen accessory layout from `at` on (base layout when omitted).
358    #[serde(default, skip_serializing_if = "Option::is_none")]
359    pub accessory: Option<WidgetElement>,
360}
361
362/// Remote JSON the widget fetches without the app ([`WidgetConfig::source`]).
363///
364/// `url`, header values and `body` may use `{{instance}}` and `{{locale}}`, so each
365/// placement of a widget can ask for its own data.
366#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
367#[cfg_attr(feature = "schema", derive(JsonSchema))]
368#[serde(rename_all = "camelCase")]
369pub struct DataSource {
370    /// `https://` URL returning a JSON object (at most 256 KiB).
371    pub url: String,
372    /// Minutes between fetches. Default 30; the OS may stretch it (minimum 15).
373    #[serde(
374        rename = "refreshMinutes",
375        default,
376        skip_serializing_if = "Option::is_none"
377    )]
378    pub refresh_minutes: Option<u32>,
379    /// `GET` (default) or `POST`.
380    #[serde(default, skip_serializing_if = "Option::is_none")]
381    pub method: Option<String>,
382    /// Extra request headers.
383    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
384    pub headers: BTreeMap<String, String>,
385    /// Request body for `POST`.
386    #[serde(default, skip_serializing_if = "Option::is_none")]
387    pub body: Option<String>,
388}
389
390impl WidgetConfig {
391    /// Config with only the `small` size family set.
392    pub fn small(el: impl Into<WidgetElement>) -> Self {
393        Self {
394            small: Some(el.into()),
395            ..Default::default()
396        }
397    }
398
399    /// Every layout root, scheduled entries included (for host-side passes such as
400    /// normalization and image prefetch).
401    pub fn layouts_mut(&mut self) -> impl Iterator<Item = &mut WidgetElement> {
402        let fallback = self
403            .fallback
404            .as_mut()
405            .map(|f| [&mut f.small, &mut f.medium, &mut f.large]);
406        [
407            &mut self.small,
408            &mut self.medium,
409            &mut self.large,
410            &mut self.accessory,
411        ]
412        .into_iter()
413        .chain(
414            self.entries
415                .iter_mut()
416                .flat_map(|e| [&mut e.small, &mut e.medium, &mut e.large, &mut e.accessory]),
417        )
418        .chain(fallback.into_iter().flatten())
419        .flatten()
420        .chain(self.templates.values_mut())
421    }
422
423    /// Set the `medium` size family.
424    pub fn with_medium(mut self, el: impl Into<WidgetElement>) -> Self {
425        self.medium = Some(el.into());
426        self
427    }
428
429    /// Set the `large` size family.
430    pub fn with_large(mut self, el: impl Into<WidgetElement>) -> Self {
431        self.large = Some(el.into());
432        self
433    }
434}
435
436fn default_version() -> u32 {
437    1
438}
439
440/// A UI element that can be a layout container or a leaf widget.
441#[derive(Debug, Clone, Serialize, Deserialize)]
442#[cfg_attr(feature = "schema", derive(JsonSchema))]
443#[serde(tag = "type", rename_all = "camelCase")]
444pub enum WidgetElement {
445    /// Vertical stack of children.
446    #[serde(rename = "vstack")]
447    VStack(VStackElement),
448    /// Horizontal stack of children.
449    #[serde(rename = "hstack")]
450    HStack(HStackElement),
451    /// Overlay stack — children layered on top of each other.
452    #[serde(rename = "zstack")]
453    ZStack(ZStackElement),
454    /// Fixed-column grid of children.
455    #[serde(rename = "grid")]
456    Grid(GridElement),
457    /// Single-child wrapper for cards, badges, and overlays.
458    #[serde(rename = "container")]
459    Container(ContainerElement),
460    /// Text label with optional semantic typography.
461    #[serde(rename = "text")]
462    Text(TextElement),
463    /// Image from SF Symbol / drawable name, base64 data, or URL.
464    #[serde(rename = "image")]
465    Image(ImageElement),
466    /// Linear or circular progress indicator.
467    #[serde(rename = "progress")]
468    Progress(ProgressElement),
469    /// Circular or capacity-style gauge.
470    #[serde(rename = "gauge")]
471    Gauge(GaugeElement),
472    /// Tappable button that opens a URL or emits `widget-action`.
473    #[serde(rename = "button")]
474    Button(ButtonElement),
475    /// On/off toggle control.
476    #[serde(rename = "toggle")]
477    Toggle(ToggleElement),
478    /// Horizontal or vertical rule.
479    #[serde(rename = "divider")]
480    Divider(DividerElement),
481    /// Flexible empty space.
482    #[serde(rename = "spacer")]
483    Spacer(SpacerElement),
484    /// Formatted date / relative time display.
485    #[serde(rename = "date")]
486    Date(DateElement),
487    /// Bar, line, area, or pie chart.
488    #[serde(rename = "chart")]
489    Chart(ChartElement),
490    /// Collection list of rows (text, optional checked marker and action).
491    #[serde(rename = "list")]
492    List(ListElement),
493    /// Tappable wrapper — makes nested content clickable.
494    #[serde(rename = "link")]
495    Link(LinkElement),
496    /// Colored shape — circle, capsule, or rectangle.
497    #[serde(rename = "shape")]
498    Shape(ShapeElement),
499    /// Live countdown/countup timer that updates without timeline refresh.
500    #[serde(rename = "timer")]
501    Timer(TimerElement),
502    /// Declarative canvas — draw arbitrary shapes via JSON commands.
503    #[serde(rename = "canvas")]
504    Canvas(CanvasElement),
505    /// Convenience element combining an SF Symbol / icon with text.
506    #[serde(rename = "label")]
507    Label(LabelElement),
508    /// One `template` copy per item of a data array, spliced into the parent's children
509    /// (resolved before rendering; see [`crate::resolve`]).
510    #[serde(rename = "repeat")]
511    Repeat(RepeatElement),
512    /// A named entry of [`WidgetConfig::templates`] with arguments (resolved before
513    /// rendering).
514    #[serde(rename = "use")]
515    Use(UseElement),
516}
517
518/// Repeat a template for every item of an array.
519#[derive(Debug, Clone, Serialize, Deserialize)]
520#[cfg_attr(feature = "schema", derive(JsonSchema))]
521#[serde(rename_all = "camelCase")]
522pub struct RepeatElement {
523    /// Path of the array: `data.tasks`, or a loop / template variable (`task.subtasks`).
524    pub each: String,
525    /// Name of the item inside the template (`{{task.title}}`). Default `item`.
526    #[serde(rename = "as", default, skip_serializing_if = "Option::is_none")]
527    pub as_name: Option<String>,
528    /// At most this many items (hard cap 50).
529    #[serde(default, skip_serializing_if = "Option::is_none")]
530    pub limit: Option<u32>,
531    /// Element drawn per item; `{{index}}` is its 0-based position.
532    pub template: Box<WidgetElement>,
533    #[serde(flatten)]
534    /// Shared style (only `when` is used).
535    pub style: ElementStyle,
536}
537
538/// Expand a named template.
539#[derive(Debug, Clone, Serialize, Deserialize)]
540#[cfg_attr(feature = "schema", derive(JsonSchema))]
541#[serde(rename_all = "camelCase")]
542pub struct UseElement {
543    /// Key in [`WidgetConfig::templates`].
544    pub template: String,
545    /// Arguments, read as `{{arg.name}}` / `bind: {"value": "arg.name"}`. A value that is
546    /// exactly one `{{expr}}` keeps the expression's JSON type.
547    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
548    #[cfg_attr(
549        feature = "schema",
550        schemars(with = "BTreeMap<String, serde_json::Value>")
551    )]
552    pub args: BTreeMap<String, serde_json::Value>,
553    #[serde(flatten)]
554    /// Style fields here override the template root (instance wins).
555    pub style: ElementStyle,
556}
557
558/// Vertical stack of children.
559#[derive(Debug, Clone, Serialize, Deserialize, Default)]
560#[cfg_attr(feature = "schema", derive(JsonSchema))]
561#[serde(rename_all = "camelCase")]
562pub struct VStackElement {
563    /// Child elements, top to bottom.
564    #[serde(default, skip_serializing_if = "Vec::is_empty")]
565    pub children: Vec<WidgetElement>,
566    /// Space between children (points).
567    #[serde(default, skip_serializing_if = "Option::is_none")]
568    pub spacing: Option<f64>,
569    /// Horizontal alignment of children.
570    #[serde(default, skip_serializing_if = "Option::is_none")]
571    pub alignment: Option<HorizontalAlignment>,
572    /// Distribution of children along the stack axis (`spaceBetween`, …). Default `start`.
573    #[serde(rename = "justify", default, skip_serializing_if = "Option::is_none")]
574    pub justify: Option<Justify>,
575    #[serde(flatten)]
576    /// Shared visual style (padding, background, frame, …).
577    pub style: ElementStyle,
578}
579
580/// Horizontal stack of children.
581#[derive(Debug, Clone, Serialize, Deserialize, Default)]
582#[cfg_attr(feature = "schema", derive(JsonSchema))]
583#[serde(rename_all = "camelCase")]
584pub struct HStackElement {
585    /// Child elements, leading to trailing.
586    #[serde(default, skip_serializing_if = "Vec::is_empty")]
587    pub children: Vec<WidgetElement>,
588    /// Space between children (points).
589    #[serde(default, skip_serializing_if = "Option::is_none")]
590    pub spacing: Option<f64>,
591    /// Vertical alignment of children.
592    #[serde(default, skip_serializing_if = "Option::is_none")]
593    pub alignment: Option<VerticalAlignment>,
594    /// Distribution of children along the stack axis (`spaceBetween`, …). Default `start`.
595    #[serde(rename = "justify", default, skip_serializing_if = "Option::is_none")]
596    pub justify: Option<Justify>,
597    #[serde(flatten)]
598    /// Shared visual style (padding, background, frame, …).
599    pub style: ElementStyle,
600}
601
602/// Overlay stack — children layered on top of each other.
603#[derive(Debug, Clone, Serialize, Deserialize, Default)]
604#[cfg_attr(feature = "schema", derive(JsonSchema))]
605#[serde(rename_all = "camelCase")]
606pub struct ZStackElement {
607    /// Layered children (later draw on top).
608    #[serde(default, skip_serializing_if = "Vec::is_empty")]
609    pub children: Vec<WidgetElement>,
610    /// Alignment of layers within the stack (e.g. `center`, `topLeading`).
611    #[serde(default, skip_serializing_if = "Option::is_none")]
612    pub alignment: Option<String>,
613    #[serde(flatten)]
614    /// Shared visual style (padding, background, frame, …).
615    pub style: ElementStyle,
616}
617
618/// Fixed-column grid of children.
619#[derive(Debug, Clone, Serialize, Deserialize, Default)]
620#[cfg_attr(feature = "schema", derive(JsonSchema))]
621#[serde(rename_all = "camelCase")]
622pub struct GridElement {
623    /// Grid cells in row-major order.
624    #[serde(default, skip_serializing_if = "Vec::is_empty")]
625    pub children: Vec<WidgetElement>,
626    /// Number of columns. Default `2`.
627    #[serde(default = "default_columns")]
628    pub columns: u32,
629    /// Column spacing (points).
630    #[serde(default, skip_serializing_if = "Option::is_none")]
631    pub spacing: Option<f64>,
632    /// Row spacing (points).
633    #[serde(
634        rename = "rowSpacing",
635        default,
636        skip_serializing_if = "Option::is_none"
637    )]
638    pub row_spacing: Option<f64>,
639    #[serde(flatten)]
640    /// Shared visual style (padding, background, frame, …).
641    pub style: ElementStyle,
642}
643
644/// Single-child wrapper for cards, badges, and overlays.
645#[derive(Debug, Clone, Serialize, Deserialize, Default)]
646#[cfg_attr(feature = "schema", derive(JsonSchema))]
647#[serde(rename_all = "camelCase")]
648pub struct ContainerElement {
649    /// Nested content (typically one child).
650    #[serde(default, skip_serializing_if = "Vec::is_empty")]
651    pub children: Vec<WidgetElement>,
652    /// Content alignment inside the box (e.g. `center`, `topLeading`).
653    #[serde(
654        rename = "contentAlignment",
655        default,
656        skip_serializing_if = "Option::is_none"
657    )]
658    pub content_alignment: Option<String>,
659    #[serde(flatten)]
660    /// Shared visual style (padding, background, frame, …).
661    pub style: ElementStyle,
662}
663
664/// Text label with optional semantic typography.
665#[derive(Debug, Clone, Serialize, Deserialize, Default)]
666#[cfg_attr(feature = "schema", derive(JsonSchema))]
667#[serde(rename_all = "camelCase")]
668pub struct TextElement {
669    /// String to display.
670    pub content: String,
671    /// Font size in points (overridden by `textStyle` when set).
672    #[serde(rename = "fontSize", default, skip_serializing_if = "Option::is_none")]
673    pub font_size: Option<f64>,
674    /// Font weight.
675    #[serde(
676        rename = "fontWeight",
677        default,
678        skip_serializing_if = "Option::is_none"
679    )]
680    pub font_weight: Option<FontWeight>,
681    /// Font design (default, monospaced, rounded, serif).
682    #[serde(
683        rename = "fontDesign",
684        default,
685        skip_serializing_if = "Option::is_none"
686    )]
687    pub font_design: Option<FontDesign>,
688    /// Semantic text style (uses Dynamic Type on Apple, sp on Android).
689    /// Overrides `fontSize` when set.
690    #[serde(rename = "textStyle", default, skip_serializing_if = "Option::is_none")]
691    pub text_style: Option<TextStyle>,
692    /// Text color.
693    #[serde(default, skip_serializing_if = "Option::is_none")]
694    pub color: Option<ColorValue>,
695    /// Text alignment within the line.
696    #[serde(default, skip_serializing_if = "Option::is_none")]
697    pub alignment: Option<TextAlignment>,
698    /// Maximum number of lines before truncation.
699    #[serde(rename = "lineLimit", default, skip_serializing_if = "Option::is_none")]
700    pub line_limit: Option<u32>,
701    /// Where text is cut when it does not fit. Default `tail`.
702    #[serde(
703        rename = "truncationMode",
704        default,
705        skip_serializing_if = "Option::is_none"
706    )]
707    pub truncation_mode: Option<TruncationMode>,
708    /// Allow shrinking the font down to this factor (`0.5` = half) before truncating.
709    #[serde(
710        rename = "minimumScaleFactor",
711        default,
712        skip_serializing_if = "Option::is_none"
713    )]
714    pub minimum_scale_factor: Option<f64>,
715    /// Line height in points.
716    #[serde(
717        rename = "lineHeight",
718        default,
719        skip_serializing_if = "Option::is_none"
720    )]
721    pub line_height: Option<f64>,
722    /// Extra space between characters in points.
723    #[serde(
724        rename = "letterSpacing",
725        default,
726        skip_serializing_if = "Option::is_none"
727    )]
728    pub letter_spacing: Option<f64>,
729    /// Underline the text.
730    #[serde(rename = "underline", default, skip_serializing_if = "Option::is_none")]
731    pub underline: Option<bool>,
732    /// Strike the text through.
733    #[serde(
734        rename = "strikethrough",
735        default,
736        skip_serializing_if = "Option::is_none"
737    )]
738    pub strikethrough: Option<bool>,
739    /// Italic.
740    #[serde(rename = "italic", default, skip_serializing_if = "Option::is_none")]
741    pub italic: Option<bool>,
742    /// Equal-width digits (counters, prices) without a monospaced font.
743    #[serde(
744        rename = "monospacedDigit",
745        default,
746        skip_serializing_if = "Option::is_none"
747    )]
748    pub monospaced_digit: Option<bool>,
749    #[serde(flatten)]
750    /// Shared visual style (padding, background, frame, …).
751    pub style: ElementStyle,
752}
753
754impl TextElement {
755    /// Set explicit point size (ignored when `text_style` is set).
756    pub fn font_size(mut self, size: f64) -> Self {
757        self.font_size = Some(size);
758        self
759    }
760
761    /// Set font weight.
762    pub fn font_weight(mut self, weight: FontWeight) -> Self {
763        self.font_weight = Some(weight);
764        self
765    }
766
767    /// Set text color.
768    pub fn color(mut self, color: impl Into<ColorValue>) -> Self {
769        self.color = Some(color.into());
770        self
771    }
772}
773
774/// Image from SF Symbol / drawable name, base64 data, or URL.
775#[derive(Debug, Clone, Serialize, Deserialize, Default)]
776#[cfg_attr(feature = "schema", derive(JsonSchema))]
777#[serde(rename_all = "camelCase")]
778pub struct ImageElement {
779    /// SF Symbol name (Apple) or Material / drawable name (Android).
780    #[serde(
781        rename = "systemName",
782        default,
783        skip_serializing_if = "Option::is_none"
784    )]
785    pub system_name: Option<String>,
786    /// Base64-encoded image data (with or without `data:image/...;base64,` prefix).
787    #[serde(default, skip_serializing_if = "Option::is_none")]
788    pub data: Option<String>,
789    /// Remote image URL (platform support varies — see capability matrix).
790    #[serde(default, skip_serializing_if = "Option::is_none")]
791    pub url: Option<String>,
792    /// Display size in points.
793    #[serde(default, skip_serializing_if = "Option::is_none")]
794    pub size: Option<f64>,
795    /// Tint color for template / symbol images.
796    #[serde(default, skip_serializing_if = "Option::is_none")]
797    pub color: Option<ColorValue>,
798    /// How the image fills its frame (`fit` or `fill`).
799    #[serde(
800        rename = "contentMode",
801        default,
802        skip_serializing_if = "Option::is_none"
803    )]
804    pub content_mode: Option<ContentMode>,
805    /// Symbol shown when `url` / `data` cannot be loaded.
806    #[serde(
807        rename = "fallbackSystemName",
808        default,
809        skip_serializing_if = "Option::is_none"
810    )]
811    pub fallback_system_name: Option<String>,
812    /// SF Symbol rendering mode (`hierarchical`, `palette`, `multicolor`). Apple; others draw monochrome.
813    #[serde(
814        rename = "symbolRendering",
815        default,
816        skip_serializing_if = "Option::is_none"
817    )]
818    pub symbol_rendering: Option<SymbolRendering>,
819    /// SF Symbol stroke weight. Apple only.
820    #[serde(
821        rename = "symbolWeight",
822        default,
823        skip_serializing_if = "Option::is_none"
824    )]
825    pub symbol_weight: Option<FontWeight>,
826    /// Colors for `symbolRendering: palette` (primary, secondary, tertiary).
827    #[serde(
828        rename = "paletteColors",
829        default,
830        skip_serializing_if = "Vec::is_empty"
831    )]
832    pub palette_colors: Vec<ColorValue>,
833    #[serde(flatten)]
834    /// Shared visual style (padding, background, frame, …).
835    pub style: ElementStyle,
836}
837
838/// Linear or circular progress indicator.
839#[derive(Debug, Clone, Serialize, Deserialize)]
840#[cfg_attr(feature = "schema", derive(JsonSchema))]
841#[serde(rename_all = "camelCase")]
842pub struct ProgressElement {
843    /// Current value. Clamped to `0..=total` by renderers.
844    pub value: f64,
845    /// Denominator for the ratio. Default `1.0`.
846    #[serde(default = "default_total")]
847    pub total: f64,
848    /// Caption above the bar. Always set on Android so hosts never show null.
849    #[serde(default, skip_serializing_if = "Option::is_none")]
850    pub label: Option<String>,
851    /// Accent / fill color for the completed portion.
852    #[serde(default, skip_serializing_if = "Option::is_none")]
853    pub tint: Option<ColorValue>,
854    /// Track / label color.
855    #[serde(default, skip_serializing_if = "Option::is_none")]
856    pub color: Option<ColorValue>,
857    /// `linear` (default) or `circular`.
858    #[serde(rename = "barStyle", default, skip_serializing_if = "Option::is_none")]
859    pub bar_style: Option<ProgressStyle>,
860    /// Unix ms: with `endAt`, the bar fills by itself between the two times (no refresh needed on Apple / desktop).
861    /// WidgetKit animates it live only for spans up to 10 minutes (a live bar keeps Notification
862    /// Center busy); a longer span shows the value at each timeline entry / reload.
863    #[serde(rename = "startAt", default, skip_serializing_if = "Option::is_none")]
864    pub start_at: Option<u64>,
865    /// Unix ms the time-driven bar reaches full (see `startAt`).
866    #[serde(rename = "endAt", default, skip_serializing_if = "Option::is_none")]
867    pub end_at: Option<u64>,
868    /// Color of the unfilled track.
869    #[serde(
870        rename = "trackColor",
871        default,
872        skip_serializing_if = "Option::is_none"
873    )]
874    pub track_color: Option<ColorValue>,
875    /// Bar height (linear) or ring width (circular) in points.
876    #[serde(rename = "thickness", default, skip_serializing_if = "Option::is_none")]
877    pub thickness: Option<f64>,
878    #[serde(flatten)]
879    /// Shared visual style (padding, background, frame, …).
880    pub style: ElementStyle,
881}
882
883impl Default for ProgressElement {
884    fn default() -> Self {
885        Self {
886            value: 0.0,
887            total: 1.0,
888            label: None,
889            tint: None,
890            color: None,
891            bar_style: None,
892            start_at: None,
893            end_at: None,
894            track_color: None,
895            thickness: None,
896            style: ElementStyle::default(),
897        }
898    }
899}
900
901/// Circular or capacity-style gauge.
902#[derive(Debug, Clone, Serialize, Deserialize, Default)]
903#[cfg_attr(feature = "schema", derive(JsonSchema))]
904#[serde(rename_all = "camelCase")]
905pub struct GaugeElement {
906    /// Current value within `[min, max]`.
907    pub value: f64,
908    /// Lower bound. Default `0`.
909    #[serde(default, skip_serializing_if = "Option::is_none")]
910    pub min: Option<f64>,
911    /// Upper bound. Default `1`.
912    #[serde(default, skip_serializing_if = "Option::is_none")]
913    pub max: Option<f64>,
914    /// Optional caption.
915    #[serde(default, skip_serializing_if = "Option::is_none")]
916    pub label: Option<String>,
917    /// Text shown for the current value (e.g. `"72%"`).
918    #[serde(
919        rename = "currentValueLabel",
920        default,
921        skip_serializing_if = "Option::is_none"
922    )]
923    pub current_value_label: Option<String>,
924    /// Accent color.
925    #[serde(default, skip_serializing_if = "Option::is_none")]
926    pub tint: Option<ColorValue>,
927    /// Secondary / track color.
928    #[serde(default, skip_serializing_if = "Option::is_none")]
929    pub color: Option<ColorValue>,
930    /// Visual style (e.g. `circular`).
931    #[serde(
932        rename = "gaugeStyle",
933        default,
934        skip_serializing_if = "Option::is_none"
935    )]
936    pub gauge_style: Option<GaugeStyle>,
937    /// Unix ms: with `endAt`, the gauge moves by itself between the two times.
938    #[serde(rename = "startAt", default, skip_serializing_if = "Option::is_none")]
939    pub start_at: Option<u64>,
940    /// Unix ms the time-driven gauge reaches `max`.
941    #[serde(rename = "endAt", default, skip_serializing_if = "Option::is_none")]
942    pub end_at: Option<u64>,
943    /// Label at the minimum end.
944    #[serde(rename = "minLabel", default, skip_serializing_if = "Option::is_none")]
945    pub min_label: Option<String>,
946    /// Label at the maximum end.
947    #[serde(rename = "maxLabel", default, skip_serializing_if = "Option::is_none")]
948    pub max_label: Option<String>,
949    #[serde(flatten)]
950    /// Shared visual style (padding, background, frame, …).
951    pub style: ElementStyle,
952}
953
954/// Tappable button that opens a URL or emits `widget-action`.
955#[derive(Debug, Clone, Serialize, Deserialize, Default)]
956#[cfg_attr(feature = "schema", derive(JsonSchema))]
957#[serde(rename_all = "camelCase")]
958pub struct ButtonElement {
959    /// Button label text.
960    pub label: String,
961    /// Deep link URL to open the app (used when no action is set).
962    #[serde(default, skip_serializing_if = "Option::is_none")]
963    pub url: Option<String>,
964    /// Action identifier — emits a `widget-action` Tauri event when tapped.
965    #[serde(default, skip_serializing_if = "Option::is_none")]
966    pub action: Option<String>,
967    /// Local state written on tap, in-process (no app needed): `{"done": "$toggle"}`.
968    /// `"$toggle"` flips a boolean (missing → `true`), `"$inc"` / `"$dec"` add / subtract 1
969    /// (missing → 0 first), `null` removes the key (the default shows again), any other
970    /// JSON is stored as is. With `action` too, the action is sent after the write.
971    #[serde(default, skip_serializing_if = "Option::is_none")]
972    #[cfg_attr(
973        feature = "schema",
974        schemars(with = "Option<serde_json::Map<String, serde_json::Value>>")
975    )]
976    pub set: Option<serde_json::Map<String, serde_json::Value>>,
977    /// Label text color.
978    #[serde(default, skip_serializing_if = "Option::is_none")]
979    pub color: Option<ColorValue>,
980    /// Button background color.
981    #[serde(
982        rename = "backgroundColor",
983        default,
984        skip_serializing_if = "Option::is_none"
985    )]
986    pub background_color: Option<ColorValue>,
987    /// Label font size in points.
988    #[serde(rename = "fontSize", default, skip_serializing_if = "Option::is_none")]
989    pub font_size: Option<f64>,
990    /// Label text alignment.
991    #[serde(
992        rename = "textAlignment",
993        default,
994        skip_serializing_if = "Option::is_none"
995    )]
996    pub text_alignment: Option<TextAlignment>,
997    /// Visual style (`bordered`, `prominent`, `plain`, `outline`). Unset keeps each
998    /// platform's classic button look; set it for the same look everywhere.
999    #[serde(rename = "variant", default, skip_serializing_if = "Option::is_none")]
1000    pub variant: Option<ButtonVariant>,
1001    /// Icon (SF Symbol name; Material / emoji elsewhere) before the label.
1002    #[serde(
1003        rename = "systemName",
1004        default,
1005        skip_serializing_if = "Option::is_none"
1006    )]
1007    pub system_name: Option<String>,
1008    /// Draw dimmed and ignore taps.
1009    #[serde(rename = "disabled", default, skip_serializing_if = "Option::is_none")]
1010    pub disabled: Option<bool>,
1011    #[serde(flatten)]
1012    /// Shared visual style (padding, background, frame, …).
1013    pub style: ElementStyle,
1014}
1015
1016/// On/off toggle control.
1017#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1018#[cfg_attr(feature = "schema", derive(JsonSchema))]
1019#[serde(rename_all = "camelCase")]
1020pub struct ToggleElement {
1021    /// Whether the toggle is on.
1022    #[serde(rename = "isOn")]
1023    pub is_on: bool,
1024    /// Optional label beside the control.
1025    #[serde(default, skip_serializing_if = "Option::is_none")]
1026    pub label: Option<String>,
1027    /// Accent color when on.
1028    #[serde(default, skip_serializing_if = "Option::is_none")]
1029    pub tint: Option<ColorValue>,
1030    /// Action identifier sent back to the app.
1031    #[serde(default, skip_serializing_if = "Option::is_none")]
1032    pub action: Option<String>,
1033    /// Local state written on tap, in-process (no app needed): `{"done": "$toggle"}`.
1034    /// `"$toggle"` flips a boolean (missing → `true`), `"$inc"` / `"$dec"` add / subtract 1
1035    /// (missing → 0 first), `null` removes the key (the default shows again), any other
1036    /// JSON is stored as is. With `action` too, the action is sent after the write.
1037    #[serde(default, skip_serializing_if = "Option::is_none")]
1038    #[cfg_attr(
1039        feature = "schema",
1040        schemars(with = "Option<serde_json::Map<String, serde_json::Value>>")
1041    )]
1042    pub set: Option<serde_json::Map<String, serde_json::Value>>,
1043    /// `switch` or `checkbox`. Unset keeps each platform's classic toggle (a glyph on
1044    /// Android); set it for the same control everywhere.
1045    #[serde(rename = "variant", default, skip_serializing_if = "Option::is_none")]
1046    pub variant: Option<ToggleVariant>,
1047    /// Color when off.
1048    #[serde(
1049        rename = "uncheckedTint",
1050        default,
1051        skip_serializing_if = "Option::is_none"
1052    )]
1053    pub unchecked_tint: Option<ColorValue>,
1054    #[serde(flatten)]
1055    /// Shared visual style (padding, background, frame, …).
1056    pub style: ElementStyle,
1057}
1058
1059/// Horizontal or vertical rule.
1060#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1061#[cfg_attr(feature = "schema", derive(JsonSchema))]
1062#[serde(rename_all = "camelCase")]
1063pub struct DividerElement {
1064    /// Line color.
1065    #[serde(default, skip_serializing_if = "Option::is_none")]
1066    pub color: Option<ColorValue>,
1067    /// Line thickness in points.
1068    #[serde(default, skip_serializing_if = "Option::is_none")]
1069    pub thickness: Option<f64>,
1070    #[serde(flatten)]
1071    /// Shared visual style (padding, background, frame, …).
1072    pub style: ElementStyle,
1073}
1074
1075/// Flexible empty space.
1076#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1077#[cfg_attr(feature = "schema", derive(JsonSchema))]
1078#[serde(rename_all = "camelCase")]
1079pub struct SpacerElement {
1080    /// Minimum length along the parent axis (points).
1081    #[serde(rename = "minLength", default, skip_serializing_if = "Option::is_none")]
1082    pub min_length: Option<f64>,
1083}
1084
1085/// Formatted date / relative time display.
1086#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1087#[cfg_attr(feature = "schema", derive(JsonSchema))]
1088#[serde(rename_all = "camelCase")]
1089pub struct DateElement {
1090    /// ISO 8601 date string.
1091    pub date: String,
1092    /// Display style (`time`, `date`, `relative`, `offset`, `timer`).
1093    #[serde(rename = "dateStyle", default, skip_serializing_if = "Option::is_none")]
1094    pub date_style: Option<DateStyle>,
1095    /// Font size in points.
1096    #[serde(rename = "fontSize", default, skip_serializing_if = "Option::is_none")]
1097    pub font_size: Option<f64>,
1098    /// Text color.
1099    #[serde(default, skip_serializing_if = "Option::is_none")]
1100    pub color: Option<ColorValue>,
1101    #[serde(flatten)]
1102    /// Shared visual style (padding, background, frame, …).
1103    pub style: ElementStyle,
1104}
1105
1106/// Bar, line, area, or pie chart.
1107#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1108#[cfg_attr(feature = "schema", derive(JsonSchema))]
1109#[serde(rename_all = "camelCase")]
1110pub struct ChartElement {
1111    /// Chart kind: `bar`, `line`, `area`, or `pie`.
1112    #[serde(rename = "chartType")]
1113    pub chart_type: ChartType,
1114    /// Data points (`label` + `value`, optional per-point `color`).
1115    #[serde(default, rename = "chartData", skip_serializing_if = "Vec::is_empty")]
1116    pub chart_data: Vec<ChartDataPoint>,
1117    /// Default series tint.
1118    #[serde(default, skip_serializing_if = "Option::is_none")]
1119    pub tint: Option<ColorValue>,
1120    /// Show the x axis labels (bar / line / area). Unset keeps each platform's classic
1121    /// chart (labels on Apple and desktop, none on Android); set it to match.
1122    #[serde(rename = "showXAxis", default, skip_serializing_if = "Option::is_none")]
1123    pub show_x_axis: Option<bool>,
1124    /// Show the y axis. Default `false`.
1125    #[serde(rename = "showYAxis", default, skip_serializing_if = "Option::is_none")]
1126    pub show_y_axis: Option<bool>,
1127    /// Show a legend of `series` (or pie slices). Default `false`.
1128    #[serde(
1129        rename = "showLegend",
1130        default,
1131        skip_serializing_if = "Option::is_none"
1132    )]
1133    pub show_legend: Option<bool>,
1134    /// Lower bound of the y scale (default: data minimum or 0).
1135    #[serde(rename = "yMin", default, skip_serializing_if = "Option::is_none")]
1136    pub y_min: Option<f64>,
1137    /// Upper bound of the y scale (default: data maximum).
1138    #[serde(rename = "yMax", default, skip_serializing_if = "Option::is_none")]
1139    pub y_max: Option<f64>,
1140    /// Line / area curve: `linear` (default), `monotone`, `step`.
1141    #[serde(
1142        rename = "interpolation",
1143        default,
1144        skip_serializing_if = "Option::is_none"
1145    )]
1146    pub interpolation: Option<ChartInterpolation>,
1147    /// Pie hole as a fraction of the radius (`0.6` = donut). Default `0`.
1148    #[serde(
1149        rename = "innerRadius",
1150        default,
1151        skip_serializing_if = "Option::is_none"
1152    )]
1153    pub inner_radius: Option<f64>,
1154    #[serde(flatten)]
1155    /// Shared visual style (padding, background, frame, …).
1156    pub style: ElementStyle,
1157}
1158
1159/// Collection list of rows (text, optional checked marker and action).
1160#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1161#[cfg_attr(feature = "schema", derive(JsonSchema))]
1162#[serde(rename_all = "camelCase")]
1163pub struct ListElement {
1164    /// Row items.
1165    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1166    pub items: Vec<ListItem>,
1167    /// Rich rows as nested elements (alternative to typed [`ListItem`]s).
1168    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1169    pub children: Vec<WidgetElement>,
1170    /// Space between rows (points).
1171    #[serde(default, skip_serializing_if = "Option::is_none")]
1172    pub spacing: Option<f64>,
1173    /// Row text font size.
1174    #[serde(rename = "fontSize", default, skip_serializing_if = "Option::is_none")]
1175    pub font_size: Option<f64>,
1176    /// Row text color.
1177    #[serde(default, skip_serializing_if = "Option::is_none")]
1178    pub color: Option<ColorValue>,
1179    #[serde(flatten)]
1180    /// Shared visual style (padding, background, frame, …).
1181    pub style: ElementStyle,
1182}
1183
1184/// Tappable wrapper — makes nested content clickable.
1185#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1186#[cfg_attr(feature = "schema", derive(JsonSchema))]
1187#[serde(rename_all = "camelCase")]
1188pub struct LinkElement {
1189    /// Nested content to wrap.
1190    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1191    pub children: Vec<WidgetElement>,
1192    /// Deep-link URL to open.
1193    #[serde(default, skip_serializing_if = "Option::is_none")]
1194    pub url: Option<String>,
1195    /// Action identifier — emits `widget-action` event.
1196    #[serde(default, skip_serializing_if = "Option::is_none")]
1197    pub action: Option<String>,
1198    /// Local state written on tap, in-process (no app needed): `{"done": "$toggle"}`.
1199    /// `"$toggle"` flips a boolean (missing → `true`), `"$inc"` / `"$dec"` add / subtract 1
1200    /// (missing → 0 first), `null` removes the key (the default shows again), any other
1201    /// JSON is stored as is. With `action` too, the action is sent after the write.
1202    #[serde(default, skip_serializing_if = "Option::is_none")]
1203    #[cfg_attr(
1204        feature = "schema",
1205        schemars(with = "Option<serde_json::Map<String, serde_json::Value>>")
1206    )]
1207    pub set: Option<serde_json::Map<String, serde_json::Value>>,
1208    #[serde(flatten)]
1209    /// Shared visual style (padding, background, frame, …).
1210    pub style: ElementStyle,
1211}
1212
1213/// Colored shape — circle, capsule, or rectangle.
1214#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1215#[cfg_attr(feature = "schema", derive(JsonSchema))]
1216#[serde(rename_all = "camelCase")]
1217pub struct ShapeElement {
1218    /// Shape kind.
1219    #[serde(rename = "shapeType")]
1220    pub shape_type: ShapeType,
1221    /// Fill color.
1222    #[serde(default, skip_serializing_if = "Option::is_none")]
1223    pub fill: Option<ColorValue>,
1224    /// Stroke color.
1225    #[serde(default, skip_serializing_if = "Option::is_none")]
1226    pub stroke: Option<ColorValue>,
1227    /// Stroke width in points.
1228    #[serde(
1229        rename = "strokeWidth",
1230        default,
1231        skip_serializing_if = "Option::is_none"
1232    )]
1233    pub stroke_width: Option<f64>,
1234    /// Bounding size in points.
1235    #[serde(default, skip_serializing_if = "Option::is_none")]
1236    pub size: Option<f64>,
1237    #[serde(flatten)]
1238    /// Shared visual style (padding, background, frame, …).
1239    pub style: ElementStyle,
1240}
1241
1242/// Live countdown/countup timer that updates without timeline refresh.
1243#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1244#[cfg_attr(feature = "schema", derive(JsonSchema))]
1245#[serde(rename_all = "camelCase")]
1246pub struct TimerElement {
1247    /// ISO 8601 target date.
1248    #[serde(rename = "targetDate")]
1249    pub target_date: String,
1250    /// Count direction. Default: `down`.
1251    #[serde(default, skip_serializing_if = "Option::is_none")]
1252    pub counting: Option<TimerCounting>,
1253    /// Font size in points.
1254    #[serde(rename = "fontSize", default, skip_serializing_if = "Option::is_none")]
1255    pub font_size: Option<f64>,
1256    /// Font weight.
1257    #[serde(
1258        rename = "fontWeight",
1259        default,
1260        skip_serializing_if = "Option::is_none"
1261    )]
1262    pub font_weight: Option<FontWeight>,
1263    /// Text color.
1264    #[serde(default, skip_serializing_if = "Option::is_none")]
1265    pub color: Option<ColorValue>,
1266    /// ISO-8601 start of a range timer (`startDate`…`targetDate`); counting up starts here.
1267    #[serde(rename = "startDate", default, skip_serializing_if = "Option::is_none")]
1268    pub start_date: Option<String>,
1269    /// Always show hours (`0:05:00`). Default: only when needed.
1270    #[serde(rename = "showHours", default, skip_serializing_if = "Option::is_none")]
1271    pub show_hours: Option<bool>,
1272    /// Text shown once a countdown reached `targetDate` (default: `0:00`).
1273    #[serde(
1274        rename = "completedText",
1275        default,
1276        skip_serializing_if = "Option::is_none"
1277    )]
1278    pub completed_text: Option<String>,
1279    #[serde(flatten)]
1280    /// Shared visual style (padding, background, frame, …).
1281    pub style: ElementStyle,
1282}
1283
1284/// Declarative canvas — draw arbitrary shapes via JSON commands.
1285#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1286#[cfg_attr(feature = "schema", derive(JsonSchema))]
1287#[serde(rename_all = "camelCase")]
1288pub struct CanvasElement {
1289    /// Canvas width in points.
1290    pub width: f64,
1291    /// Canvas height in points.
1292    pub height: f64,
1293    /// Draw commands (`circle`, `line`, `rect`, `arc`, `text`, `path`).
1294    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1295    pub elements: Vec<CanvasDrawCommand>,
1296    #[serde(flatten)]
1297    /// Shared visual style (padding, background, frame, …).
1298    pub style: ElementStyle,
1299}
1300
1301/// Convenience element combining an SF Symbol / icon with text.
1302#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1303#[cfg_attr(feature = "schema", derive(JsonSchema))]
1304#[serde(rename_all = "camelCase")]
1305pub struct LabelElement {
1306    /// Label text.
1307    pub text: String,
1308    /// SF Symbol or platform icon name.
1309    #[serde(rename = "systemName")]
1310    pub system_name: String,
1311    /// Icon tint color.
1312    #[serde(rename = "iconColor", default, skip_serializing_if = "Option::is_none")]
1313    pub icon_color: Option<ColorValue>,
1314    /// Text font size.
1315    #[serde(rename = "fontSize", default, skip_serializing_if = "Option::is_none")]
1316    pub font_size: Option<f64>,
1317    /// Text font weight.
1318    #[serde(
1319        rename = "fontWeight",
1320        default,
1321        skip_serializing_if = "Option::is_none"
1322    )]
1323    pub font_weight: Option<FontWeight>,
1324    /// Text color.
1325    #[serde(default, skip_serializing_if = "Option::is_none")]
1326    pub color: Option<ColorValue>,
1327    /// Space between icon and text.
1328    #[serde(default, skip_serializing_if = "Option::is_none")]
1329    pub spacing: Option<f64>,
1330    #[serde(flatten)]
1331    /// Shared visual style (padding, background, frame, …).
1332    pub style: ElementStyle,
1333}
1334
1335impl From<VStackElement> for WidgetElement {
1336    fn from(value: VStackElement) -> Self {
1337        WidgetElement::VStack(value)
1338    }
1339}
1340
1341impl From<HStackElement> for WidgetElement {
1342    fn from(value: HStackElement) -> Self {
1343        WidgetElement::HStack(value)
1344    }
1345}
1346
1347impl From<ZStackElement> for WidgetElement {
1348    fn from(value: ZStackElement) -> Self {
1349        WidgetElement::ZStack(value)
1350    }
1351}
1352
1353impl From<GridElement> for WidgetElement {
1354    fn from(value: GridElement) -> Self {
1355        WidgetElement::Grid(value)
1356    }
1357}
1358
1359impl From<ContainerElement> for WidgetElement {
1360    fn from(value: ContainerElement) -> Self {
1361        WidgetElement::Container(value)
1362    }
1363}
1364
1365impl From<TextElement> for WidgetElement {
1366    fn from(value: TextElement) -> Self {
1367        WidgetElement::Text(value)
1368    }
1369}
1370
1371impl From<ImageElement> for WidgetElement {
1372    fn from(value: ImageElement) -> Self {
1373        WidgetElement::Image(value)
1374    }
1375}
1376
1377impl From<ProgressElement> for WidgetElement {
1378    fn from(value: ProgressElement) -> Self {
1379        WidgetElement::Progress(value)
1380    }
1381}
1382
1383impl From<GaugeElement> for WidgetElement {
1384    fn from(value: GaugeElement) -> Self {
1385        WidgetElement::Gauge(value)
1386    }
1387}
1388
1389impl From<ButtonElement> for WidgetElement {
1390    fn from(value: ButtonElement) -> Self {
1391        WidgetElement::Button(value)
1392    }
1393}
1394
1395impl From<ToggleElement> for WidgetElement {
1396    fn from(value: ToggleElement) -> Self {
1397        WidgetElement::Toggle(value)
1398    }
1399}
1400
1401impl From<DividerElement> for WidgetElement {
1402    fn from(value: DividerElement) -> Self {
1403        WidgetElement::Divider(value)
1404    }
1405}
1406
1407impl From<SpacerElement> for WidgetElement {
1408    fn from(value: SpacerElement) -> Self {
1409        WidgetElement::Spacer(value)
1410    }
1411}
1412
1413impl From<DateElement> for WidgetElement {
1414    fn from(value: DateElement) -> Self {
1415        WidgetElement::Date(value)
1416    }
1417}
1418
1419impl From<ChartElement> for WidgetElement {
1420    fn from(value: ChartElement) -> Self {
1421        WidgetElement::Chart(value)
1422    }
1423}
1424
1425impl From<ListElement> for WidgetElement {
1426    fn from(value: ListElement) -> Self {
1427        WidgetElement::List(value)
1428    }
1429}
1430
1431impl From<LinkElement> for WidgetElement {
1432    fn from(value: LinkElement) -> Self {
1433        WidgetElement::Link(value)
1434    }
1435}
1436
1437impl From<ShapeElement> for WidgetElement {
1438    fn from(value: ShapeElement) -> Self {
1439        WidgetElement::Shape(value)
1440    }
1441}
1442
1443impl From<TimerElement> for WidgetElement {
1444    fn from(value: TimerElement) -> Self {
1445        WidgetElement::Timer(value)
1446    }
1447}
1448
1449impl From<CanvasElement> for WidgetElement {
1450    fn from(value: CanvasElement) -> Self {
1451        WidgetElement::Canvas(value)
1452    }
1453}
1454
1455impl From<LabelElement> for WidgetElement {
1456    fn from(value: LabelElement) -> Self {
1457        WidgetElement::Label(value)
1458    }
1459}
1460
1461/// Build a [`TextElement`] with the given content.
1462pub fn text(content: impl Into<String>) -> TextElement {
1463    TextElement {
1464        content: content.into(),
1465        ..Default::default()
1466    }
1467}
1468
1469/// Build a [`VStackElement`] with the given children.
1470pub fn vstack(children: Vec<WidgetElement>) -> VStackElement {
1471    VStackElement {
1472        children,
1473        ..Default::default()
1474    }
1475}
1476
1477/// Build an [`HStackElement`] with the given children.
1478pub fn hstack(children: Vec<WidgetElement>) -> HStackElement {
1479    HStackElement {
1480        children,
1481        ..Default::default()
1482    }
1483}
1484
1485fn default_columns() -> u32 {
1486    2
1487}
1488fn default_total() -> f64 {
1489    1.0
1490}
1491
1492#[cfg(test)]
1493mod tests {
1494    use super::*;
1495    use std::path::PathBuf;
1496
1497    /// Build → serialize → parse back: builders nest layouts, ergonomic setters land as
1498    /// camelCase fields, `None` is omitted (never `null`), parsing fills defaults (version,
1499    /// grid columns, window flags) and `layouts_mut` visits entry layouts too.
1500    #[test]
1501    fn build_serialize_parse_and_walk_layouts() {
1502        // 1. Builders.
1503        let c = WidgetConfig::small(text("s"))
1504            .with_medium(text("m"))
1505            .with_large(hstack(vec![text("l").into()]));
1506        assert!(c.small.is_some() && c.medium.is_some());
1507        match c.large {
1508            Some(WidgetElement::HStack(h)) => assert_eq!(h.children.len(), 1),
1509            other => panic!("{other:?}"),
1510        }
1511        let cfg = WidgetConfig::small(vstack(vec![text("72°")
1512            .font_size(36.0)
1513            .font_weight(FontWeight::Bold)
1514            .color("#fff")
1515            .into()]));
1516        let v = serde_json::to_value(&cfg).unwrap();
1517        assert_eq!(v["small"]["type"], "vstack");
1518        let child = &v["small"]["children"][0];
1519        assert_eq!(child["type"], "text");
1520        assert_eq!(child["content"], "72°");
1521        assert_eq!(child["fontSize"], 36.0);
1522        assert_eq!(child["fontWeight"], "bold");
1523        assert_eq!(child["color"], "#fff");
1524
1525        // 2. Unset options are omitted and stay unset after the round trip.
1526        let cfg = WidgetConfig::small(TextElement {
1527            content: "hi".into(),
1528            font_size: Some(12.0),
1529            ..Default::default()
1530        });
1531        let json = serde_json::to_string(&cfg).unwrap();
1532        assert!(!json.contains("null"), "JSON must not contain null: {json}");
1533        assert!(!json.contains("\"medium\""));
1534        assert!(!json.contains("\"fontWeight\""));
1535        let back: WidgetConfig = serde_json::from_str(&json).unwrap();
1536        assert!(back.medium.is_none());
1537        match back.small.unwrap() {
1538            WidgetElement::Text(TextElement {
1539                font_weight,
1540                content,
1541                ..
1542            }) => {
1543                assert!(font_weight.is_none());
1544                assert_eq!(content, "hi");
1545            }
1546            other => panic!("expected text, got {other:?}"),
1547        }
1548
1549        // 3. Parse defaults.
1550        let parsed: WidgetConfig = serde_json::from_str(r#"{"small":{"type":"grid"}}"#).unwrap();
1551        assert_eq!(parsed.version, 1);
1552        match parsed.small {
1553            Some(WidgetElement::Grid(g)) => assert_eq!(g.columns, 2),
1554            other => panic!("{other:?}"),
1555        }
1556        let w: WidgetWindowConfig =
1557            serde_json::from_str(r#"{"label":"a","width":1,"height":1}"#).unwrap();
1558        assert!(w.skip_taskbar);
1559        assert!(!w.always_on_top);
1560
1561        // 4. Layout walk includes timeline entries.
1562        let mut c: WidgetConfig = serde_json::from_value(serde_json::json!({
1563            "small": {"type": "spacer"},
1564            "entries": [{"at": 1, "medium": {"type": "spacer"}, "large": {"type": "spacer"}}]
1565        }))
1566        .unwrap();
1567        assert_eq!(c.layouts_mut().count(), 3);
1568    }
1569
1570    fn walk_json_files(dir: &std::path::Path, out: &mut Vec<PathBuf>) {
1571        let Ok(entries) = std::fs::read_dir(dir) else {
1572            return;
1573        };
1574        for entry in entries.flatten() {
1575            let path = entry.path();
1576            if path.is_dir() {
1577                walk_json_files(&path, out);
1578            } else if path.extension().and_then(|e| e.to_str()) == Some("json") {
1579                out.push(path);
1580            }
1581        }
1582    }
1583
1584    /// Round-trip every fixture through `WidgetConfig` — catches wire drift after
1585    /// struct-variant refactors (tag + flattened fields must stay identical).
1586    #[test]
1587    fn wire_format_unchanged() {
1588        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
1589        let mut files = Vec::new();
1590        walk_json_files(&root, &mut files);
1591        assert!(
1592            !files.is_empty(),
1593            "expected fixtures under {}",
1594            root.display()
1595        );
1596        for path in files {
1597            let raw = std::fs::read_to_string(&path).unwrap();
1598            let cfg: WidgetConfig = serde_json::from_str(&raw).unwrap_or_else(|e| {
1599                panic!("deserialize {}: {e}", path.display());
1600            });
1601            let encoded = serde_json::to_value(&cfg).unwrap();
1602            let again: WidgetConfig = serde_json::from_value(encoded.clone()).unwrap();
1603            assert_eq!(
1604                encoded,
1605                serde_json::to_value(&again).unwrap(),
1606                "wire drift in {}",
1607                path.display()
1608            );
1609            let out = serde_json::to_string(&cfg).unwrap();
1610            assert!(
1611                !out.contains("null"),
1612                "{} serialized with null: {out}",
1613                path.display()
1614            );
1615        }
1616    }
1617}