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 }
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#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
67#[repr(C)]
68pub enum Spacing { Xs, Sm, Md, Lg, Xl }
69
70/// A small, finite icon set (maps to Material icons in the shell).
71#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
72#[repr(C)]
73pub enum Icon { Delete, Add, Edit, Close, Settings, Check, Star }
74
75#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
76#[repr(C)]
77pub enum ImageShape { Square, Rounded, Circle }
78
79#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
80#[repr(C)]
81pub enum ImageRatio { Wide, Square, Tall }
82
83#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
84#[repr(C)]
85pub enum BoxAlign { TopStart, TopEnd, Center, BottomStart, BottomCenter, BottomEnd }
86
87/// Project-identity colors (distinct from semantic `Tone`). Concrete RGB decided
88/// in the render layer.
89#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
90#[repr(C)]
91pub enum ProjectColor { Indigo, Teal, Coral, Amber, Lime, Pink }
92
93// ------------------------------- theme -------------------------------
94
95/// A 24-bit RGB color. Used for a theme's brand/seed color — the one place an app
96/// supplies an arbitrary color (everything else is intent tokens).
97#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
98#[repr(C)]
99pub struct Rgb {
100    pub r: u8,
101    pub g: u8,
102    pub b: u8,
103}
104
105impl Rgb {
106    pub const fn new(r: u8, g: u8, b: u8) -> Self {
107        Self { r, g, b }
108    }
109}
110
111/// Global corner-radius scale. `Medium` ≈ the current (un-themed) look.
112#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
113#[repr(C)]
114pub enum Corner { None, Small, Medium, Large }
115
116/// Global spacing scale. `Comfortable` ≈ the current (un-themed) spacing.
117#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
118#[repr(C)]
119pub enum Density { Compact, Comfortable }
120
121/// A finite, cross-platform font family (maps to each platform's nearest system
122/// font design — no bundled font files). `System` ≈ the current look.
123#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
124#[repr(C)]
125pub enum FontFamily { System, Rounded, Serif, Monospace }
126
127/// App branding as data — the visual twin of `dark_mode`. Set on a [`Widget::Scaffold`]
128/// (`theme: None` = the framework defaults, i.e. no visual change). The shell maps these
129/// to its native theming: `seed` → the brand/primary color (Android M3 scheme / iOS tint /
130/// web `--primary`), plus a global corner, spacing, and font choice.
131#[derive(Facet, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
132#[repr(C)]
133pub struct Theme {
134    pub seed: Rgb,
135    pub corner: Corner,
136    pub density: Density,
137    pub font: FontFamily,
138}
139
140/// `Theme::default()` matches the framework's un-themed look as closely as a theme can
141/// (medium corners, comfortable spacing, system font) with a neutral indigo seed — so an
142/// app can override just the bits it cares about: `Theme { seed: brand, ..Default::default() }`.
143impl Default for Theme {
144    fn default() -> Self {
145        Theme {
146            seed: Rgb::new(0x5C, 0x6B, 0xC0), // indigo — matches the legacy default accent
147            corner: Corner::Medium,
148            density: Density::Comfortable,
149            font: FontFamily::System,
150        }
151    }
152}
153
154/// A bottom-navigation tab. `selected` marks the active one; tapping sends
155/// `on_select`.
156#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
157#[repr(C)]
158pub struct Tab {
159    pub label: String,
160    pub selected: bool,
161    pub on_select: ActionToken,
162}
163
164// ------------------------------- widgets -------------------------------
165
166/// The app-agnostic widget tree the shell renders. **Fixed across all apps.**
167#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
168#[repr(C)]
169pub enum Widget {
170    // Content
171    Text { content: String, style: TextStyle },
172    Image { source: String, shape: ImageShape, ratio: ImageRatio },
173    Badge { label: String, tone: Tone },
174    /// Small non-interactive colored dot — a project/identity hint.
175    ColorDot { color: ProjectColor },
176    Divider,
177    Spacer { size: Spacing },
178    // Layout
179    Row { children: Vec<Widget> },
180    Column { children: Vec<Widget> },
181    /// Card; tappable when `on_press` is set.
182    Card { child: Box<Widget>, style: CardStyle, on_press: Option<ActionToken> },
183    /// Z-stack: children layered back-to-front, positioned by `align`. With
184    /// `scrim`, the first child is a background image, darkened for legibility,
185    /// and the rest render on top in light content.
186    Box { children: Vec<Widget>, align: BoxAlign, scrim: bool },
187    /// Fixed 2-column grid; children flow left-to-right, top-to-bottom.
188    Grid { children: Vec<Widget> },
189    // Input
190    Button { label: String, style: ButtonStyle, on_press: ActionToken },
191    IconButton { icon: Icon, on_press: ActionToken },
192    Chip { label: String, selected: bool, on_press: ActionToken },
193    TextField { id: String, placeholder: String, value: String },
194    Toggle { id: String, label: String, value: bool },
195    Checkbox { id: String, label: String, value: bool },
196    /// Continuous 0..=`max` slider; emits `Input { id, Int }`.
197    Slider { id: String, value: i32, max: i32 },
198    /// Numeric stepper with −/+ controls carrying their own events.
199    Stepper { value: i32, on_decrement: ActionToken, on_increment: ActionToken },
200    /// App shell: a top bar (`title` + optional `back`), a scrollable `body`,
201    /// and bottom-nav `tabs`. `dark_mode` is theme-as-data — the shell themes
202    /// the whole app from it.
203    ///
204    /// `route` + `depth` drive navigation: the shell animates the body when
205    /// `route` (the current screen's identity) changes — slide for push/pop
206    /// (direction from whether `depth` grew or shrank), crossfade for a lateral
207    /// move at the same depth — and wires the system back button to `back`.
208    Scaffold {
209        title: String,
210        body: Box<Widget>,
211        tabs: Vec<Tab>,
212        back: Option<ActionToken>,
213        dark_mode: bool,
214        /// App branding (brand color, corner, density, font). `None` = framework
215        /// defaults (no visual change) — theme-as-data, the visual twin of `dark_mode`.
216        theme: Option<Theme>,
217        route: String,
218        depth: u32,
219    },
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use serde::Serialize;
226    use serde::de::DeserializeOwned;
227
228    // Round-trips the ABI without requiring `PartialEq` on the wire types:
229    // serialize → deserialize → re-serialize, and compare the two encodings.
230    fn round_trips<T: Serialize + DeserializeOwned>(value: &T) {
231        let a = serde_json::to_string(value).expect("serialize");
232        let back: T = serde_json::from_str(&a).expect("deserialize");
233        let b = serde_json::to_string(&back).expect("re-serialize");
234        assert_eq!(a, b);
235    }
236
237    #[test]
238    fn action_round_trips() {
239        round_trips(&Action::Start);
240        round_trips(&Action::Fired { token: "tok".to_string() });
241        round_trips(&Action::Input { id: "field".to_string(), value: InputValue::Bool(true) });
242        round_trips(&Action::Restore { data: "{}".to_string() });
243    }
244
245    #[test]
246    fn widget_round_trips() {
247        round_trips(&Widget::Text { content: "hi".to_string(), style: TextStyle::Title });
248        round_trips(&Widget::ColorDot { color: ProjectColor::Teal });
249        // Un-themed scaffold (theme: None) — the default, must round-trip.
250        round_trips(&Widget::Scaffold {
251            title: "T".to_string(),
252            body: Box::new(Widget::Divider),
253            tabs: vec![Tab { label: "A".to_string(), selected: true, on_select: "t".to_string() }],
254            back: Some("b".to_string()),
255            dark_mode: true,
256            theme: None,
257            route: "r".to_string(),
258            depth: 2,
259        });
260        // Themed scaffold — all four theme knobs must round-trip.
261        round_trips(&Widget::Scaffold {
262            title: "T".to_string(),
263            body: Box::new(Widget::Divider),
264            tabs: vec![],
265            back: None,
266            dark_mode: false,
267            theme: Some(Theme {
268                seed: Rgb::new(0xC8, 0x5A, 0x3C),
269                corner: Corner::Large,
270                density: Density::Compact,
271                font: FontFamily::Rounded,
272            }),
273            route: "r".to_string(),
274            depth: 1,
275        });
276    }
277}