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/// Semantic status color (distinct from brand/identity color).
62#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
63#[repr(C)]
64pub enum Tone { Neutral, Success, Warning, Danger, Info }
65
66/// How a `Chart` draws its series.
67///
68/// **Cartesian** styles plot every series over the shared `labels` x-axis:
69/// `Bar`/`Line` (grouped bars / one polyline per series), `StackedBar` (series stack to a total
70/// per x-slot), `StackedBar100` (each x-slot fills to 100% — series as proportions).
71///
72/// **Circular** styles ignore the x-axis and the `axis` flag: `Pie`/`Donut` turn **each series**
73/// into one wedge sized by its magnitude (`Donut` leaves a center hole); `Rings` draws concentric
74/// progress arcs (Apple-Watch fitness style), one per series, swept by `sum(values) / goal`;
75/// `Gauge` draws a single arc for the first series' `value / goal` with the number in the center.
76#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
77#[repr(C)]
78pub enum ChartStyle { Bar, Line, StackedBar, StackedBar100, Pie, Donut, Rings, Gauge }
79
80/// One named data series in a [`Widget::Chart`]. Cartesian styles plot `values` across the chart's
81/// x-axis `labels`; circular styles (pie/donut/rings/gauge) collapse the series to a single
82/// magnitude (`values` summed). `color` overrides the auto-assigned palette slot; `goal` is the
83/// denominator for `Rings`/`Gauge` progress (ignored by the other styles).
84#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq)]
85#[repr(C)]
86pub struct ChartSeries {
87 pub name: String,
88 pub values: Vec<f32>,
89 pub color: Option<Rgb>,
90 pub goal: Option<f32>,
91}
92
93impl ChartSeries {
94 /// A named series carrying `values`. Color falls back to the chart palette; no goal.
95 #[must_use]
96 pub fn new(name: impl Into<String>, values: Vec<f32>) -> Self {
97 Self { name: name.into(), values, color: None, goal: None }
98 }
99 /// Override the auto-assigned palette color for this series.
100 #[must_use]
101 pub fn with_color(mut self, color: Rgb) -> Self {
102 self.color = Some(color);
103 self
104 }
105 /// Set the denominator for `Rings`/`Gauge` progress (`sum(values) / goal`). Ignored by
106 /// cartesian and pie/donut styles.
107 #[must_use]
108 pub fn with_goal(mut self, goal: f32) -> Self {
109 self.goal = Some(goal);
110 self
111 }
112}
113
114#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
115#[repr(C)]
116pub enum Spacing { Xs, Sm, Md, Lg, Xl }
117
118/// A finite icon set (maps to Material icons / SF Symbols / web glyphs per shell).
119/// Grouped: editing, navigation/chrome, content, and domain icons.
120#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
121#[repr(C)]
122pub enum Icon {
123 // editing / status
124 Delete, Add, Edit, Close, Settings, Check, Star, Info,
125 // navigation / chrome
126 Home, Search, Menu, Filter, Back, Forward, Down, Bell, Cart, Share, Heart, HeartFilled,
127 // people / contact
128 Person, People, Phone, Mail, Calendar, Clock, MapPin,
129 // content / media
130 Camera, Photo, Play,
131 // domain
132 Scissors,
133}
134
135#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
136#[repr(C)]
137pub enum ImageShape { Square, Rounded, Circle }
138
139#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
140#[repr(C)]
141pub enum ImageRatio { Wide, Square, Tall }
142
143#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
144#[repr(C)]
145pub enum BoxAlign { TopStart, TopEnd, Center, BottomStart, BottomCenter, BottomEnd }
146
147/// Project-identity colors (distinct from semantic `Tone`). Concrete RGB decided
148/// in the render layer.
149#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
150#[repr(C)]
151pub enum ProjectColor { Indigo, Teal, Coral, Amber, Lime, Pink }
152
153// ------------------------------- theme -------------------------------
154
155/// A 24-bit RGB color. Used for a theme's brand/seed color — the one place an app
156/// supplies an arbitrary color (everything else is intent tokens).
157#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
158#[repr(C)]
159pub struct Rgb {
160 pub r: u8,
161 pub g: u8,
162 pub b: u8,
163}
164
165impl Rgb {
166 pub const fn new(r: u8, g: u8, b: u8) -> Self {
167 Self { r, g, b }
168 }
169}
170
171/// Global corner-radius scale. `Medium` ≈ the current (un-themed) look.
172#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
173#[repr(C)]
174pub enum Corner { None, Small, Medium, Large }
175
176/// Global spacing scale. `Comfortable` ≈ the current (un-themed) spacing.
177#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
178#[repr(C)]
179pub enum Density { Compact, Comfortable }
180
181/// A finite, cross-platform font family (maps to each platform's nearest system
182/// font design — no bundled font files). `System` ≈ the current look.
183#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
184#[repr(C)]
185pub enum FontFamily { System, Rounded, Serif, Monospace }
186
187/// App branding as data — the visual twin of `dark_mode`. Set on a [`Widget::Scaffold`]
188/// (`theme: None` = the framework defaults, i.e. no visual change). The shell maps these
189/// to its native theming: `seed` → the brand/primary color (Android M3 scheme / iOS tint /
190/// web `--primary`), plus a global corner, spacing, and font choice.
191#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
192#[repr(C)]
193pub struct Theme {
194 pub seed: Rgb,
195 /// Optional secondary brand color. `None` ⇒ derived from `seed`. Used for the
196 /// gradient on `CardStyle::Brand` (seed → accent) and as a secondary accent.
197 pub accent: Option<Rgb>,
198 pub corner: Corner,
199 pub density: Density,
200 pub font: FontFamily,
201}
202
203/// `Theme::default()` matches the framework's un-themed look as closely as a theme can
204/// (medium corners, comfortable spacing, system font) with a neutral indigo seed — so an
205/// app can override just the bits it cares about: `Theme { seed: brand, ..Default::default() }`.
206impl Default for Theme {
207 fn default() -> Self {
208 Theme {
209 seed: Rgb::new(0x5C, 0x6B, 0xC0), // indigo — matches the legacy default accent
210 accent: None,
211 corner: Corner::Medium,
212 density: Density::Comfortable,
213 font: FontFamily::System,
214 }
215 }
216}
217
218/// A bottom-navigation tab. `selected` marks the active one; tapping sends
219/// `on_select`. `icon` (optional) renders above the label for an icon tab bar.
220#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
221#[repr(C)]
222pub struct Tab {
223 pub label: String,
224 pub selected: bool,
225 pub on_select: ActionToken,
226 /// Optional leading icon (icon tab bar). `None` = label-only (the original look).
227 pub icon: Option<Icon>,
228}
229
230/// A floating action button anchored over the scaffold body (the raised primary action).
231#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
232#[repr(C)]
233pub struct Fab {
234 pub icon: Icon,
235 pub on_press: ActionToken,
236}
237
238/// One option in a [`Widget::Segmented`] control (mirrors [`Tab`]). `selected` marks the
239/// active segment; tapping sends `on_select`.
240#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
241#[repr(C)]
242pub struct Segment {
243 pub label: String,
244 pub selected: bool,
245 pub on_select: ActionToken,
246}
247
248/// A modal bottom sheet anchored over the scaffold body (a scrim behind, a panel rising from
249/// the bottom). Present (`Some`) ⇒ open; tapping the scrim/handle sends `on_dismiss`.
250#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
251#[repr(C)]
252pub struct Sheet {
253 pub title: String,
254 pub child: Box<Widget>,
255 pub on_dismiss: ActionToken,
256}
257
258/// One revealed action in a `SwipeAction` row (swipe to reveal, tap to fire).
259#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
260#[repr(C)]
261pub struct SwipeButton {
262 pub label: String,
263 pub tone: Tone,
264 pub on_tap: ActionToken,
265}
266
267// ------------------------------- widgets -------------------------------
268
269/// The app-agnostic widget tree the shell renders. **Fixed across all apps.**
270#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
271#[repr(C)]
272pub enum Widget {
273 // Content
274 Text { content: String, style: TextStyle },
275 Image { source: String, shape: ImageShape, ratio: ImageRatio },
276 Badge { label: String, tone: Tone },
277 /// A circular avatar image with an optional colored status dot.
278 Avatar { source: String, status: Option<Tone> },
279 /// A star rating. `value` is in tenths (e.g. `48` = 4.8 of `max` stars). When `on_rate`
280 /// is set (one token per star), the stars are tappable — star *i* fires `on_rate[i]`.
281 Rating { value: u32, max: u8, on_rate: Option<Vec<ActionToken>> },
282 /// Small non-interactive colored dot — a project/identity hint.
283 ColorDot { color: ProjectColor },
284 Divider,
285 /// Progress indicator: `value` 0.0–1.0 for a determinate bar, `None` for an indeterminate spinner.
286 Progress { value: Option<f32> },
287 /// Shimmer placeholder shown while content loads.
288 Skeleton,
289 /// A data chart drawing one or more named `series` in the given `style` (see [`ChartStyle`]).
290 /// `labels` (optional) annotate the x-axis for cartesian styles. `axis` shows y gridlines +
291 /// tick values (cartesian only); `legend` shows a series swatch+name row. Non-interactive.
292 Chart { series: Vec<ChartSeries>, labels: Vec<String>, style: ChartStyle, axis: bool, legend: bool },
293 /// An inline month calendar. `first_weekday` is the weekday of day 1 (0=Sun..6=Sat) so the
294 /// shells render leading blanks without date math; `on_day[d-1]` fires when day `d` is tapped
295 /// (length = days in the month). `selected` highlights a day.
296 Calendar { year: u32, month: u8, first_weekday: u8, selected: Option<u8>, on_day: Vec<ActionToken> },
297 /// A list row that reveals trailing `actions` on horizontal swipe (each tappable). On web the
298 /// actions render inline as a trailing button row (no gesture).
299 SwipeAction { child: Box<Widget>, actions: Vec<SwipeButton> },
300 Spacer { size: Spacing },
301 // Layout
302 Row { children: Vec<Widget> },
303 Column { children: Vec<Widget> },
304 /// Card; tappable when `on_press` is set.
305 Card { child: Box<Widget>, style: CardStyle, on_press: Option<ActionToken> },
306 /// Z-stack: children layered back-to-front, positioned by `align`. With
307 /// `scrim`, the first child is a background image, darkened for legibility,
308 /// and the rest render on top in light content.
309 Box { children: Vec<Widget>, align: BoxAlign, scrim: bool },
310 /// Fixed 2-column grid; children flow left-to-right, top-to-bottom.
311 Grid { children: Vec<Widget> },
312 /// Horizontally scrolling row of children (a carousel / chip rail).
313 Scroller { children: Vec<Widget> },
314 // Input
315 Button { label: String, style: ButtonStyle, on_press: ActionToken },
316 IconButton { icon: Icon, on_press: ActionToken },
317 Chip { label: String, selected: bool, on_press: ActionToken },
318 TextField { id: String, placeholder: String, value: String },
319 /// A search input (leading magnifier, pill shape); emits `Input { id, Text }` like `TextField`.
320 SearchField { id: String, placeholder: String, value: String },
321 /// A single-choice segmented control — exclusive options in a pill (e.g. Men/Women/Kids).
322 Segmented { segments: Vec<Segment> },
323 Toggle { id: String, label: String, value: bool },
324 Checkbox { id: String, label: String, value: bool },
325 /// Continuous 0..=`max` slider; emits `Input { id, Int }`.
326 Slider { id: String, value: i32, max: i32 },
327 /// Numeric stepper with −/+ controls carrying their own events.
328 Stepper { value: i32, on_decrement: ActionToken, on_increment: ActionToken },
329 /// App shell: a top bar (`title` + optional `back`), a scrollable `body`,
330 /// and bottom-nav `tabs`. `dark_mode` is theme-as-data — the shell themes
331 /// the whole app from it.
332 ///
333 /// `route` + `depth` drive navigation: the shell animates the body when
334 /// `route` (the current screen's identity) changes — slide for push/pop
335 /// (direction from whether `depth` grew or shrank), crossfade for a lateral
336 /// move at the same depth — and wires the system back button to `back`.
337 Scaffold {
338 title: String,
339 body: Box<Widget>,
340 tabs: Vec<Tab>,
341 back: Option<ActionToken>,
342 dark_mode: bool,
343 /// App branding (brand color, corner, density, font). `None` = framework
344 /// defaults (no visual change) — theme-as-data, the visual twin of `dark_mode`.
345 theme: Option<Theme>,
346 /// Optional floating action button (raised primary action over the body).
347 fab: Option<Fab>,
348 /// Optional modal bottom sheet over the body (a scrim + a panel from the bottom).
349 sheet: Option<Sheet>,
350 /// Pull-to-refresh: when set, the body is pull-refreshable and fires this event on pull.
351 /// The app owns `refreshing` — set it true when the pull fires, clear it when the async
352 /// reload completes (the shell shows a spinner while it's true).
353 on_refresh: Option<ActionToken>,
354 refreshing: bool,
355 route: String,
356 depth: u32,
357 },
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363 use serde::Serialize;
364 use serde::de::DeserializeOwned;
365
366 // Round-trips the ABI without requiring `PartialEq` on the wire types:
367 // serialize → deserialize → re-serialize, and compare the two encodings.
368 fn round_trips<T: Serialize + DeserializeOwned>(value: &T) {
369 let a = serde_json::to_string(value).expect("serialize");
370 let back: T = serde_json::from_str(&a).expect("deserialize");
371 let b = serde_json::to_string(&back).expect("re-serialize");
372 assert_eq!(a, b);
373 }
374
375 #[test]
376 fn action_round_trips() {
377 round_trips(&Action::Start);
378 round_trips(&Action::Fired { token: "tok".to_string() });
379 round_trips(&Action::Input { id: "field".to_string(), value: InputValue::Bool(true) });
380 round_trips(&Action::Restore { data: "{}".to_string() });
381 }
382
383 #[test]
384 fn widget_round_trips() {
385 round_trips(&Widget::Text { content: "hi".to_string(), style: TextStyle::Title });
386 round_trips(&Widget::ColorDot { color: ProjectColor::Teal });
387 round_trips(&Widget::Chart {
388 series: vec![ChartSeries { name: "s".to_string(), values: vec![1.0, 2.5, 3.0], color: None, goal: None }],
389 labels: vec!["a".to_string()],
390 style: ChartStyle::Bar,
391 axis: true,
392 legend: false,
393 });
394 round_trips(&Widget::Calendar { year: 2026, month: 6, first_weekday: 1, selected: Some(15), on_day: vec!["d1".to_string(), "d2".to_string()] });
395 round_trips(&Widget::SwipeAction { child: Box::new(Widget::Divider), actions: vec![SwipeButton { label: "Del".to_string(), tone: Tone::Danger, on_tap: "t".to_string() }] });
396 // Un-themed scaffold (theme: None) — the default, must round-trip.
397 round_trips(&Widget::Scaffold {
398 title: "T".to_string(),
399 body: Box::new(Widget::Divider),
400 tabs: vec![Tab { label: "A".to_string(), selected: true, on_select: "t".to_string(), icon: Some(Icon::Home) }],
401 back: Some("b".to_string()),
402 dark_mode: true,
403 theme: None,
404 fab: None,
405 sheet: None,
406 on_refresh: None,
407 refreshing: false,
408 route: "r".to_string(),
409 depth: 2,
410 });
411 // Themed scaffold — all four theme knobs must round-trip.
412 round_trips(&Widget::Scaffold {
413 title: "T".to_string(),
414 body: Box::new(Widget::Divider),
415 tabs: vec![],
416 back: None,
417 dark_mode: false,
418 theme: Some(Theme {
419 seed: Rgb::new(0xC8, 0x5A, 0x3C),
420 accent: Some(Rgb::new(0xE0, 0x6A, 0x2C)),
421 corner: Corner::Large,
422 density: Density::Compact,
423 font: FontFamily::Rounded,
424 }),
425 fab: Some(Fab { icon: Icon::Calendar, on_press: "f".to_string() }),
426 sheet: Some(Sheet { title: "S".to_string(), child: Box::new(Widget::Divider), on_dismiss: "d".to_string() }),
427 on_refresh: Some("r".to_string()),
428 refreshing: true,
429 route: "r".to_string(),
430 depth: 1,
431 });
432 }
433}