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