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