Skip to main content

mobiler_web/
lib.rs

1//! `mobiler-web` — Mobiler's web shell.
2//!
3//! Renders **any** Mobiler app's `Widget` tree to the DOM (Leptos / WASM), driving
4//! the Rust core via crux's `Core` and fulfilling capabilities (HTTP) with the
5//! browser's `fetch`. The web twin of the generic Android/SwiftUI shells: write
6//! your app once as a `MobilerApp`, then
7//!
8//! ```ignore
9//! fn main() { mobiler_web::run::<my_app::App>(); }
10//! ```
11//!
12//! renders it on the web — fully styled, no CSS required: the shell ships its own
13//! theme (`mobiler.css`) and injects it on mount, and `Scaffold.dark_mode` flips
14//! the whole theme. Your crate only supplies a minimal `index.html` with the Trunk
15//! entry point; an app may add its own stylesheet to override any widget class.
16
17use std::cell::RefCell;
18use std::sync::Arc;
19
20use crux_core::{App, Core};
21use leptos::prelude::*;
22use mobiler_core::{
23    Action, BoxAlign, ButtonStyle, CardStyle, Effect, Icon, ImageRatio, ImageShape, InputValue,
24    PluginCall, PluginResponse, ProjectColor, Spacing, TextStyle, Tone, Widget,
25};
26use wasm_bindgen_futures::spawn_local;
27
28/// The shell's own stylesheet — the web twin of the look the Android/SwiftUI shells
29/// decide in code. Shipped with the crate and injected on mount, so `run::<App>()`
30/// renders a fully styled, themeable app with no CSS required from the consuming
31/// app (it can still override any class). Uses CSS variables so `Scaffold.dark_mode`
32/// flips the whole theme by toggling one class.
33const STYLE: &str = include_str!("mobiler.css");
34
35/// Cloneable handle for sending an `Action` into the core. Leptos 0.7 view closures
36/// require `Send`, so this is `Arc` + `Send + Sync` (the crux `Core` is both).
37type Dispatch = Arc<dyn Fn(Action) + Send + Sync>;
38
39/// What a Mobiler app must be to render on the web: a crux `App` speaking the fixed
40/// ABI (`Action` in, `Widget` out, `Effect` for capabilities). `MobilerShell<_>`
41/// satisfies this automatically.
42pub trait WebApp:
43    App<Event = Action, ViewModel = Widget, Effect = Effect> + Default + Send + Sync + 'static
44where
45    Self::Model: Default + Send + Sync,
46{
47}
48impl<T> WebApp for T
49where
50    T: App<Event = Action, ViewModel = Widget, Effect = Effect> + Default + Send + Sync + 'static,
51    T::Model: Default + Send + Sync,
52{
53}
54
55/// Mount a Mobiler app into the document body. Call from your wasm `main`.
56pub fn run<A: WebApp>()
57where
58    A::Model: Default + Send + Sync,
59{
60    console_error_panic_hook::set_once();
61    inject_default_style();
62    leptos::mount::mount_to_body(shell::<A>);
63}
64
65/// Inject the shell's default stylesheet at the **front** of `<head>` so it's the
66/// lowest-precedence baseline: an app that ships its own CSS (later in the document)
67/// overrides any of these classes, while an app with no CSS still gets a full theme.
68fn inject_default_style() {
69    let document = leptos::prelude::document();
70    let Some(head) = document.head() else { return };
71    let Ok(style) = document.create_element("style") else { return };
72    let _ = style.set_attribute("data-mobiler", "shell");
73    style.set_text_content(Some(STYLE));
74    let _ = head.insert_before(&style, head.first_child().as_ref());
75}
76
77fn shell<A: WebApp>() -> impl IntoView
78where
79    A::Model: Default + Send + Sync,
80{
81    let core = Arc::new(Core::<A>::new());
82    let (view, set_view) = signal(core.view());
83
84    let send: Dispatch = {
85        let core = core.clone();
86        Arc::new(move |action: Action| {
87            let effects = core.process_event(action);
88            drive(&core, set_view, effects);
89        })
90    };
91
92    // Fire Start so the app can load initial data (mirrors the native shells).
93    send(Action::Start);
94
95    let send_for_view = send.clone();
96    view! {
97        <div class="app">
98            {move || render(&view.get(), &send_for_view)}
99        </div>
100    }
101}
102
103/// Process effects: re-read the view on Render; fulfil HTTP via fetch and resolve.
104fn drive<A: WebApp>(core: &Arc<Core<A>>, set_view: WriteSignal<Widget>, effects: Vec<Effect>)
105where
106    A::Model: Default + Send + Sync,
107{
108    for effect in effects {
109        match effect {
110            Effect::Render(_) => set_view.set(core.view()),
111            // Fire-and-forget capabilities aren't fulfilled in this minimal shell yet.
112            Effect::PluginNotify(_) => {}
113            Effect::Plugin(mut request) => {
114                let core = core.clone();
115                spawn_local(async move {
116                    let response = perform(&request.operation).await;
117                    if let Ok(next) = core.resolve(&mut request, response) {
118                        drive(&core, set_view, next);
119                    }
120                });
121            }
122        }
123    }
124}
125
126/// Fulfil the `http` capability with `fetch`.
127async fn perform(call: &PluginCall) -> PluginResponse {
128    if call.plugin != "http" {
129        return PluginResponse { ok: false, output: format!("plugin '{}' not available", call.plugin) };
130    }
131    let v: serde_json::Value = serde_json::from_str(&call.input).unwrap_or(serde_json::Value::Null);
132    let url = v.get("url").and_then(serde_json::Value::as_str).unwrap_or("");
133    let body = v.get("body").and_then(serde_json::Value::as_str);
134
135    use gloo_net::http::Request;
136    let builder = match call.op.as_str() {
137        "POST" => Request::post(url),
138        "PATCH" => Request::patch(url),
139        "DELETE" => Request::delete(url),
140        _ => Request::get(url),
141    };
142    let request = match body {
143        Some(b) => builder.header("Content-Type", "application/json").body(b),
144        None => builder.build(),
145    };
146    let request = match request {
147        Ok(r) => r,
148        Err(e) => return PluginResponse { ok: false, output: e.to_string() },
149    };
150    match request.send().await {
151        Ok(resp) => PluginResponse { ok: resp.ok(), output: resp.text().await.unwrap_or_default() },
152        Err(e) => PluginResponse { ok: false, output: e.to_string() },
153    }
154}
155
156// ---------------- Widget → DOM ----------------
157
158/// `Widget` → DOM. **Exhaustive** by construction — the `match` has no catch-all,
159/// so (like the Compose/SwiftUI shells) it won't compile until every `Widget`
160/// variant is handled. Style *intent* (TextStyle, Tone, …) becomes a CSS class;
161/// the concrete look lives in `mobiler.css`.
162fn render(widget: &Widget, send: &Dispatch) -> AnyView {
163    match widget {
164        // ---- content ----
165        Widget::Text { content, style } => {
166            let (class, content) = (text_class(*style), content.clone());
167            view! { <p class=class>{content}</p> }.into_any()
168        }
169        Widget::Image { source, shape, ratio } => {
170            let (class, source) = (image_class(*shape, *ratio), source.clone());
171            view! { <img class=class src=source /> }.into_any()
172        }
173        Widget::Badge { label, tone } => {
174            let (class, label) = (format!("badge {}", tone_class(*tone)), label.clone());
175            view! { <span class=class>{label}</span> }.into_any()
176        }
177        Widget::ColorDot { color } => {
178            view! { <span class=format!("dot {}", dot_class(*color))></span> }.into_any()
179        }
180        Widget::Divider => view! { <hr class="divider" /> }.into_any(),
181        Widget::Spacer { size } => {
182            view! { <div class=format!("spacer {}", spacer_class(*size))></div> }.into_any()
183        }
184
185        // ---- layout ----
186        Widget::Row { children } => {
187            let kids = render_all(children, send);
188            view! { <div class="row">{kids}</div> }.into_any()
189        }
190        Widget::Column { children } => {
191            let kids = render_all(children, send);
192            view! { <div class="col">{kids}</div> }.into_any()
193        }
194        Widget::Card { child, style, on_press } => {
195            let class = format!("card {}", card_class(*style));
196            let body = render(child, send);
197            match on_press {
198                Some(token) => {
199                    let (send, token) = (send.clone(), token.clone());
200                    view! {
201                        <button
202                            class=format!("{class} card-tappable")
203                            on:click=move |_| send(Action::Fired { token: token.clone() })
204                        >
205                            {body}
206                        </button>
207                    }
208                    .into_any()
209                }
210                None => view! { <div class=class>{body}</div> }.into_any(),
211            }
212        }
213        // Z-stack. With `scrim`, the first child is a background image, darkened
214        // by an overlay, and the rest layer on top in light content — the DOM twin
215        // of the Compose `matchParentSize` scrim / SwiftUI `.overlay` on the image.
216        Widget::Box { children, align, scrim } => {
217            let acls = align_class(*align);
218            if *scrim && children.len() > 1 {
219                let bg = render(&children[0], send);
220                let content = render_all(&children[1..], send);
221                view! {
222                    <div class=format!("box box-scrim {acls}")>
223                        {bg}
224                        <div class="scrim"></div>
225                        <div class="box-content">{content}</div>
226                    </div>
227                }
228                .into_any()
229            } else {
230                let kids = render_all(children, send);
231                view! { <div class=format!("box {acls}")>{kids}</div> }.into_any()
232            }
233        }
234        Widget::Grid { children } => {
235            let kids = render_all(children, send);
236            view! { <div class="grid">{kids}</div> }.into_any()
237        }
238
239        // ---- input / actions ----
240        Widget::Button { label, style, on_press } => {
241            let (send, token, label) = (send.clone(), on_press.clone(), label.clone());
242            let class = format!("btn {}", button_class(*style));
243            view! {
244                <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
245                    {label}
246                </button>
247            }
248            .into_any()
249        }
250        Widget::IconButton { icon, on_press } => {
251            let (send, token) = (send.clone(), on_press.clone());
252            let glyph = icon_glyph(*icon);
253            view! {
254                <button class="iconbtn" on:click=move |_| send(Action::Fired { token: token.clone() })>
255                    {glyph}
256                </button>
257            }
258            .into_any()
259        }
260        Widget::Chip { label, selected, on_press } => {
261            let (send, token, label) = (send.clone(), on_press.clone(), label.clone());
262            let class = if *selected { "chip selected" } else { "chip" };
263            view! {
264                <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
265                    {label}
266                </button>
267            }
268            .into_any()
269        }
270        Widget::TextField { id, placeholder, value } => {
271            let (send, id) = (send.clone(), id.clone());
272            let (placeholder, value) = (placeholder.clone(), value.clone());
273            view! {
274                <input
275                    class="field"
276                    placeholder=placeholder
277                    prop:value=value
278                    on:input=move |ev| send(Action::Input {
279                        id: id.clone(),
280                        value: InputValue::Text(event_target_value(&ev)),
281                    })
282                />
283            }
284            .into_any()
285        }
286        Widget::Toggle { id, label, value } => {
287            let (send, id, label, checked) = (send.clone(), id.clone(), label.clone(), *value);
288            view! {
289                <label class="toggle">
290                    {label}
291                    <input
292                        type="checkbox"
293                        role="switch"
294                        prop:checked=checked
295                        on:change=move |ev| send(Action::Input {
296                            id: id.clone(),
297                            value: InputValue::Bool(event_target_checked(&ev)),
298                        })
299                    />
300                </label>
301            }
302            .into_any()
303        }
304        Widget::Checkbox { id, label, value } => {
305            let (send, id, label, checked) = (send.clone(), id.clone(), label.clone(), *value);
306            view! {
307                <label class="check">
308                    <input
309                        type="checkbox"
310                        prop:checked=checked
311                        on:change=move |ev| send(Action::Input {
312                            id: id.clone(),
313                            value: InputValue::Bool(event_target_checked(&ev)),
314                        })
315                    />
316                    {label}
317                </label>
318            }
319            .into_any()
320        }
321        Widget::Slider { id, value, max } => {
322            let (send, id, value, max) = (send.clone(), id.clone(), *value, *max);
323            view! {
324                <input
325                    class="slider"
326                    type="range"
327                    min="0"
328                    max=max
329                    prop:value=value
330                    on:input=move |ev| send(Action::Input {
331                        id: id.clone(),
332                        value: InputValue::Int(event_target_value(&ev).parse().unwrap_or(0)),
333                    })
334                />
335            }
336            .into_any()
337        }
338        Widget::Stepper { value, on_decrement, on_increment } => {
339            let send_dec = send.clone();
340            let send_inc = send.clone();
341            let (dec, inc) = (on_decrement.clone(), on_increment.clone());
342            view! {
343                <div class="stepper">
344                    <button on:click=move |_| send_dec(Action::Fired { token: dec.clone() })>"−"</button>
345                    <span class="stepper-value">{*value}</span>
346                    <button on:click=move |_| send_inc(Action::Fired { token: inc.clone() })>"+"</button>
347                </div>
348            }
349            .into_any()
350        }
351
352        // ---- shell ----
353        Widget::Scaffold { title, body, tabs, back, dark_mode, route, depth } => {
354            let back_btn = back.clone().map(|token| {
355                let send = send.clone();
356                view! {
357                    <button class="back" on:click=move |_| send(Action::Fired { token: token.clone() })>
358                        "‹"
359                    </button>
360                }
361            });
362            let tabbar = (!tabs.is_empty()).then(|| {
363                let tabs: Vec<AnyView> = tabs
364                    .iter()
365                    .map(|tab| {
366                        let (send, token) = (send.clone(), tab.on_select.clone());
367                        let class = if tab.selected { "tab selected" } else { "tab" };
368                        let label = tab.label.clone();
369                        view! {
370                            <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
371                                {label}
372                            </button>
373                        }
374                        .into_any()
375                    })
376                    .collect();
377                view! { <div class="tabbar">{tabs}</div> }
378            });
379            // `theme-dark` flips the CSS variables for the whole shell — theme-as-data,
380            // the web twin of the native shells' `preferredColorScheme`/Material theme.
381            let class = if *dark_mode { "scaffold theme-dark" } else { "scaffold" };
382            let body_class = format!("scaffold-body {}", nav_class(route, *depth));
383            let (title, body) = (title.clone(), render(body, send));
384            view! {
385                <div class=class>
386                    <div class="topbar">
387                        {back_btn}
388                        <span class="title">{title}</span>
389                    </div>
390                    <div class=body_class data-route=route.clone()>{body}</div>
391                    {tabbar}
392                </div>
393            }
394            .into_any()
395        }
396    }
397}
398
399/// Render a slice of children as sibling views.
400fn render_all(children: &[Widget], send: &Dispatch) -> Vec<AnyView> {
401    children.iter().map(|c| render(c, send)).collect()
402}
403
404thread_local! {
405    /// (previous route key, previous depth, alternating toggle). The render is a
406    /// stateless whole-tree rebuild, so nav state lives here (wasm is single-
407    /// threaded). Lets the Scaffold body animate on navigation — the web twin of
408    /// the native shells keying their body on `route`.
409    static NAV: RefCell<(String, u32, bool)> = const { RefCell::new((String::new(), 0, false)) };
410}
411
412/// Pick the Scaffold body's transition class for this render. Returns `""` for a
413/// same-route data update (re-render in place, no transition). On a route change it
414/// returns a directional class — slide-in from the right when `depth` grew (push),
415/// from the left when it shrank (pop), a crossfade for a lateral move — and *alternates*
416/// the `-a`/`-b` suffix each navigation so the CSS animation restarts even though
417/// Leptos reuses the same DOM node.
418fn nav_class(route: &str, depth: u32) -> &'static str {
419    NAV.with_borrow_mut(|(prev_route, prev_depth, toggle)| {
420        if route == prev_route {
421            return "";
422        }
423        let dir = if depth > *prev_depth {
424            ["nav-push-a", "nav-push-b"]
425        } else if depth < *prev_depth {
426            ["nav-pop-a", "nav-pop-b"]
427        } else {
428            ["nav-fade-a", "nav-fade-b"]
429        };
430        *toggle = !*toggle;
431        *prev_route = route.to_string();
432        *prev_depth = depth;
433        dir[usize::from(*toggle)]
434    })
435}
436
437// ---- style intent → CSS class / glyph (the only place that names the look) ----
438
439fn text_class(s: TextStyle) -> &'static str {
440    match s {
441        TextStyle::Title => "t-title",
442        TextStyle::Subtitle => "t-subtitle",
443        TextStyle::Caption => "t-caption",
444        TextStyle::Emphasis => "t-emphasis",
445        TextStyle::Body => "t-body",
446    }
447}
448
449fn button_class(s: ButtonStyle) -> &'static str {
450    match s {
451        ButtonStyle::Filled => "btn-filled",
452        ButtonStyle::Outlined => "btn-outlined",
453        ButtonStyle::Text => "btn-text",
454    }
455}
456
457fn card_class(s: CardStyle) -> &'static str {
458    match s {
459        CardStyle::Elevated => "card-elevated",
460        CardStyle::Outlined => "card-outlined",
461        CardStyle::Filled => "card-filled",
462    }
463}
464
465fn tone_class(t: Tone) -> &'static str {
466    match t {
467        Tone::Neutral => "tone-neutral",
468        Tone::Success => "tone-success",
469        Tone::Warning => "tone-warning",
470        Tone::Danger => "tone-danger",
471        Tone::Info => "tone-info",
472    }
473}
474
475fn spacer_class(s: Spacing) -> &'static str {
476    match s {
477        Spacing::Xs => "sp-xs",
478        Spacing::Sm => "sp-sm",
479        Spacing::Md => "sp-md",
480        Spacing::Lg => "sp-lg",
481        Spacing::Xl => "sp-xl",
482    }
483}
484
485fn icon_glyph(i: Icon) -> &'static str {
486    match i {
487        Icon::Delete => "🗑",
488        Icon::Add => "+",
489        Icon::Edit => "✏️",
490        Icon::Close => "✕",
491        Icon::Settings => "⚙",
492        Icon::Check => "✓",
493        Icon::Star => "★",
494    }
495}
496
497fn image_class(shape: ImageShape, ratio: ImageRatio) -> String {
498    let shape = match shape {
499        ImageShape::Square => "img-square",
500        ImageShape::Rounded => "img-rounded",
501        ImageShape::Circle => "img-circle",
502    };
503    let ratio = match ratio {
504        ImageRatio::Wide => "ratio-wide",
505        ImageRatio::Square => "ratio-square",
506        ImageRatio::Tall => "ratio-tall",
507    };
508    format!("img {shape} {ratio}")
509}
510
511fn dot_class(c: ProjectColor) -> &'static str {
512    match c {
513        ProjectColor::Indigo => "dot-indigo",
514        ProjectColor::Teal => "dot-teal",
515        ProjectColor::Coral => "dot-coral",
516        ProjectColor::Amber => "dot-amber",
517        ProjectColor::Lime => "dot-lime",
518        ProjectColor::Pink => "dot-pink",
519    }
520}
521
522fn align_class(a: BoxAlign) -> &'static str {
523    match a {
524        BoxAlign::TopStart => "align-top-start",
525        BoxAlign::TopEnd => "align-top-end",
526        BoxAlign::Center => "align-center",
527        BoxAlign::BottomStart => "align-bottom-start",
528        BoxAlign::BottomCenter => "align-bottom-center",
529        BoxAlign::BottomEnd => "align-bottom-end",
530    }
531}