Skip to main content

mobiler_core/
lib.rs

1//! Mobiler runtime — the developer-facing API.
2//!
3//! Implement [`MobilerApp`] with your **typed** events, model, and view (built
4//! from the [builders](#functions)). Mobiler wraps it in [`MobilerShell`], a
5//! Crux app speaking the fixed UI ABI ([`mobiler_ui`]); you never touch the wire
6//! protocol. Device APIs are capabilities via [`Cx`].
7
8use std::marker::PhantomData;
9
10use crux_core::{
11    App, Command,
12    capability::Operation,
13    macros::effect,
14    render::{RenderOperation, render},
15};
16use facet::Facet;
17use serde::{Deserialize, Serialize, de::DeserializeOwned};
18
19pub use mobiler_ui::{
20    Action, BoxAlign, ButtonStyle, CardStyle, Icon, ImageRatio, ImageShape, InputValue,
21    ProjectColor, Spacing, Tab, TextStyle, Tone, Widget,
22};
23
24// ============================ capabilities ============================
25
26/// Built-in capabilities the generic shell fulfils.
27#[effect(facet_typegen)]
28#[derive(Debug)]
29pub enum Effect {
30    Render(RenderOperation),
31    /// Fire-and-forget plugin call (shell does not resolve).
32    PluginNotify(PluginNotify),
33    /// Request/response plugin call (shell resolves with a [`PluginResponse`]).
34    Plugin(PluginCall),
35}
36
37#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
38pub struct PluginNotify {
39    pub plugin: String,
40    pub op: String,
41    pub input: String,
42}
43impl Operation for PluginNotify {
44    type Output = ();
45}
46
47#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
48pub struct PluginCall {
49    pub plugin: String,
50    pub op: String,
51    pub input: String,
52}
53impl Operation for PluginCall {
54    type Output = PluginResponse;
55}
56
57#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
58pub struct PluginResponse {
59    pub ok: bool,
60    pub output: String,
61}
62
63type Continuation<E> = Box<dyn FnOnce(PluginResponse) -> E + Send>;
64
65/// Effects an app requests during `update`, generic over the app event type so
66/// continuations stay fully typed.
67pub struct Cx<E> {
68    notifications: Vec<PluginNotify>,
69    requests: Vec<(PluginCall, Continuation<E>)>,
70}
71
72impl<E> Default for Cx<E> {
73    fn default() -> Self {
74        Self { notifications: Vec::new(), requests: Vec::new() }
75    }
76}
77
78impl<E> Cx<E> {
79    /// Fire-and-forget call to a native plugin.
80    pub fn notify(&mut self, plugin: impl Into<String>, op: impl Into<String>, input: impl Into<String>) {
81        self.notifications.push(PluginNotify { plugin: plugin.into(), op: op.into(), input: input.into() });
82    }
83
84    /// Request/response call: when the plugin replies, `then(response)` produces
85    /// the typed event delivered back to your `update`.
86    pub fn plugin(
87        &mut self,
88        plugin: impl Into<String>,
89        op: impl Into<String>,
90        input: impl Into<String>,
91        then: impl FnOnce(PluginResponse) -> E + Send + 'static,
92    ) {
93        self.requests
94            .push((PluginCall { plugin: plugin.into(), op: op.into(), input: input.into() }, Box::new(then)));
95    }
96
97    /// Persist `data` (handed back to [`MobilerApp::restore`] on next startup).
98    pub fn save(&mut self, data: impl Into<String>) {
99        self.notify("storage", "save", data);
100    }
101}
102
103// ============================ the app trait ============================
104
105/// What a Mobiler app implements. Write typed domain events; Mobiler serializes
106/// them into opaque tokens behind the scenes.
107pub trait MobilerApp: Default {
108    type Event: Serialize + DeserializeOwned + Send + 'static;
109    type Model: Default;
110
111    fn update(&self, event: Self::Event, model: &mut Self::Model, cx: &mut Cx<Self::Event>);
112
113    fn input(&self, id: &str, value: InputValue, model: &mut Self::Model, cx: &mut Cx<Self::Event>) {
114        let _ = (id, value, model, cx);
115    }
116
117    /// Restore persisted state on startup. `data` is whatever you last passed to
118    /// `cx.save` (or empty if nothing was saved). Default: ignore.
119    fn restore(&self, data: &str, model: &mut Self::Model) {
120        let _ = (data, model);
121    }
122
123    fn view(&self, model: &Self::Model) -> Widget;
124}
125
126/// Crux adapter: turns a [`MobilerApp`] into an app speaking the fixed ABI.
127pub struct MobilerShell<A>(PhantomData<fn() -> A>);
128
129impl<A> Default for MobilerShell<A> {
130    fn default() -> Self {
131        Self(PhantomData)
132    }
133}
134
135impl<A: MobilerApp> App for MobilerShell<A> {
136    type Event = Action;
137    type Model = A::Model;
138    type ViewModel = Widget;
139    type Effect = Effect;
140
141    fn update(&self, action: Action, model: &mut Self::Model) -> Command<Effect, Action> {
142        let app = A::default();
143        let mut cx = Cx::<A::Event>::default();
144        match action {
145            Action::Fired { token } => {
146                if let Ok(event) = serde_json::from_str::<A::Event>(&token) {
147                    app.update(event, model, &mut cx);
148                }
149            }
150            Action::Input { id, value } => app.input(&id, value, model, &mut cx),
151            Action::Restore { data } => app.restore(&data, model),
152        }
153        let mut commands: Vec<Command<Effect, Action>> = Vec::new();
154        for op in cx.notifications {
155            commands.push(Command::notify_shell(op).build());
156        }
157        for (op, then) in cx.requests {
158            commands.push(Command::request_from_shell(op).then_send(move |response: PluginResponse| {
159                Action::Fired { token: serde_json::to_string(&then(response)).expect("serialize event") }
160            }));
161        }
162        commands.push(render());
163        Command::all(commands)
164    }
165
166    fn view(&self, model: &Self::Model) -> Widget {
167        A::default().view(model)
168    }
169}
170
171// ============================ widget builders ============================
172// Action-carrying builders take a TYPED event and serialize it into a token.
173
174fn tok<E: Serialize>(event: E) -> String {
175    serde_json::to_string(&event).expect("serialize event")
176}
177
178#[must_use]
179pub fn styled(content: impl Into<String>, style: TextStyle) -> Widget {
180    Widget::Text { content: content.into(), style }
181}
182#[must_use]
183pub fn text(content: impl Into<String>) -> Widget { styled(content, TextStyle::Body) }
184#[must_use]
185pub fn title(content: impl Into<String>) -> Widget { styled(content, TextStyle::Title) }
186#[must_use]
187pub fn subtitle(content: impl Into<String>) -> Widget { styled(content, TextStyle::Subtitle) }
188#[must_use]
189pub fn caption(content: impl Into<String>) -> Widget { styled(content, TextStyle::Caption) }
190#[must_use]
191pub fn emphasis(content: impl Into<String>) -> Widget { styled(content, TextStyle::Emphasis) }
192
193#[must_use]
194pub fn image(source: impl Into<String>, shape: ImageShape, ratio: ImageRatio) -> Widget {
195    Widget::Image { source: source.into(), shape, ratio }
196}
197#[must_use]
198pub fn badge(label: impl Into<String>, tone: Tone) -> Widget {
199    Widget::Badge { label: label.into(), tone }
200}
201/// A small colored identity dot.
202#[must_use]
203pub fn color_dot(color: ProjectColor) -> Widget {
204    Widget::ColorDot { color }
205}
206#[must_use]
207pub fn divider() -> Widget { Widget::Divider }
208#[must_use]
209pub fn spacer(size: Spacing) -> Widget { Widget::Spacer { size } }
210
211#[must_use]
212pub fn row(children: Vec<Widget>) -> Widget { Widget::Row { children } }
213#[must_use]
214pub fn column(children: Vec<Widget>) -> Widget { Widget::Column { children } }
215#[must_use]
216pub fn card(child: Widget, style: CardStyle) -> Widget {
217    Widget::Card { child: Box::new(child), style, on_press: None }
218}
219/// A tappable card carrying a typed press event.
220#[must_use]
221pub fn card_button<E: Serialize>(child: Widget, style: CardStyle, on_press: E) -> Widget {
222    Widget::Card { child: Box::new(child), style, on_press: Some(tok(on_press)) }
223}
224/// Z-stack/overlay (the `Box` widget). With `scrim`, the first child is a
225/// darkened background and the rest render on top.
226#[must_use]
227pub fn stack(align: BoxAlign, scrim: bool, children: Vec<Widget>) -> Widget {
228    Widget::Box { children, align, scrim }
229}
230#[must_use]
231pub fn grid(children: Vec<Widget>) -> Widget { Widget::Grid { children } }
232
233#[must_use]
234pub fn button<E: Serialize>(label: impl Into<String>, style: ButtonStyle, on_press: E) -> Widget {
235    Widget::Button { label: label.into(), style, on_press: tok(on_press) }
236}
237#[must_use]
238pub fn icon_button<E: Serialize>(icon: Icon, on_press: E) -> Widget {
239    Widget::IconButton { icon, on_press: tok(on_press) }
240}
241#[must_use]
242pub fn chip<E: Serialize>(label: impl Into<String>, selected: bool, on_press: E) -> Widget {
243    Widget::Chip { label: label.into(), selected, on_press: tok(on_press) }
244}
245#[must_use]
246pub fn text_field(id: impl Into<String>, placeholder: impl Into<String>, value: impl Into<String>) -> Widget {
247    Widget::TextField { id: id.into(), placeholder: placeholder.into(), value: value.into() }
248}
249#[must_use]
250pub fn switch(id: impl Into<String>, label: impl Into<String>, value: bool) -> Widget {
251    Widget::Switch { id: id.into(), label: label.into(), value }
252}
253#[must_use]
254pub fn checkbox(id: impl Into<String>, label: impl Into<String>, value: bool) -> Widget {
255    Widget::Checkbox { id: id.into(), label: label.into(), value }
256}
257#[must_use]
258pub fn slider(id: impl Into<String>, value: i32, max: i32) -> Widget {
259    Widget::Slider { id: id.into(), value, max }
260}
261#[must_use]
262pub fn stepper<E: Serialize>(value: i32, on_decrement: E, on_increment: E) -> Widget {
263    Widget::Stepper { value, on_decrement: tok(on_decrement), on_increment: tok(on_increment) }
264}
265
266/// A bottom-nav tab carrying a typed selection event.
267#[must_use]
268pub fn tab<E: Serialize>(label: impl Into<String>, selected: bool, on_select: E) -> Tab {
269    Tab { label: label.into(), selected, on_select: tok(on_select) }
270}
271
272/// App shell: top bar + bottom-nav `tabs` + scrollable `body`. `dark_mode` is
273/// theme-as-data (the shell themes the whole app from it).
274#[must_use]
275pub fn scaffold(title: impl Into<String>, dark_mode: bool, tabs: Vec<Tab>, body: Widget) -> Widget {
276    Widget::Scaffold { title: title.into(), body: Box::new(body), tabs, back: None, dark_mode }
277}
278
279/// Like [`scaffold`], but the top bar shows a back arrow firing `back` (e.g. for
280/// a detail screen pushed over a tab).
281#[must_use]
282pub fn scaffold_back<E: Serialize>(title: impl Into<String>, dark_mode: bool, tabs: Vec<Tab>, body: Widget, back: E) -> Widget {
283    Widget::Scaffold { title: title.into(), body: Box::new(body), tabs, back: Some(tok(back)), dark_mode }
284}