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