Skip to main content

mobiler_ui/
lib.rs

1//! Mobiler's fixed UI wire ABI.
2//!
3//! These types are the **stable contract** between any Mobiler app's Rust core
4//! and the native shell. Because they never change per app, a single shell is
5//! built once and renders *any* Mobiler app — the shell only ever knows these
6//! types, never an app's domain events or widgets.
7//!
8//! - The core emits a [`Widget`] tree (the `ViewModel`).
9//! - The shell sends back an [`Action`] (the `Event`).
10//! - App domain events ride inside actions as opaque [`ActionToken`]s that the
11//!   shell round-trips without interpreting.
12//!
13//! Style is expressed as **intent tokens** (e.g. [`TextStyle`], [`Tone`]); the
14//! shell maps each to a concrete look (font, color, dp), so dark mode and theme
15//! come for free on the native side.
16
17use facet::Facet;
18use serde::{Deserialize, Serialize};
19
20/// An opaque, serialized app event (e.g. JSON of the app's domain action).
21pub type ActionToken = String;
22
23/// A value produced by an input widget at runtime.
24#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
25#[repr(C)]
26pub enum InputValue {
27    Text(String),
28    Bool(bool),
29    Int(i64),
30}
31
32/// What the shell sends back to the core. **Fixed across all apps.**
33#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
34#[repr(C)]
35pub enum Action {
36    /// An action widget (button/etc.) fired; `token` is the opaque app event.
37    Fired { token: ActionToken },
38    /// A value-carrying input changed; `id` names the widget.
39    Input { id: String, value: InputValue },
40    /// Persisted state handed back to the core on startup (empty string if none).
41    Restore { data: String },
42    /// Fired once on startup (after `Restore`) so the app can kick off initial
43    /// effects (e.g. fetching data).
44    Start,
45}
46
47// ---------------------------- style tokens ----------------------------
48
49#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
50#[repr(C)]
51pub enum TextStyle { Body, Title, Subtitle, Caption, Emphasis }
52
53#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
54#[repr(C)]
55pub enum ButtonStyle { Filled, Outlined, Text }
56
57#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
58#[repr(C)]
59pub enum CardStyle { Elevated, Outlined, Filled, Brand }
60
61/// Accessibility role for [`Widget::A11y`] — the control type announced to a screen reader. Maps
62/// best-effort per platform: iOS accessibility traits, Android semantics role / `heading()`, web ARIA role.
63#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
64#[repr(C)]
65pub enum A11yRole { Button, Link, Image, Header, Adjustable }
66
67/// What a [`Widget::TextField`] accepts — selects the on-screen keyboard,
68/// secure (masked) entry, and single- vs multi-line layout in one axis.
69///
70/// `Text` is the plain default. `Secure` masks input (passwords). `Email`,
71/// `Number` (integer), `Decimal`, `Phone`, and `Url` pick the matching native
72/// keyboard / input mode without masking. `Multiline` is a growable multi-row
73/// text area (plain keyboard).
74#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
75#[repr(C)]
76pub enum FieldKind { Text, Secure, Email, Number, Decimal, Phone, Url, Multiline }
77
78/// Semantic status color (distinct from brand/identity color).
79#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
80#[repr(C)]
81pub enum Tone { Neutral, Success, Warning, Danger, Info }
82
83/// How a `Chart` draws its series.
84///
85/// **Cartesian** styles plot every series over the shared `labels` x-axis:
86/// `Bar`/`Line` (grouped bars / one polyline per series), `StackedBar` (series stack to a total
87/// per x-slot), `StackedBar100` (each x-slot fills to 100% — series as proportions).
88///
89/// **Circular** styles ignore the x-axis and the `axis` flag: `Pie`/`Donut` turn **each series**
90/// into one wedge sized by its magnitude (`Donut` leaves a center hole); `Rings` draws concentric
91/// progress arcs (Apple-Watch fitness style), one per series, swept by `sum(values) / goal`;
92/// `Gauge` draws a single arc for the first series' `value / goal` with the number in the center.
93#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
94#[repr(C)]
95pub enum ChartStyle { Bar, Line, StackedBar, StackedBar100, Pie, Donut, Rings, Gauge }
96
97/// One named data series in a [`Widget::Chart`]. Cartesian styles plot `values` across the chart's
98/// x-axis `labels`; circular styles (pie/donut/rings/gauge) collapse the series to a single
99/// magnitude (`values` summed). `color` overrides the auto-assigned palette slot; `goal` is the
100/// denominator for `Rings`/`Gauge` progress (ignored by the other styles).
101#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq)]
102#[repr(C)]
103pub struct ChartSeries {
104    pub name: String,
105    pub values: Vec<f32>,
106    pub color: Option<Rgb>,
107    pub goal: Option<f32>,
108}
109
110impl ChartSeries {
111    /// A named series carrying `values`. Color falls back to the chart palette; no goal.
112    #[must_use]
113    pub fn new(name: impl Into<String>, values: Vec<f32>) -> Self {
114        Self { name: name.into(), values, color: None, goal: None }
115    }
116    /// Override the auto-assigned palette color for this series.
117    #[must_use]
118    pub fn with_color(mut self, color: Rgb) -> Self {
119        self.color = Some(color);
120        self
121    }
122    /// Set the denominator for `Rings`/`Gauge` progress (`sum(values) / goal`). Ignored by
123    /// cartesian and pie/donut styles.
124    #[must_use]
125    pub fn with_goal(mut self, goal: f32) -> Self {
126        self.goal = Some(goal);
127        self
128    }
129}
130
131// ----------------------------- region chart -----------------------------
132//
133// A [`Widget::RegionChart`] is a variable-width stacked-region ("Marimekko" / coverage-gap)
134// chart: arbitrary colored rectangles placed in a 2-D `[0, x_max] × [0, y_max]` plane, each with
135// an in-cell label, plus horizontal reference lines, an irregular x-axis, an optional right-side
136// bracket annotation, and a legend. The app computes the geometry; the shells map domain→pixels.
137
138/// One rectangle in a [`Widget::RegionChart`], spanning `[x0, x1]` horizontally and `[y0, y1]`
139/// vertically in the chart's domain. `label` is centered inside (empty = none); `vertical` rotates
140/// it 90° for narrow columns. `color` overrides the auto-assigned palette slot.
141#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq)]
142#[repr(C)]
143pub struct ChartRegion {
144    pub x0: f32,
145    pub x1: f32,
146    pub y0: f32,
147    pub y1: f32,
148    pub color: Option<Rgb>,
149    pub label: String,
150    pub vertical: bool,
151}
152
153/// A horizontal reference line across a [`Widget::RegionChart`] at `value`, with a right-edge
154/// `label` chip. `dashed` draws it dashed (e.g. a "max insured" ceiling) vs solid (a target).
155#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq)]
156#[repr(C)]
157pub struct ChartRefLine {
158    pub value: f32,
159    pub label: String,
160    pub dashed: bool,
161}
162
163/// A right-side bracket annotation spanning `[y0, y1]` with a `label` note (e.g. a ceiling band).
164#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq)]
165#[repr(C)]
166pub struct ChartBracket {
167    pub y0: f32,
168    pub y1: f32,
169    pub label: String,
170    /// Show an ⓘ info marker above the label (e.g. a "Ceiling max …" note). `label` may contain
171    /// `\n` for multiple lines.
172    pub info: bool,
173}
174
175/// An x-axis tick on a [`Widget::RegionChart`] at domain position `at`, labelled `label`. Ticks
176/// are irregular (the app places them), so shells position them by fraction, not even spacing.
177#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq)]
178#[repr(C)]
179pub struct ChartTick {
180    pub at: f32,
181    pub label: String,
182}
183
184/// One legend entry (swatch + name) for a [`Widget::RegionChart`].
185#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq)]
186#[repr(C)]
187pub struct ChartLegendItem {
188    pub label: String,
189    pub color: Rgb,
190}
191
192impl ChartRegion {
193    /// A region spanning `[x0,x1] × [y0,y1]` with a centered `label` (palette color, horizontal).
194    #[must_use]
195    pub fn new(x0: f32, x1: f32, y0: f32, y1: f32, label: impl Into<String>) -> Self {
196        Self { x0, x1, y0, y1, color: None, label: label.into(), vertical: false }
197    }
198    /// Override the fill color.
199    #[must_use]
200    pub fn with_color(mut self, color: Rgb) -> Self {
201        self.color = Some(color);
202        self
203    }
204    /// Render the label rotated 90° (for tall, narrow regions).
205    #[must_use]
206    pub fn vertical(mut self) -> Self {
207        self.vertical = true;
208        self
209    }
210}
211
212impl ChartRefLine {
213    /// A solid target line at `value` with a right-edge chip.
214    #[must_use]
215    pub fn target(value: f32, label: impl Into<String>) -> Self {
216        Self { value, label: label.into(), dashed: false }
217    }
218    /// A dashed "max"/ceiling line at `value`.
219    #[must_use]
220    pub fn max(value: f32, label: impl Into<String>) -> Self {
221        Self { value, label: label.into(), dashed: true }
222    }
223}
224
225impl ChartTick {
226    #[must_use]
227    pub fn new(at: f32, label: impl Into<String>) -> Self {
228        Self { at, label: label.into() }
229    }
230}
231
232impl ChartLegendItem {
233    #[must_use]
234    pub fn new(label: impl Into<String>, color: Rgb) -> Self {
235        Self { label: label.into(), color }
236    }
237}
238
239impl ChartBracket {
240    #[must_use]
241    pub fn new(y0: f32, y1: f32, label: impl Into<String>) -> Self {
242        Self { y0, y1, label: label.into(), info: false }
243    }
244    /// Show an ⓘ info marker above the label.
245    #[must_use]
246    pub fn with_info(mut self) -> Self {
247        self.info = true;
248        self
249    }
250}
251
252#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
253#[repr(C)]
254pub enum Spacing { Xs, Sm, Md, Lg, Xl }
255
256/// A finite icon set (maps to Material icons / SF Symbols / web glyphs per shell).
257/// Grouped: editing, navigation/chrome, content, and domain icons.
258#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
259#[repr(C)]
260pub enum Icon {
261    // editing / status
262    Delete, Add, Edit, Close, Settings, Check, Star, Info,
263    // navigation / chrome
264    Home, Search, Menu, Filter, Back, Forward, Down, Bell, Cart, Share, Heart, HeartFilled,
265    // people / contact
266    Person, People, Phone, Mail, Calendar, Clock, MapPin,
267    // content / media
268    Camera, Photo, Play,
269    // domain
270    Scissors,
271}
272
273#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
274#[repr(C)]
275pub enum ImageShape { Square, Rounded, Circle }
276
277#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
278#[repr(C)]
279pub enum ImageRatio { Wide, Square, Tall }
280
281#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
282#[repr(C)]
283pub enum BoxAlign { TopStart, TopEnd, Center, BottomStart, BottomCenter, BottomEnd }
284
285/// Project-identity colors (distinct from semantic `Tone`). Concrete RGB decided
286/// in the render layer.
287#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
288#[repr(C)]
289pub enum ProjectColor { Indigo, Teal, Coral, Amber, Lime, Pink }
290
291// ------------------------------- theme -------------------------------
292
293/// A 24-bit RGB color. Used for a theme's brand/seed color — the one place an app
294/// supplies an arbitrary color (everything else is intent tokens).
295#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
296#[repr(C)]
297pub struct Rgb {
298    pub r: u8,
299    pub g: u8,
300    pub b: u8,
301}
302
303impl Rgb {
304    pub const fn new(r: u8, g: u8, b: u8) -> Self {
305        Self { r, g, b }
306    }
307}
308
309/// Global corner-radius scale. `Medium` ≈ the current (un-themed) look.
310#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
311#[repr(C)]
312pub enum Corner { None, Small, Medium, Large }
313
314/// Global spacing scale. `Comfortable` ≈ the current (un-themed) spacing.
315#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
316#[repr(C)]
317pub enum Density { Compact, Comfortable }
318
319/// A finite, cross-platform font family (maps to each platform's nearest system
320/// font design — no bundled font files). `System` ≈ the current look.
321#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
322#[repr(C)]
323pub enum FontFamily { System, Rounded, Serif, Monospace }
324
325/// App branding as data — the visual twin of `dark_mode`. Set on a [`Widget::Scaffold`]
326/// (`theme: None` = the framework defaults, i.e. no visual change). The shell maps these
327/// to its native theming: `seed` → the brand/primary color (Android M3 scheme / iOS tint /
328/// web `--primary`), plus a global corner, spacing, and font choice.
329#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
330#[repr(C)]
331pub struct Theme {
332    pub seed: Rgb,
333    /// Optional secondary brand color. `None` ⇒ derived from `seed`. Used for the
334    /// gradient on `CardStyle::Brand` (seed → accent) and as a secondary accent.
335    pub accent: Option<Rgb>,
336    pub corner: Corner,
337    pub density: Density,
338    pub font: FontFamily,
339}
340
341/// `Theme::default()` matches the framework's un-themed look as closely as a theme can
342/// (medium corners, comfortable spacing, system font) with a neutral indigo seed — so an
343/// app can override just the bits it cares about: `Theme { seed: brand, ..Default::default() }`.
344impl Default for Theme {
345    fn default() -> Self {
346        Theme {
347            seed: Rgb::new(0x5C, 0x6B, 0xC0), // indigo — matches the legacy default accent
348            accent: None,
349            corner: Corner::Medium,
350            density: Density::Comfortable,
351            font: FontFamily::System,
352        }
353    }
354}
355
356/// A bottom-navigation tab. `selected` marks the active one; tapping sends
357/// `on_select`. `icon` (optional) renders above the label for an icon tab bar.
358#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
359#[repr(C)]
360pub struct Tab {
361    pub label: String,
362    pub selected: bool,
363    pub on_select: ActionToken,
364    /// Optional leading icon (icon tab bar). `None` = label-only (the original look).
365    pub icon: Option<Icon>,
366}
367
368/// A floating action button anchored over the scaffold body (the raised primary action).
369#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
370#[repr(C)]
371pub struct Fab {
372    pub icon: Icon,
373    pub on_press: ActionToken,
374}
375
376/// One option in a [`Widget::Segmented`] control (mirrors [`Tab`]). `selected` marks the
377/// active segment; tapping sends `on_select`.
378#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
379#[repr(C)]
380pub struct Segment {
381    pub label: String,
382    pub selected: bool,
383    pub on_select: ActionToken,
384}
385
386/// A modal bottom sheet anchored over the scaffold body (a scrim behind, a panel rising from
387/// the bottom). Present (`Some`) ⇒ open; tapping the scrim/handle sends `on_dismiss`.
388#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
389#[repr(C)]
390pub struct Sheet {
391    pub title: String,
392    pub child: Box<Widget>,
393    pub on_dismiss: ActionToken,
394}
395
396/// One revealed action in a `SwipeAction` row (swipe to reveal, tap to fire).
397#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
398#[repr(C)]
399pub struct SwipeButton {
400    pub label: String,
401    pub tone: Tone,
402    pub on_tap: ActionToken,
403}
404
405/// One subtitle/caption track for a [`Widget::Video`]. `url` points at a WebVTT (`.vtt`) file,
406/// `language` is a BCP-47 tag (e.g. `"en"`), `label` is the human-readable menu entry, and
407/// `default_on` selects it by default. Sidecar tracks work on web (`<track>`) and Android
408/// (Media3 subtitle configuration); on iOS only captions already embedded in an HLS manifest are
409/// selectable (AVPlayer can't attach a sidecar VTT to an MP4 — a documented v1 gap).
410#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq)]
411#[repr(C)]
412pub struct Caption {
413    pub url: String,
414    pub label: String,
415    pub language: String,
416    pub default_on: bool,
417}
418
419/// A pin on a [`Widget::Map`]. `id` is echoed back when the marker is tapped
420/// (`Action::Input { id: "{map_id}.marker", value: Text(marker.id) }`).
421#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq)]
422#[repr(C)]
423pub struct MapMarker {
424    pub id: String,
425    pub lat: f64,
426    pub lng: f64,
427    pub title: Option<String>,
428}
429
430// ------------------------------- widgets -------------------------------
431
432/// The app-agnostic widget tree the shell renders. **Fixed across all apps.**
433#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
434#[repr(C)]
435pub enum Widget {
436    // Content
437    Text { content: String, style: TextStyle },
438    Image { source: String, shape: ImageShape, ratio: ImageRatio },
439    Badge { label: String, tone: Tone },
440    /// A circular avatar image with an optional colored status dot.
441    Avatar { source: String, status: Option<Tone> },
442    /// An in-app PDF viewer showing the document at `url` (a remote https URL or a local
443    /// file URI). Each shell uses its native renderer — PDFKit on iOS, a paged `PdfRenderer`
444    /// on Android, an `<iframe>` on web — so the app only supplies the URL (e.g. a
445    /// backend-generated report). Fills its width; give it room (place in a sized container).
446    PdfView { url: String },
447    /// An in-app native video player for the stream/file at `url` (MP4 everywhere; HLS `.m3u8` on
448    /// iOS/Android natively + Safari on web; or a local file URI). Native player per shell — AVPlayer
449    /// (iOS), Media3/ExoPlayer (Android), a `<video>` element (web). **Controllable:** `playing` drives
450    /// play/pause (app-owned, like a `Toggle`); set `seek_to_ms` to jump (the shell seeks when the value
451    /// CHANGES; `-1` = no seek). The shell reports the current position ~once/second via
452    /// `Action::Input { id, value: Int(position_ms) }` (handle it in [`MobilerApp::input`]), and fires
453    /// `on_ended` when the clip finishes. `controls` shows the native transport bar; `looping` restarts
454    /// on end; `muted` starts muted (needed for reliable autoplay). Fills its width; give it room.
455    ///
456    /// v2 fields: `poster` shows a thumbnail image before the first play; `start_at_ms` resumes at an
457    /// offset (applied once on load, `-1` = start). `captions` adds subtitle tracks (see [`Caption`]).
458    /// `rate` sets playback speed (`1.0` = normal) and `volume` the level (`0.0`–`1.0`). For a playlist,
459    /// set `urls` (non-empty takes precedence over `url`) with `start_index`; the shell auto-advances
460    /// and reports the current track ~as it changes via `Action::Input { id: "{id}.index", … }`, and
461    /// `seek_index` jumps to a track when it CHANGES (`-1` = none). The shell also reports
462    /// `"{id}.duration"`, `"{id}.state"` (0 idle / 1 buffering / 2 ready-paused / 3 playing / 4 ended)
463    /// and `"{id}.buffered"` via the same `Input` path (handle them in [`MobilerApp::input`]). Set
464    /// `allow_pip` to enable Picture-in-Picture (the shell adds a PiP affordance).
465    Video {
466        url: String,
467        id: String,
468        playing: bool,
469        seek_to_ms: i64,
470        controls: bool,
471        looping: bool,
472        muted: bool,
473        on_ended: Option<ActionToken>,
474        poster: Option<String>,
475        start_at_ms: i64,
476        captions: Vec<Caption>,
477        rate: f32,
478        volume: f32,
479        urls: Vec<String>,
480        start_index: i64,
481        seek_index: i64,
482        allow_pip: bool,
483    },
484    /// Displays the web page / embedded player at `url` in a native web view — `WKWebView` on iOS,
485    /// `android.webkit.WebView` on Android, an `<iframe>` on web. General-purpose: docs, dashboards,
486    /// or a hosted player embed (e.g. a Bunny.net / YouTube embed URL, which brings its own
487    /// captions/quality/thumbnails). JavaScript and inline media autoplay are enabled so hosted
488    /// players work. This is NOT the default way to play video — use [`Widget::Video`] for a
489    /// controllable native player. Fills its width; give it room (place in a sized container).
490    WebView { url: String },
491    /// An interactive map (a "live native view" like [`Widget::Video`]): iOS MapKit, Android MapLibre
492    /// Native, web MapLibre-GL — no API key. The app drives the camera (`center_lat`/`center_lng`/`zoom`)
493    /// and `markers`; the user pans/zooms when `interactive`. Taps report back via `Action::Input`:
494    /// a map tap → `{ id: "{id}.tap", value: Text("lat,lng") }`, a marker tap → `{ id: "{id}.marker",
495    /// value: Text(marker.id) }` (handle in [`MobilerApp::input`] by id-suffix). `style_url` selects the
496    /// MapLibre vector style on Android/web (None → a free default); iOS MapKit uses Apple Maps and
497    /// ignores it. Fills its width; give it a height (place in a sized container).
498    Map {
499        id: String,
500        center_lat: f64,
501        center_lng: f64,
502        zoom: f64,
503        markers: Vec<MapMarker>,
504        style_url: Option<String>,
505        interactive: bool,
506    },
507    /// A star rating. `value` is in tenths (e.g. `48` = 4.8 of `max` stars). When `on_rate`
508    /// is set (one token per star), the stars are tappable — star *i* fires `on_rate[i]`.
509    Rating { value: u32, max: u8, on_rate: Option<Vec<ActionToken>> },
510    /// Small non-interactive colored dot — a project/identity hint.
511    ColorDot { color: ProjectColor },
512    Divider,
513    /// Progress indicator: `value` 0.0–1.0 for a determinate bar, `None` for an indeterminate spinner.
514    Progress { value: Option<f32> },
515    /// Shimmer placeholder shown while content loads.
516    Skeleton,
517    /// A data chart drawing one or more named `series` in the given `style` (see [`ChartStyle`]).
518    /// `labels` (optional) annotate the x-axis for cartesian styles. `axis` shows y gridlines +
519    /// tick values (cartesian only); `legend` shows a series swatch+name row. Non-interactive.
520    Chart { series: Vec<ChartSeries>, labels: Vec<String>, style: ChartStyle, axis: bool, legend: bool },
521    /// A variable-width stacked-region ("Marimekko" / coverage-gap) chart: `regions` are arbitrary
522    /// colored rectangles in the `[0, x_max] × [0, y_max]` plane (each with an in-cell label),
523    /// `ticks` annotate the irregular x-axis, `ref_lines` are horizontal target/max lines with
524    /// right-edge chips, `bracket` is an optional right-side range annotation, and `legend` names
525    /// the colors. The app supplies all geometry; shells map domain→pixels. Non-interactive.
526    RegionChart {
527        regions: Vec<ChartRegion>,
528        ticks: Vec<ChartTick>,
529        x_max: f32,
530        y_max: f32,
531        ref_lines: Vec<ChartRefLine>,
532        bracket: Option<ChartBracket>,
533        legend: Vec<ChartLegendItem>,
534    },
535    /// An inline month calendar. `first_weekday` is the weekday of day 1 (0=Sun..6=Sat) so the
536    /// shells render leading blanks without date math; `on_day[d-1]` fires when day `d` is tapped
537    /// (length = days in the month). `selected` highlights a day.
538    Calendar { year: u32, month: u8, first_weekday: u8, selected: Option<u8>, on_day: Vec<ActionToken> },
539    /// A list row that reveals trailing `actions` on horizontal swipe (each tappable). On web the
540    /// actions render inline as a trailing button row (no gesture).
541    SwipeAction { child: Box<Widget>, actions: Vec<SwipeButton> },
542    /// A scrollable list for long/paged feeds, with shell-detected events at both ends: the bottom
543    /// `on_load_more` fires when the user scrolls near the end (infinite scroll), the top
544    /// `on_refresh` fires on pull-to-refresh. `loading`/`refreshing`/`has_more` are app-owned: set
545    /// `loading` while a page loads (shell shows a spinner, stops firing), `has_more=false` when
546    /// exhausted, and `refreshing` while a pull-refresh runs. The app appends to `children` on each
547    /// load-more. `on_refresh` is set via [`with_refresh`](mobiler_core::with_refresh).
548    LazyList {
549        children: Vec<Widget>,
550        on_load_more: Option<ActionToken>,
551        loading: bool,
552        has_more: bool,
553        on_refresh: Option<ActionToken>,
554        refreshing: bool,
555    },
556    Spacer { size: Spacing },
557    // Layout
558    Row { children: Vec<Widget> },
559    Column { children: Vec<Widget> },
560    /// Card; tappable when `on_press` is set. Fires `on_long_press` (when set) on a
561    /// press-and-hold (web: a ~500 ms pointer-hold; iOS: `onLongPressGesture`;
562    /// Android: `combinedClickable`'s `onLongClick`).
563    Card {
564        child: Box<Widget>,
565        style: CardStyle,
566        on_press: Option<ActionToken>,
567        on_long_press: Option<ActionToken>,
568    },
569    /// Z-stack: children layered back-to-front, positioned by `align`. With
570    /// `scrim`, the first child is a background image, darkened for legibility,
571    /// and the rest render on top in light content.
572    Box { children: Vec<Widget>, align: BoxAlign, scrim: bool },
573    /// Fixed 2-column grid; children flow left-to-right, top-to-bottom.
574    Grid { children: Vec<Widget> },
575    /// Horizontally scrolling row of children (a carousel / chip rail).
576    Scroller { children: Vec<Widget> },
577    /// Two-pane master-detail. On a **wide** screen (tablet / landscape — the shell's regular size
578    /// class) `primary` and `detail` render side-by-side; on a **compact** screen (phone) it shows
579    /// ONE pane: `primary` until `show_detail` is set (the app sets it when a row is selected), then
580    /// `detail` with a back chevron that fires `on_back` (the app clears its selection). On wide,
581    /// `show_detail`/`on_back` are ignored — both panes stay visible, so `detail` should show a
582    /// placeholder until something is selected.
583    Split { primary: Box<Widget>, detail: Box<Widget>, show_detail: bool, on_back: Option<ActionToken> },
584    /// Accessibility wrapper: presents `child`'s subtree as ONE screen-reader element named by `label`
585    /// (so an unlabeled IconButton/Image gets a name, or a Card's children group into one announced
586    /// element). `hint` describes what activation does; `role` is the control type. Shell-applied:
587    /// iOS accessibilityLabel/Hint/Traits, Android contentDescription/role/heading, web aria-label/role.
588    A11y { child: Box<Widget>, label: String, hint: Option<String>, role: Option<A11yRole> },
589    // Input
590    Button { label: String, style: ButtonStyle, on_press: ActionToken },
591    IconButton { icon: Icon, on_press: ActionToken },
592    Chip { label: String, selected: bool, on_press: ActionToken },
593    /// A text input. `kind` selects keyboard / secure entry / multiline
594    /// (see [`FieldKind`]); `error`, when `Some`, shows an inline validation
595    /// message below the field and marks it invalid. Emits `Input { id, Text }`.
596    TextField { id: String, placeholder: String, value: String, kind: FieldKind, error: Option<String> },
597    /// A search input (leading magnifier, pill shape); emits `Input { id, Text }` like `TextField`.
598    SearchField { id: String, placeholder: String, value: String },
599    /// A single-choice segmented control — exclusive options in a pill (e.g. Men/Women/Kids).
600    Segmented { segments: Vec<Segment> },
601    Toggle { id: String, label: String, value: bool },
602    Checkbox { id: String, label: String, value: bool },
603    /// Continuous 0..=`max` slider; emits `Input { id, Int }`.
604    Slider { id: String, value: i32, max: i32 },
605    /// Numeric stepper with −/+ controls carrying their own events.
606    Stepper { value: i32, on_decrement: ActionToken, on_increment: ActionToken },
607    /// App shell: a top bar (`title` + optional `back`), a scrollable `body`,
608    /// and bottom-nav `tabs`. `dark_mode` is theme-as-data — the shell themes
609    /// the whole app from it.
610    ///
611    /// `route` + `depth` drive navigation: the shell animates the body when
612    /// `route` (the current screen's identity) changes — slide for push/pop
613    /// (direction from whether `depth` grew or shrank), crossfade for a lateral
614    /// move at the same depth — and wires the system back button to `back`.
615    Scaffold {
616        title: String,
617        body: Box<Widget>,
618        tabs: Vec<Tab>,
619        back: Option<ActionToken>,
620        dark_mode: bool,
621        /// App branding (brand color, corner, density, font). `None` = framework
622        /// defaults (no visual change) — theme-as-data, the visual twin of `dark_mode`.
623        theme: Option<Theme>,
624        /// Optional floating action button (raised primary action over the body).
625        fab: Option<Fab>,
626        /// Optional modal bottom sheet over the body (a scrim + a panel from the bottom).
627        sheet: Option<Sheet>,
628        /// Pull-to-refresh: when set, the body is pull-refreshable and fires this event on pull.
629        /// The app owns `refreshing` — set it true when the pull fires, clear it when the async
630        /// reload completes (the shell shows a spinner while it's true).
631        on_refresh: Option<ActionToken>,
632        refreshing: bool,
633        route: String,
634        depth: u32,
635    },
636}
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641    use serde::Serialize;
642    use serde::de::DeserializeOwned;
643
644    // Round-trips the ABI without requiring `PartialEq` on the wire types:
645    // serialize → deserialize → re-serialize, and compare the two encodings.
646    fn round_trips<T: Serialize + DeserializeOwned>(value: &T) {
647        let a = serde_json::to_string(value).expect("serialize");
648        let back: T = serde_json::from_str(&a).expect("deserialize");
649        let b = serde_json::to_string(&back).expect("re-serialize");
650        assert_eq!(a, b);
651    }
652
653    #[test]
654    fn action_round_trips() {
655        round_trips(&Action::Start);
656        round_trips(&Action::Fired { token: "tok".to_string() });
657        round_trips(&Action::Input { id: "field".to_string(), value: InputValue::Bool(true) });
658        round_trips(&Action::Restore { data: "{}".to_string() });
659    }
660
661    #[test]
662    fn widget_round_trips() {
663        round_trips(&Widget::Text { content: "hi".to_string(), style: TextStyle::Title });
664        round_trips(&Widget::ColorDot { color: ProjectColor::Teal });
665        round_trips(&Widget::Chart {
666            series: vec![ChartSeries { name: "s".to_string(), values: vec![1.0, 2.5, 3.0], color: None, goal: None }],
667            labels: vec!["a".to_string()],
668            style: ChartStyle::Bar,
669            axis: true,
670            legend: false,
671        });
672        round_trips(&Widget::RegionChart {
673            regions: vec![ChartRegion::new(0.0, 3.0, 0.0, 80.0, "80%").vertical(),
674                          ChartRegion::new(3.0, 21.0, 0.0, 80.0, "CHF 80'000").with_color(Rgb::new(0x8E, 0xC6, 0xBA))],
675            ticks: vec![ChartTick { at: 3.0, label: "3 Mt.".to_string() }, ChartTick { at: 65.0, label: "65 J.".to_string() }],
676            x_max: 65.0,
677            y_max: 80.0,
678            ref_lines: vec![ChartRefLine { value: 80.0, label: "CHF 80'000".to_string(), dashed: false }],
679            bracket: Some(ChartBracket { y0: 60.0, y1: 80.0, label: "Ceiling".to_string(), info: true }),
680            legend: vec![ChartLegendItem { label: "Gap".to_string(), color: Rgb::new(0x5A, 0x7D, 0x9A) }],
681        });
682        round_trips(&Widget::PdfView { url: "https://example.com/report.pdf".to_string() });
683        round_trips(&Widget::Video { url: "https://example.com/clip.mp4".to_string(), id: "v1".to_string(), playing: true, seek_to_ms: -1, controls: true, looping: false, muted: true, on_ended: Some("ended".to_string()), poster: Some("https://example.com/poster.jpg".to_string()), start_at_ms: 12000, captions: vec![Caption { url: "https://example.com/en.vtt".to_string(), label: "English".to_string(), language: "en".to_string(), default_on: true }], rate: 1.5, volume: 0.8, urls: vec![], start_index: 0, seek_index: -1, allow_pip: true });
684        round_trips(&Widget::Video { url: "https://example.com/live.m3u8".to_string(), id: "v2".to_string(), playing: false, seek_to_ms: 5000, controls: false, looping: true, muted: false, on_ended: None, poster: None, start_at_ms: -1, captions: vec![], rate: 1.0, volume: 1.0, urls: vec!["https://example.com/a.mp4".to_string(), "https://example.com/b.mp4".to_string()], start_index: 1, seek_index: 0, allow_pip: false });
685        round_trips(&Widget::WebView { url: "https://iframe.mediadelivery.net/embed/1/abc".to_string() });
686        round_trips(&Widget::Map { id: "m1".to_string(), center_lat: 47.3769, center_lng: 8.5417, zoom: 14.0, markers: vec![MapMarker { id: "shop".to_string(), lat: 47.3769, lng: 8.5417, title: Some("Fade House".to_string()) }], style_url: Some("https://tiles.openfreemap.org/styles/liberty".to_string()), interactive: true });
687        round_trips(&Widget::Map { id: "m2".to_string(), center_lat: 0.0, center_lng: 0.0, zoom: 2.0, markers: vec![], style_url: None, interactive: false });
688        round_trips(&Widget::TextField { id: "email".to_string(), placeholder: "you@co".to_string(), value: "".to_string(), kind: FieldKind::Email, error: None });
689        round_trips(&Widget::TextField { id: "pw".to_string(), placeholder: "Password".to_string(), value: "x".to_string(), kind: FieldKind::Secure, error: Some("Too short".to_string()) });
690        round_trips(&Widget::Calendar { year: 2026, month: 6, first_weekday: 1, selected: Some(15), on_day: vec!["d1".to_string(), "d2".to_string()] });
691        round_trips(&Widget::SwipeAction { child: Box::new(Widget::Divider), actions: vec![SwipeButton { label: "Del".to_string(), tone: Tone::Danger, on_tap: "t".to_string() }] });
692        round_trips(&Widget::Split { primary: Box::new(Widget::Divider), detail: Box::new(Widget::Divider), show_detail: true, on_back: Some("back".to_string()) });
693        round_trips(&Widget::Split { primary: Box::new(Widget::Divider), detail: Box::new(Widget::Divider), show_detail: false, on_back: None });
694        round_trips(&Widget::LazyList { children: vec![Widget::Divider], on_load_more: Some("more".to_string()), loading: false, has_more: true, on_refresh: Some("refresh".to_string()), refreshing: false });
695        // Un-themed scaffold (theme: None) — the default, must round-trip.
696        round_trips(&Widget::Scaffold {
697            title: "T".to_string(),
698            body: Box::new(Widget::Divider),
699            tabs: vec![Tab { label: "A".to_string(), selected: true, on_select: "t".to_string(), icon: Some(Icon::Home) }],
700            back: Some("b".to_string()),
701            dark_mode: true,
702            theme: None,
703            fab: None,
704            sheet: None,
705            on_refresh: None,
706            refreshing: false,
707            route: "r".to_string(),
708            depth: 2,
709        });
710        // Themed scaffold — all four theme knobs must round-trip.
711        round_trips(&Widget::Scaffold {
712            title: "T".to_string(),
713            body: Box::new(Widget::Divider),
714            tabs: vec![],
715            back: None,
716            dark_mode: false,
717            theme: Some(Theme {
718                seed: Rgb::new(0xC8, 0x5A, 0x3C),
719                accent: Some(Rgb::new(0xE0, 0x6A, 0x2C)),
720                corner: Corner::Large,
721                density: Density::Compact,
722                font: FontFamily::Rounded,
723            }),
724            fab: Some(Fab { icon: Icon::Calendar, on_press: "f".to_string() }),
725            sheet: Some(Sheet { title: "S".to_string(), child: Box::new(Widget::Divider), on_dismiss: "d".to_string() }),
726            on_refresh: Some("r".to_string()),
727            refreshing: true,
728            route: "r".to_string(),
729            depth: 1,
730        });
731    }
732}