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/// What a [`Widget::TextField`] accepts — selects the on-screen keyboard,
62/// secure (masked) entry, and single- vs multi-line layout in one axis.
63///
64/// `Text` is the plain default. `Secure` masks input (passwords). `Email`,
65/// `Number` (integer), `Decimal`, `Phone`, and `Url` pick the matching native
66/// keyboard / input mode without masking. `Multiline` is a growable multi-row
67/// text area (plain keyboard).
68#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
69#[repr(C)]
70pub enum FieldKind { Text, Secure, Email, Number, Decimal, Phone, Url, Multiline }
71
72/// Semantic status color (distinct from brand/identity color).
73#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
74#[repr(C)]
75pub enum Tone { Neutral, Success, Warning, Danger, Info }
76
77/// How a `Chart` draws its series.
78///
79/// **Cartesian** styles plot every series over the shared `labels` x-axis:
80/// `Bar`/`Line` (grouped bars / one polyline per series), `StackedBar` (series stack to a total
81/// per x-slot), `StackedBar100` (each x-slot fills to 100% — series as proportions).
82///
83/// **Circular** styles ignore the x-axis and the `axis` flag: `Pie`/`Donut` turn **each series**
84/// into one wedge sized by its magnitude (`Donut` leaves a center hole); `Rings` draws concentric
85/// progress arcs (Apple-Watch fitness style), one per series, swept by `sum(values) / goal`;
86/// `Gauge` draws a single arc for the first series' `value / goal` with the number in the center.
87#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
88#[repr(C)]
89pub enum ChartStyle { Bar, Line, StackedBar, StackedBar100, Pie, Donut, Rings, Gauge }
90
91/// One named data series in a [`Widget::Chart`]. Cartesian styles plot `values` across the chart's
92/// x-axis `labels`; circular styles (pie/donut/rings/gauge) collapse the series to a single
93/// magnitude (`values` summed). `color` overrides the auto-assigned palette slot; `goal` is the
94/// denominator for `Rings`/`Gauge` progress (ignored by the other styles).
95#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq)]
96#[repr(C)]
97pub struct ChartSeries {
98 pub name: String,
99 pub values: Vec<f32>,
100 pub color: Option<Rgb>,
101 pub goal: Option<f32>,
102}
103
104impl ChartSeries {
105 /// A named series carrying `values`. Color falls back to the chart palette; no goal.
106 #[must_use]
107 pub fn new(name: impl Into<String>, values: Vec<f32>) -> Self {
108 Self { name: name.into(), values, color: None, goal: None }
109 }
110 /// Override the auto-assigned palette color for this series.
111 #[must_use]
112 pub fn with_color(mut self, color: Rgb) -> Self {
113 self.color = Some(color);
114 self
115 }
116 /// Set the denominator for `Rings`/`Gauge` progress (`sum(values) / goal`). Ignored by
117 /// cartesian and pie/donut styles.
118 #[must_use]
119 pub fn with_goal(mut self, goal: f32) -> Self {
120 self.goal = Some(goal);
121 self
122 }
123}
124
125// ----------------------------- region chart -----------------------------
126//
127// A [`Widget::RegionChart`] is a variable-width stacked-region ("Marimekko" / coverage-gap)
128// chart: arbitrary colored rectangles placed in a 2-D `[0, x_max] × [0, y_max]` plane, each with
129// an in-cell label, plus horizontal reference lines, an irregular x-axis, an optional right-side
130// bracket annotation, and a legend. The app computes the geometry; the shells map domain→pixels.
131
132/// One rectangle in a [`Widget::RegionChart`], spanning `[x0, x1]` horizontally and `[y0, y1]`
133/// vertically in the chart's domain. `label` is centered inside (empty = none); `vertical` rotates
134/// it 90° for narrow columns. `color` overrides the auto-assigned palette slot.
135#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq)]
136#[repr(C)]
137pub struct ChartRegion {
138 pub x0: f32,
139 pub x1: f32,
140 pub y0: f32,
141 pub y1: f32,
142 pub color: Option<Rgb>,
143 pub label: String,
144 pub vertical: bool,
145}
146
147/// A horizontal reference line across a [`Widget::RegionChart`] at `value`, with a right-edge
148/// `label` chip. `dashed` draws it dashed (e.g. a "max insured" ceiling) vs solid (a target).
149#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq)]
150#[repr(C)]
151pub struct ChartRefLine {
152 pub value: f32,
153 pub label: String,
154 pub dashed: bool,
155}
156
157/// A right-side bracket annotation spanning `[y0, y1]` with a `label` note (e.g. a ceiling band).
158#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq)]
159#[repr(C)]
160pub struct ChartBracket {
161 pub y0: f32,
162 pub y1: f32,
163 pub label: String,
164 /// Show an ⓘ info marker above the label (e.g. a "Ceiling max …" note). `label` may contain
165 /// `\n` for multiple lines.
166 pub info: bool,
167}
168
169/// An x-axis tick on a [`Widget::RegionChart`] at domain position `at`, labelled `label`. Ticks
170/// are irregular (the app places them), so shells position them by fraction, not even spacing.
171#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq)]
172#[repr(C)]
173pub struct ChartTick {
174 pub at: f32,
175 pub label: String,
176}
177
178/// One legend entry (swatch + name) for a [`Widget::RegionChart`].
179#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq)]
180#[repr(C)]
181pub struct ChartLegendItem {
182 pub label: String,
183 pub color: Rgb,
184}
185
186impl ChartRegion {
187 /// A region spanning `[x0,x1] × [y0,y1]` with a centered `label` (palette color, horizontal).
188 #[must_use]
189 pub fn new(x0: f32, x1: f32, y0: f32, y1: f32, label: impl Into<String>) -> Self {
190 Self { x0, x1, y0, y1, color: None, label: label.into(), vertical: false }
191 }
192 /// Override the fill color.
193 #[must_use]
194 pub fn with_color(mut self, color: Rgb) -> Self {
195 self.color = Some(color);
196 self
197 }
198 /// Render the label rotated 90° (for tall, narrow regions).
199 #[must_use]
200 pub fn vertical(mut self) -> Self {
201 self.vertical = true;
202 self
203 }
204}
205
206impl ChartRefLine {
207 /// A solid target line at `value` with a right-edge chip.
208 #[must_use]
209 pub fn target(value: f32, label: impl Into<String>) -> Self {
210 Self { value, label: label.into(), dashed: false }
211 }
212 /// A dashed "max"/ceiling line at `value`.
213 #[must_use]
214 pub fn max(value: f32, label: impl Into<String>) -> Self {
215 Self { value, label: label.into(), dashed: true }
216 }
217}
218
219impl ChartTick {
220 #[must_use]
221 pub fn new(at: f32, label: impl Into<String>) -> Self {
222 Self { at, label: label.into() }
223 }
224}
225
226impl ChartLegendItem {
227 #[must_use]
228 pub fn new(label: impl Into<String>, color: Rgb) -> Self {
229 Self { label: label.into(), color }
230 }
231}
232
233impl ChartBracket {
234 #[must_use]
235 pub fn new(y0: f32, y1: f32, label: impl Into<String>) -> Self {
236 Self { y0, y1, label: label.into(), info: false }
237 }
238 /// Show an ⓘ info marker above the label.
239 #[must_use]
240 pub fn with_info(mut self) -> Self {
241 self.info = true;
242 self
243 }
244}
245
246#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
247#[repr(C)]
248pub enum Spacing { Xs, Sm, Md, Lg, Xl }
249
250/// A finite icon set (maps to Material icons / SF Symbols / web glyphs per shell).
251/// Grouped: editing, navigation/chrome, content, and domain icons.
252#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
253#[repr(C)]
254pub enum Icon {
255 // editing / status
256 Delete, Add, Edit, Close, Settings, Check, Star, Info,
257 // navigation / chrome
258 Home, Search, Menu, Filter, Back, Forward, Down, Bell, Cart, Share, Heart, HeartFilled,
259 // people / contact
260 Person, People, Phone, Mail, Calendar, Clock, MapPin,
261 // content / media
262 Camera, Photo, Play,
263 // domain
264 Scissors,
265}
266
267#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
268#[repr(C)]
269pub enum ImageShape { Square, Rounded, Circle }
270
271#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
272#[repr(C)]
273pub enum ImageRatio { Wide, Square, Tall }
274
275#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
276#[repr(C)]
277pub enum BoxAlign { TopStart, TopEnd, Center, BottomStart, BottomCenter, BottomEnd }
278
279/// Project-identity colors (distinct from semantic `Tone`). Concrete RGB decided
280/// in the render layer.
281#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
282#[repr(C)]
283pub enum ProjectColor { Indigo, Teal, Coral, Amber, Lime, Pink }
284
285// ------------------------------- theme -------------------------------
286
287/// A 24-bit RGB color. Used for a theme's brand/seed color — the one place an app
288/// supplies an arbitrary color (everything else is intent tokens).
289#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
290#[repr(C)]
291pub struct Rgb {
292 pub r: u8,
293 pub g: u8,
294 pub b: u8,
295}
296
297impl Rgb {
298 pub const fn new(r: u8, g: u8, b: u8) -> Self {
299 Self { r, g, b }
300 }
301}
302
303/// Global corner-radius scale. `Medium` ≈ the current (un-themed) look.
304#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
305#[repr(C)]
306pub enum Corner { None, Small, Medium, Large }
307
308/// Global spacing scale. `Comfortable` ≈ the current (un-themed) spacing.
309#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
310#[repr(C)]
311pub enum Density { Compact, Comfortable }
312
313/// A finite, cross-platform font family (maps to each platform's nearest system
314/// font design — no bundled font files). `System` ≈ the current look.
315#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
316#[repr(C)]
317pub enum FontFamily { System, Rounded, Serif, Monospace }
318
319/// App branding as data — the visual twin of `dark_mode`. Set on a [`Widget::Scaffold`]
320/// (`theme: None` = the framework defaults, i.e. no visual change). The shell maps these
321/// to its native theming: `seed` → the brand/primary color (Android M3 scheme / iOS tint /
322/// web `--primary`), plus a global corner, spacing, and font choice.
323#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
324#[repr(C)]
325pub struct Theme {
326 pub seed: Rgb,
327 /// Optional secondary brand color. `None` ⇒ derived from `seed`. Used for the
328 /// gradient on `CardStyle::Brand` (seed → accent) and as a secondary accent.
329 pub accent: Option<Rgb>,
330 pub corner: Corner,
331 pub density: Density,
332 pub font: FontFamily,
333}
334
335/// `Theme::default()` matches the framework's un-themed look as closely as a theme can
336/// (medium corners, comfortable spacing, system font) with a neutral indigo seed — so an
337/// app can override just the bits it cares about: `Theme { seed: brand, ..Default::default() }`.
338impl Default for Theme {
339 fn default() -> Self {
340 Theme {
341 seed: Rgb::new(0x5C, 0x6B, 0xC0), // indigo — matches the legacy default accent
342 accent: None,
343 corner: Corner::Medium,
344 density: Density::Comfortable,
345 font: FontFamily::System,
346 }
347 }
348}
349
350/// A bottom-navigation tab. `selected` marks the active one; tapping sends
351/// `on_select`. `icon` (optional) renders above the label for an icon tab bar.
352#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
353#[repr(C)]
354pub struct Tab {
355 pub label: String,
356 pub selected: bool,
357 pub on_select: ActionToken,
358 /// Optional leading icon (icon tab bar). `None` = label-only (the original look).
359 pub icon: Option<Icon>,
360}
361
362/// A floating action button anchored over the scaffold body (the raised primary action).
363#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
364#[repr(C)]
365pub struct Fab {
366 pub icon: Icon,
367 pub on_press: ActionToken,
368}
369
370/// One option in a [`Widget::Segmented`] control (mirrors [`Tab`]). `selected` marks the
371/// active segment; tapping sends `on_select`.
372#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
373#[repr(C)]
374pub struct Segment {
375 pub label: String,
376 pub selected: bool,
377 pub on_select: ActionToken,
378}
379
380/// A modal bottom sheet anchored over the scaffold body (a scrim behind, a panel rising from
381/// the bottom). Present (`Some`) ⇒ open; tapping the scrim/handle sends `on_dismiss`.
382#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
383#[repr(C)]
384pub struct Sheet {
385 pub title: String,
386 pub child: Box<Widget>,
387 pub on_dismiss: ActionToken,
388}
389
390/// One revealed action in a `SwipeAction` row (swipe to reveal, tap to fire).
391#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
392#[repr(C)]
393pub struct SwipeButton {
394 pub label: String,
395 pub tone: Tone,
396 pub on_tap: ActionToken,
397}
398
399// ------------------------------- widgets -------------------------------
400
401/// The app-agnostic widget tree the shell renders. **Fixed across all apps.**
402#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
403#[repr(C)]
404pub enum Widget {
405 // Content
406 Text { content: String, style: TextStyle },
407 Image { source: String, shape: ImageShape, ratio: ImageRatio },
408 Badge { label: String, tone: Tone },
409 /// A circular avatar image with an optional colored status dot.
410 Avatar { source: String, status: Option<Tone> },
411 /// An in-app PDF viewer showing the document at `url` (a remote https URL or a local
412 /// file URI). Each shell uses its native renderer — PDFKit on iOS, a paged `PdfRenderer`
413 /// on Android, an `<iframe>` on web — so the app only supplies the URL (e.g. a
414 /// backend-generated report). Fills its width; give it room (place in a sized container).
415 PdfView { url: String },
416 /// An in-app native video player for the stream/file at `url` (MP4 everywhere; HLS `.m3u8` on
417 /// iOS/Android natively + Safari on web; or a local file URI). Native player per shell — AVPlayer
418 /// (iOS), Media3/ExoPlayer (Android), a `<video>` element (web). **Controllable:** `playing` drives
419 /// play/pause (app-owned, like a `Toggle`); set `seek_to_ms` to jump (the shell seeks when the value
420 /// CHANGES; `-1` = no seek). The shell reports the current position ~once/second via
421 /// `Action::Input { id, value: Int(position_ms) }` (handle it in [`MobilerApp::input`]), and fires
422 /// `on_ended` when the clip finishes. `controls` shows the native transport bar; `looping` restarts
423 /// on end; `muted` starts muted (needed for reliable autoplay). Fills its width; give it room.
424 Video {
425 url: String,
426 id: String,
427 playing: bool,
428 seek_to_ms: i64,
429 controls: bool,
430 looping: bool,
431 muted: bool,
432 on_ended: Option<ActionToken>,
433 },
434 /// A star rating. `value` is in tenths (e.g. `48` = 4.8 of `max` stars). When `on_rate`
435 /// is set (one token per star), the stars are tappable — star *i* fires `on_rate[i]`.
436 Rating { value: u32, max: u8, on_rate: Option<Vec<ActionToken>> },
437 /// Small non-interactive colored dot — a project/identity hint.
438 ColorDot { color: ProjectColor },
439 Divider,
440 /// Progress indicator: `value` 0.0–1.0 for a determinate bar, `None` for an indeterminate spinner.
441 Progress { value: Option<f32> },
442 /// Shimmer placeholder shown while content loads.
443 Skeleton,
444 /// A data chart drawing one or more named `series` in the given `style` (see [`ChartStyle`]).
445 /// `labels` (optional) annotate the x-axis for cartesian styles. `axis` shows y gridlines +
446 /// tick values (cartesian only); `legend` shows a series swatch+name row. Non-interactive.
447 Chart { series: Vec<ChartSeries>, labels: Vec<String>, style: ChartStyle, axis: bool, legend: bool },
448 /// A variable-width stacked-region ("Marimekko" / coverage-gap) chart: `regions` are arbitrary
449 /// colored rectangles in the `[0, x_max] × [0, y_max]` plane (each with an in-cell label),
450 /// `ticks` annotate the irregular x-axis, `ref_lines` are horizontal target/max lines with
451 /// right-edge chips, `bracket` is an optional right-side range annotation, and `legend` names
452 /// the colors. The app supplies all geometry; shells map domain→pixels. Non-interactive.
453 RegionChart {
454 regions: Vec<ChartRegion>,
455 ticks: Vec<ChartTick>,
456 x_max: f32,
457 y_max: f32,
458 ref_lines: Vec<ChartRefLine>,
459 bracket: Option<ChartBracket>,
460 legend: Vec<ChartLegendItem>,
461 },
462 /// An inline month calendar. `first_weekday` is the weekday of day 1 (0=Sun..6=Sat) so the
463 /// shells render leading blanks without date math; `on_day[d-1]` fires when day `d` is tapped
464 /// (length = days in the month). `selected` highlights a day.
465 Calendar { year: u32, month: u8, first_weekday: u8, selected: Option<u8>, on_day: Vec<ActionToken> },
466 /// A list row that reveals trailing `actions` on horizontal swipe (each tappable). On web the
467 /// actions render inline as a trailing button row (no gesture).
468 SwipeAction { child: Box<Widget>, actions: Vec<SwipeButton> },
469 /// A scrollable list for long/paged feeds, with shell-detected events at both ends: the bottom
470 /// `on_load_more` fires when the user scrolls near the end (infinite scroll), the top
471 /// `on_refresh` fires on pull-to-refresh. `loading`/`refreshing`/`has_more` are app-owned: set
472 /// `loading` while a page loads (shell shows a spinner, stops firing), `has_more=false` when
473 /// exhausted, and `refreshing` while a pull-refresh runs. The app appends to `children` on each
474 /// load-more. `on_refresh` is set via [`with_refresh`](mobiler_core::with_refresh).
475 LazyList {
476 children: Vec<Widget>,
477 on_load_more: Option<ActionToken>,
478 loading: bool,
479 has_more: bool,
480 on_refresh: Option<ActionToken>,
481 refreshing: bool,
482 },
483 Spacer { size: Spacing },
484 // Layout
485 Row { children: Vec<Widget> },
486 Column { children: Vec<Widget> },
487 /// Card; tappable when `on_press` is set.
488 Card { child: Box<Widget>, style: CardStyle, on_press: Option<ActionToken> },
489 /// Z-stack: children layered back-to-front, positioned by `align`. With
490 /// `scrim`, the first child is a background image, darkened for legibility,
491 /// and the rest render on top in light content.
492 Box { children: Vec<Widget>, align: BoxAlign, scrim: bool },
493 /// Fixed 2-column grid; children flow left-to-right, top-to-bottom.
494 Grid { children: Vec<Widget> },
495 /// Horizontally scrolling row of children (a carousel / chip rail).
496 Scroller { children: Vec<Widget> },
497 // Input
498 Button { label: String, style: ButtonStyle, on_press: ActionToken },
499 IconButton { icon: Icon, on_press: ActionToken },
500 Chip { label: String, selected: bool, on_press: ActionToken },
501 /// A text input. `kind` selects keyboard / secure entry / multiline
502 /// (see [`FieldKind`]); `error`, when `Some`, shows an inline validation
503 /// message below the field and marks it invalid. Emits `Input { id, Text }`.
504 TextField { id: String, placeholder: String, value: String, kind: FieldKind, error: Option<String> },
505 /// A search input (leading magnifier, pill shape); emits `Input { id, Text }` like `TextField`.
506 SearchField { id: String, placeholder: String, value: String },
507 /// A single-choice segmented control — exclusive options in a pill (e.g. Men/Women/Kids).
508 Segmented { segments: Vec<Segment> },
509 Toggle { id: String, label: String, value: bool },
510 Checkbox { id: String, label: String, value: bool },
511 /// Continuous 0..=`max` slider; emits `Input { id, Int }`.
512 Slider { id: String, value: i32, max: i32 },
513 /// Numeric stepper with −/+ controls carrying their own events.
514 Stepper { value: i32, on_decrement: ActionToken, on_increment: ActionToken },
515 /// App shell: a top bar (`title` + optional `back`), a scrollable `body`,
516 /// and bottom-nav `tabs`. `dark_mode` is theme-as-data — the shell themes
517 /// the whole app from it.
518 ///
519 /// `route` + `depth` drive navigation: the shell animates the body when
520 /// `route` (the current screen's identity) changes — slide for push/pop
521 /// (direction from whether `depth` grew or shrank), crossfade for a lateral
522 /// move at the same depth — and wires the system back button to `back`.
523 Scaffold {
524 title: String,
525 body: Box<Widget>,
526 tabs: Vec<Tab>,
527 back: Option<ActionToken>,
528 dark_mode: bool,
529 /// App branding (brand color, corner, density, font). `None` = framework
530 /// defaults (no visual change) — theme-as-data, the visual twin of `dark_mode`.
531 theme: Option<Theme>,
532 /// Optional floating action button (raised primary action over the body).
533 fab: Option<Fab>,
534 /// Optional modal bottom sheet over the body (a scrim + a panel from the bottom).
535 sheet: Option<Sheet>,
536 /// Pull-to-refresh: when set, the body is pull-refreshable and fires this event on pull.
537 /// The app owns `refreshing` — set it true when the pull fires, clear it when the async
538 /// reload completes (the shell shows a spinner while it's true).
539 on_refresh: Option<ActionToken>,
540 refreshing: bool,
541 route: String,
542 depth: u32,
543 },
544}
545
546#[cfg(test)]
547mod tests {
548 use super::*;
549 use serde::Serialize;
550 use serde::de::DeserializeOwned;
551
552 // Round-trips the ABI without requiring `PartialEq` on the wire types:
553 // serialize → deserialize → re-serialize, and compare the two encodings.
554 fn round_trips<T: Serialize + DeserializeOwned>(value: &T) {
555 let a = serde_json::to_string(value).expect("serialize");
556 let back: T = serde_json::from_str(&a).expect("deserialize");
557 let b = serde_json::to_string(&back).expect("re-serialize");
558 assert_eq!(a, b);
559 }
560
561 #[test]
562 fn action_round_trips() {
563 round_trips(&Action::Start);
564 round_trips(&Action::Fired { token: "tok".to_string() });
565 round_trips(&Action::Input { id: "field".to_string(), value: InputValue::Bool(true) });
566 round_trips(&Action::Restore { data: "{}".to_string() });
567 }
568
569 #[test]
570 fn widget_round_trips() {
571 round_trips(&Widget::Text { content: "hi".to_string(), style: TextStyle::Title });
572 round_trips(&Widget::ColorDot { color: ProjectColor::Teal });
573 round_trips(&Widget::Chart {
574 series: vec![ChartSeries { name: "s".to_string(), values: vec![1.0, 2.5, 3.0], color: None, goal: None }],
575 labels: vec!["a".to_string()],
576 style: ChartStyle::Bar,
577 axis: true,
578 legend: false,
579 });
580 round_trips(&Widget::RegionChart {
581 regions: vec![ChartRegion::new(0.0, 3.0, 0.0, 80.0, "80%").vertical(),
582 ChartRegion::new(3.0, 21.0, 0.0, 80.0, "CHF 80'000").with_color(Rgb::new(0x8E, 0xC6, 0xBA))],
583 ticks: vec![ChartTick { at: 3.0, label: "3 Mt.".to_string() }, ChartTick { at: 65.0, label: "65 J.".to_string() }],
584 x_max: 65.0,
585 y_max: 80.0,
586 ref_lines: vec![ChartRefLine { value: 80.0, label: "CHF 80'000".to_string(), dashed: false }],
587 bracket: Some(ChartBracket { y0: 60.0, y1: 80.0, label: "Ceiling".to_string(), info: true }),
588 legend: vec![ChartLegendItem { label: "Gap".to_string(), color: Rgb::new(0x5A, 0x7D, 0x9A) }],
589 });
590 round_trips(&Widget::PdfView { url: "https://example.com/report.pdf".to_string() });
591 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()) });
592 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 });
593 round_trips(&Widget::TextField { id: "email".to_string(), placeholder: "you@co".to_string(), value: "".to_string(), kind: FieldKind::Email, error: None });
594 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()) });
595 round_trips(&Widget::Calendar { year: 2026, month: 6, first_weekday: 1, selected: Some(15), on_day: vec!["d1".to_string(), "d2".to_string()] });
596 round_trips(&Widget::SwipeAction { child: Box::new(Widget::Divider), actions: vec![SwipeButton { label: "Del".to_string(), tone: Tone::Danger, on_tap: "t".to_string() }] });
597 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 });
598 // Un-themed scaffold (theme: None) — the default, must round-trip.
599 round_trips(&Widget::Scaffold {
600 title: "T".to_string(),
601 body: Box::new(Widget::Divider),
602 tabs: vec![Tab { label: "A".to_string(), selected: true, on_select: "t".to_string(), icon: Some(Icon::Home) }],
603 back: Some("b".to_string()),
604 dark_mode: true,
605 theme: None,
606 fab: None,
607 sheet: None,
608 on_refresh: None,
609 refreshing: false,
610 route: "r".to_string(),
611 depth: 2,
612 });
613 // Themed scaffold — all four theme knobs must round-trip.
614 round_trips(&Widget::Scaffold {
615 title: "T".to_string(),
616 body: Box::new(Widget::Divider),
617 tabs: vec![],
618 back: None,
619 dark_mode: false,
620 theme: Some(Theme {
621 seed: Rgb::new(0xC8, 0x5A, 0x3C),
622 accent: Some(Rgb::new(0xE0, 0x6A, 0x2C)),
623 corner: Corner::Large,
624 density: Density::Compact,
625 font: FontFamily::Rounded,
626 }),
627 fab: Some(Fab { icon: Icon::Calendar, on_press: "f".to_string() }),
628 sheet: Some(Sheet { title: "S".to_string(), child: Box::new(Widget::Divider), on_dismiss: "d".to_string() }),
629 on_refresh: Some("r".to_string()),
630 refreshing: true,
631 route: "r".to_string(),
632 depth: 1,
633 });
634 }
635}