1use 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, PluginNotify, PluginResponse, ProjectColor, Spacing, TextStyle, Tone, Widget,
25};
26use wasm_bindgen_futures::spawn_local;
27
28const STYLE: &str = include_str!("mobiler.css");
34
35type Dispatch = Arc<dyn Fn(Action) + Send + Sync>;
38
39pub 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
55pub 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
65fn 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 let saved = local_storage().and_then(|s| s.get_item(STORAGE_KEY).ok().flatten()).unwrap_or_default();
95 if !saved.is_empty() {
96 send(Action::Restore { data: saved });
97 }
98 send(Action::Start);
99
100 let send_for_view = send.clone();
101 view! {
102 <div class="app">
103 {move || render(&view.get(), &send_for_view)}
104 </div>
105 }
106}
107
108fn drive<A: WebApp>(core: &Arc<Core<A>>, set_view: WriteSignal<Widget>, effects: Vec<Effect>)
110where
111 A::Model: Default + Send + Sync,
112{
113 for effect in effects {
114 match effect {
115 Effect::Render(_) => set_view.set(core.view()),
116 Effect::PluginNotify(notify) => perform_notify(¬ify.operation),
117 Effect::Plugin(mut request) => {
118 let core = core.clone();
119 spawn_local(async move {
120 let response = perform(&request.operation).await;
121 if let Ok(next) = core.resolve(&mut request, response) {
122 drive(&core, set_view, next);
123 }
124 });
125 }
126 }
127 }
128}
129
130async fn perform(call: &PluginCall) -> PluginResponse {
133 if call.plugin == "device" {
134 let ua = web_sys::window()
135 .and_then(|w| w.navigator().user_agent().ok())
136 .unwrap_or_default();
137 return PluginResponse { ok: true, output: ua };
138 }
139 if call.plugin == "photo" && call.op == "pick" {
140 return take_image(false).await;
141 }
142 if call.plugin == "camera" && call.op == "capture" {
143 return take_image(true).await;
144 }
145 if call.plugin == "dialog" && call.op == "confirm" {
146 let v: serde_json::Value = serde_json::from_str(&call.input).unwrap_or(serde_json::Value::Null);
147 let title = v.get("title").and_then(serde_json::Value::as_str).unwrap_or("");
148 let message = v.get("message").and_then(serde_json::Value::as_str).unwrap_or("");
149 let prompt = if title.is_empty() { message.to_string() } else { format!("{title}\n\n{message}") };
150 let ok = web_sys::window()
151 .and_then(|w| w.confirm_with_message(&prompt).ok())
152 .unwrap_or(false);
153 return PluginResponse { ok, output: if ok { "ok".into() } else { "cancel".into() } };
154 }
155 if call.plugin != "http" {
156 return PluginResponse { ok: false, output: format!("plugin '{}' not available", call.plugin) };
157 }
158 let v: serde_json::Value = serde_json::from_str(&call.input).unwrap_or(serde_json::Value::Null);
159 let url = v.get("url").and_then(serde_json::Value::as_str).unwrap_or("");
160 let body = v.get("body").and_then(serde_json::Value::as_str);
161
162 use gloo_net::http::Request;
163 let builder = match call.op.as_str() {
164 "POST" => Request::post(url),
165 "PATCH" => Request::patch(url),
166 "DELETE" => Request::delete(url),
167 _ => Request::get(url),
168 };
169 let request = match body {
170 Some(b) => builder.header("Content-Type", "application/json").body(b),
171 None => builder.build(),
172 };
173 let request = match request {
174 Ok(r) => r,
175 Err(e) => return PluginResponse { ok: false, output: e.to_string() },
176 };
177 match request.send().await {
178 Ok(resp) => PluginResponse { ok: resp.ok(), output: resp.text().await.unwrap_or_default() },
179 Err(e) => PluginResponse { ok: false, output: e.to_string() },
180 }
181}
182
183async fn take_image(capture: bool) -> PluginResponse {
190 use wasm_bindgen::{closure::Closure, JsCast};
191 let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
192 return PluginResponse { ok: false, output: "no document".into() };
193 };
194 let Some(input) = doc.create_element("input").ok().and_then(|e| e.dyn_into::<web_sys::HtmlInputElement>().ok()) else {
195 return PluginResponse { ok: false, output: "no input element".into() };
196 };
197 input.set_type("file");
198 input.set_accept("image/*");
199 if capture {
200 let _ = input.set_attribute("capture", "environment");
202 }
203
204 let (tx, rx) = futures_channel::oneshot::channel::<Option<String>>();
205 let tx = std::cell::RefCell::new(Some(tx));
206 let input_for_cb = input.clone();
207 let on_change = Closure::wrap(Box::new(move || {
208 let url = input_for_cb
209 .files()
210 .and_then(|files| files.get(0))
211 .and_then(|file| web_sys::Url::create_object_url_with_blob(&file).ok());
212 if let Some(tx) = tx.borrow_mut().take() {
213 let _ = tx.send(url);
214 }
215 }) as Box<dyn FnMut()>);
216 input.set_onchange(Some(on_change.as_ref().unchecked_ref()));
217 input.click();
218 on_change.forget(); match rx.await {
221 Ok(Some(url)) => PluginResponse { ok: true, output: url },
222 _ => PluginResponse { ok: false, output: "cancelled".into() },
223 }
224}
225
226const STORAGE_KEY: &str = "mobiler.state";
227
228fn local_storage() -> Option<web_sys::Storage> {
230 web_sys::window()?.local_storage().ok().flatten()
231}
232
233fn perform_notify(notify: &PluginNotify) {
237 let win = match web_sys::window() {
238 Some(w) => w,
239 None => return,
240 };
241 match (notify.plugin.as_str(), notify.op.as_str()) {
242 ("storage", "save") => {
244 if let Some(s) = local_storage() {
245 let _ = s.set_item(STORAGE_KEY, ¬ify.input);
246 }
247 }
248 ("clipboard", "copy") => {
250 let _ = win.navigator().clipboard().write_text(¬ify.input);
251 }
252 ("browser", "open") => {
254 let _ = win.open_with_url_and_target(¬ify.input, "_blank");
255 }
256 ("share", _) => {
259 let _ = win.navigator().clipboard().write_text(¬ify.input);
260 }
261 ("toast", _) => show_toast(¬ify.input),
263 ("haptics", style) => {
265 let ms = match style {
266 "light" => 15,
267 "heavy" => 50,
268 _ => 30, };
270 let _ = win.navigator().vibrate_with_duration(ms);
271 }
272 _ => {} }
274}
275
276fn show_toast(text: &str) {
279 let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return };
280 let (Ok(el), Some(body)) = (doc.create_element("div"), doc.body()) else { return };
281 el.set_class_name("toast");
282 el.set_text_content(Some(text));
283 let _ = body.append_child(&el);
284 gloo_timers::callback::Timeout::new(2600, move || el.remove()).forget();
285}
286
287fn render(widget: &Widget, send: &Dispatch) -> AnyView {
294 match widget {
295 Widget::Text { content, style } => {
297 let (class, content) = (text_class(*style), content.clone());
298 view! { <p class=class>{content}</p> }.into_any()
299 }
300 Widget::Image { source, shape, ratio } => {
301 let (class, source) = (image_class(*shape, *ratio), source.clone());
302 view! { <img class=class src=source /> }.into_any()
303 }
304 Widget::Badge { label, tone } => {
305 let (class, label) = (format!("badge {}", tone_class(*tone)), label.clone());
306 view! { <span class=class>{label}</span> }.into_any()
307 }
308 Widget::ColorDot { color } => {
309 view! { <span class=format!("dot {}", dot_class(*color))></span> }.into_any()
310 }
311 Widget::Divider => view! { <hr class="divider" /> }.into_any(),
312 Widget::Spacer { size } => {
313 view! { <div class=format!("spacer {}", spacer_class(*size))></div> }.into_any()
314 }
315
316 Widget::Row { children } => {
318 let kids = render_all(children, send);
319 view! { <div class="row">{kids}</div> }.into_any()
320 }
321 Widget::Column { children } => {
322 let kids = render_all(children, send);
323 view! { <div class="col">{kids}</div> }.into_any()
324 }
325 Widget::Card { child, style, on_press } => {
326 let class = format!("card {}", card_class(*style));
327 let body = render(child, send);
328 match on_press {
329 Some(token) => {
330 let (send, token) = (send.clone(), token.clone());
331 view! {
332 <button
333 class=format!("{class} card-tappable")
334 on:click=move |_| send(Action::Fired { token: token.clone() })
335 >
336 {body}
337 </button>
338 }
339 .into_any()
340 }
341 None => view! { <div class=class>{body}</div> }.into_any(),
342 }
343 }
344 Widget::Box { children, align, scrim } => {
348 let acls = align_class(*align);
349 if *scrim && children.len() > 1 {
350 let bg = render(&children[0], send);
351 let content = render_all(&children[1..], send);
352 view! {
353 <div class=format!("box box-scrim {acls}")>
354 {bg}
355 <div class="scrim"></div>
356 <div class="box-content">{content}</div>
357 </div>
358 }
359 .into_any()
360 } else {
361 let kids = render_all(children, send);
362 view! { <div class=format!("box {acls}")>{kids}</div> }.into_any()
363 }
364 }
365 Widget::Grid { children } => {
366 let kids = render_all(children, send);
367 view! { <div class="grid">{kids}</div> }.into_any()
368 }
369
370 Widget::Button { label, style, on_press } => {
372 let (send, token, label) = (send.clone(), on_press.clone(), label.clone());
373 let class = format!("btn {}", button_class(*style));
374 view! {
375 <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
376 {label}
377 </button>
378 }
379 .into_any()
380 }
381 Widget::IconButton { icon, on_press } => {
382 let (send, token) = (send.clone(), on_press.clone());
383 let glyph = icon_glyph(*icon);
384 view! {
385 <button class="iconbtn" on:click=move |_| send(Action::Fired { token: token.clone() })>
386 {glyph}
387 </button>
388 }
389 .into_any()
390 }
391 Widget::Chip { label, selected, on_press } => {
392 let (send, token, label) = (send.clone(), on_press.clone(), label.clone());
393 let class = if *selected { "chip selected" } else { "chip" };
394 view! {
395 <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
396 {label}
397 </button>
398 }
399 .into_any()
400 }
401 Widget::TextField { id, placeholder, value } => {
402 let (send, id) = (send.clone(), id.clone());
403 let (placeholder, value) = (placeholder.clone(), value.clone());
404 view! {
405 <input
406 class="field"
407 placeholder=placeholder
408 prop:value=value
409 on:input=move |ev| send(Action::Input {
410 id: id.clone(),
411 value: InputValue::Text(event_target_value(&ev)),
412 })
413 />
414 }
415 .into_any()
416 }
417 Widget::Toggle { id, label, value } => {
418 let (send, id, label, checked) = (send.clone(), id.clone(), label.clone(), *value);
419 view! {
420 <label class="toggle">
421 {label}
422 <input
423 type="checkbox"
424 role="switch"
425 prop:checked=checked
426 on:change=move |ev| send(Action::Input {
427 id: id.clone(),
428 value: InputValue::Bool(event_target_checked(&ev)),
429 })
430 />
431 </label>
432 }
433 .into_any()
434 }
435 Widget::Checkbox { id, label, value } => {
436 let (send, id, label, checked) = (send.clone(), id.clone(), label.clone(), *value);
437 view! {
438 <label class="check">
439 <input
440 type="checkbox"
441 prop:checked=checked
442 on:change=move |ev| send(Action::Input {
443 id: id.clone(),
444 value: InputValue::Bool(event_target_checked(&ev)),
445 })
446 />
447 {label}
448 </label>
449 }
450 .into_any()
451 }
452 Widget::Slider { id, value, max } => {
453 let (send, id, value, max) = (send.clone(), id.clone(), *value, *max);
454 view! {
455 <input
456 class="slider"
457 type="range"
458 min="0"
459 max=max
460 prop:value=value
461 on:input=move |ev| send(Action::Input {
462 id: id.clone(),
463 value: InputValue::Int(event_target_value(&ev).parse().unwrap_or(0)),
464 })
465 />
466 }
467 .into_any()
468 }
469 Widget::Stepper { value, on_decrement, on_increment } => {
470 let send_dec = send.clone();
471 let send_inc = send.clone();
472 let (dec, inc) = (on_decrement.clone(), on_increment.clone());
473 view! {
474 <div class="stepper">
475 <button on:click=move |_| send_dec(Action::Fired { token: dec.clone() })>"−"</button>
476 <span class="stepper-value">{*value}</span>
477 <button on:click=move |_| send_inc(Action::Fired { token: inc.clone() })>"+"</button>
478 </div>
479 }
480 .into_any()
481 }
482
483 Widget::Scaffold { title, body, tabs, back, dark_mode, route, depth } => {
485 let back_btn = back.clone().map(|token| {
486 let send = send.clone();
487 view! {
488 <button class="back" on:click=move |_| send(Action::Fired { token: token.clone() })>
489 "‹"
490 </button>
491 }
492 });
493 let tabbar = (!tabs.is_empty()).then(|| {
494 let tabs: Vec<AnyView> = tabs
495 .iter()
496 .map(|tab| {
497 let (send, token) = (send.clone(), tab.on_select.clone());
498 let class = if tab.selected { "tab selected" } else { "tab" };
499 let label = tab.label.clone();
500 view! {
501 <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
502 {label}
503 </button>
504 }
505 .into_any()
506 })
507 .collect();
508 view! { <div class="tabbar">{tabs}</div> }
509 });
510 let class = if *dark_mode { "scaffold theme-dark" } else { "scaffold" };
513 let body_class = format!("scaffold-body {}", nav_class(route, *depth));
514 let (title, body) = (title.clone(), render(body, send));
515 view! {
516 <div class=class>
517 <div class="topbar">
518 {back_btn}
519 <span class="title">{title}</span>
520 </div>
521 <div class=body_class data-route=route.clone()>{body}</div>
522 {tabbar}
523 </div>
524 }
525 .into_any()
526 }
527 }
528}
529
530fn render_all(children: &[Widget], send: &Dispatch) -> Vec<AnyView> {
532 children.iter().map(|c| render(c, send)).collect()
533}
534
535thread_local! {
536 static NAV: RefCell<(String, u32, bool)> = const { RefCell::new((String::new(), 0, false)) };
541}
542
543fn nav_class(route: &str, depth: u32) -> &'static str {
550 NAV.with_borrow_mut(|(prev_route, prev_depth, toggle)| {
551 if route == prev_route {
552 return "";
553 }
554 let dir = if depth > *prev_depth {
555 ["nav-push-a", "nav-push-b"]
556 } else if depth < *prev_depth {
557 ["nav-pop-a", "nav-pop-b"]
558 } else {
559 ["nav-fade-a", "nav-fade-b"]
560 };
561 *toggle = !*toggle;
562 *prev_route = route.to_string();
563 *prev_depth = depth;
564 dir[usize::from(*toggle)]
565 })
566}
567
568fn text_class(s: TextStyle) -> &'static str {
571 match s {
572 TextStyle::Title => "t-title",
573 TextStyle::Subtitle => "t-subtitle",
574 TextStyle::Caption => "t-caption",
575 TextStyle::Emphasis => "t-emphasis",
576 TextStyle::Body => "t-body",
577 }
578}
579
580fn button_class(s: ButtonStyle) -> &'static str {
581 match s {
582 ButtonStyle::Filled => "btn-filled",
583 ButtonStyle::Outlined => "btn-outlined",
584 ButtonStyle::Text => "btn-text",
585 }
586}
587
588fn card_class(s: CardStyle) -> &'static str {
589 match s {
590 CardStyle::Elevated => "card-elevated",
591 CardStyle::Outlined => "card-outlined",
592 CardStyle::Filled => "card-filled",
593 }
594}
595
596fn tone_class(t: Tone) -> &'static str {
597 match t {
598 Tone::Neutral => "tone-neutral",
599 Tone::Success => "tone-success",
600 Tone::Warning => "tone-warning",
601 Tone::Danger => "tone-danger",
602 Tone::Info => "tone-info",
603 }
604}
605
606fn spacer_class(s: Spacing) -> &'static str {
607 match s {
608 Spacing::Xs => "sp-xs",
609 Spacing::Sm => "sp-sm",
610 Spacing::Md => "sp-md",
611 Spacing::Lg => "sp-lg",
612 Spacing::Xl => "sp-xl",
613 }
614}
615
616fn icon_glyph(i: Icon) -> &'static str {
617 match i {
618 Icon::Delete => "🗑",
619 Icon::Add => "+",
620 Icon::Edit => "✏️",
621 Icon::Close => "✕",
622 Icon::Settings => "⚙",
623 Icon::Check => "✓",
624 Icon::Star => "★",
625 }
626}
627
628fn image_class(shape: ImageShape, ratio: ImageRatio) -> String {
629 let shape = match shape {
630 ImageShape::Square => "img-square",
631 ImageShape::Rounded => "img-rounded",
632 ImageShape::Circle => "img-circle",
633 };
634 let ratio = match ratio {
635 ImageRatio::Wide => "ratio-wide",
636 ImageRatio::Square => "ratio-square",
637 ImageRatio::Tall => "ratio-tall",
638 };
639 format!("img {shape} {ratio}")
640}
641
642fn dot_class(c: ProjectColor) -> &'static str {
643 match c {
644 ProjectColor::Indigo => "dot-indigo",
645 ProjectColor::Teal => "dot-teal",
646 ProjectColor::Coral => "dot-coral",
647 ProjectColor::Amber => "dot-amber",
648 ProjectColor::Lime => "dot-lime",
649 ProjectColor::Pink => "dot-pink",
650 }
651}
652
653fn align_class(a: BoxAlign) -> &'static str {
654 match a {
655 BoxAlign::TopStart => "align-top-start",
656 BoxAlign::TopEnd => "align-top-end",
657 BoxAlign::Center => "align-center",
658 BoxAlign::BottomStart => "align-bottom-start",
659 BoxAlign::BottomCenter => "align-bottom-center",
660 BoxAlign::BottomEnd => "align-bottom-end",
661 }
662}