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