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, 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 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
103fn 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 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
126async 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
156fn render(widget: &Widget, send: &Dispatch) -> AnyView {
163 match widget {
164 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 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 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 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 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 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
399fn render_all(children: &[Widget], send: &Dispatch) -> Vec<AnyView> {
401 children.iter().map(|c| render(c, send)).collect()
402}
403
404thread_local! {
405 static NAV: RefCell<(String, u32, bool)> = const { RefCell::new((String::new(), 0, false)) };
410}
411
412fn 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
437fn 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}