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