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, ChartStyle, Corner, Density, Effect, FontFamily, Icon,
24 ImageRatio, ImageShape, InputValue, PluginCall, PluginNotify, PluginResponse, ProjectColor,
25 Spacing, 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 == "datetime" {
147 return match call.op.as_str() {
148 "date" => take_datetime("date").await,
149 "time" => take_datetime("time").await,
150 other => PluginResponse { ok: false, output: format!("unknown datetime op '{other}'") },
151 };
152 }
153 if call.plugin == "dialog" && call.op == "confirm" {
154 let v: serde_json::Value = serde_json::from_str(&call.input).unwrap_or(serde_json::Value::Null);
155 let title = v.get("title").and_then(serde_json::Value::as_str).unwrap_or("");
156 let message = v.get("message").and_then(serde_json::Value::as_str).unwrap_or("");
157 let prompt = if title.is_empty() { message.to_string() } else { format!("{title}\n\n{message}") };
158 let ok = web_sys::window()
159 .and_then(|w| w.confirm_with_message(&prompt).ok())
160 .unwrap_or(false);
161 return PluginResponse { ok, output: if ok { "ok".into() } else { "cancel".into() } };
162 }
163 if call.plugin != "http" {
164 return PluginResponse { ok: false, output: format!("plugin '{}' not available", call.plugin) };
165 }
166 let v: serde_json::Value = serde_json::from_str(&call.input).unwrap_or(serde_json::Value::Null);
167 let url = v.get("url").and_then(serde_json::Value::as_str).unwrap_or("");
168 let body = v.get("body").and_then(serde_json::Value::as_str);
169
170 use gloo_net::http::Request;
171 let builder = match call.op.as_str() {
172 "POST" => Request::post(url),
173 "PATCH" => Request::patch(url),
174 "DELETE" => Request::delete(url),
175 _ => Request::get(url),
176 };
177 let request = match body {
178 Some(b) => builder.header("Content-Type", "application/json").body(b),
179 None => builder.build(),
180 };
181 let request = match request {
182 Ok(r) => r,
183 Err(e) => return PluginResponse { ok: false, output: e.to_string() },
184 };
185 match request.send().await {
186 Ok(resp) => PluginResponse { ok: resp.ok(), output: resp.text().await.unwrap_or_default() },
187 Err(e) => PluginResponse { ok: false, output: e.to_string() },
188 }
189}
190
191async fn take_image(capture: bool) -> PluginResponse {
198 use wasm_bindgen::{closure::Closure, JsCast};
199 let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
200 return PluginResponse { ok: false, output: "no document".into() };
201 };
202 let Some(input) = doc.create_element("input").ok().and_then(|e| e.dyn_into::<web_sys::HtmlInputElement>().ok()) else {
203 return PluginResponse { ok: false, output: "no input element".into() };
204 };
205 input.set_type("file");
206 input.set_accept("image/*");
207 if capture {
208 let _ = input.set_attribute("capture", "environment");
210 }
211
212 let (tx, rx) = futures_channel::oneshot::channel::<Option<String>>();
213 let tx = std::cell::RefCell::new(Some(tx));
214 let input_for_cb = input.clone();
215 let on_change = Closure::wrap(Box::new(move || {
216 let url = input_for_cb
217 .files()
218 .and_then(|files| files.get(0))
219 .and_then(|file| web_sys::Url::create_object_url_with_blob(&file).ok());
220 if let Some(tx) = tx.borrow_mut().take() {
221 let _ = tx.send(url);
222 }
223 }) as Box<dyn FnMut()>);
224 input.set_onchange(Some(on_change.as_ref().unchecked_ref()));
225 input.click();
226 on_change.forget(); match rx.await {
229 Ok(Some(url)) => PluginResponse { ok: true, output: url },
230 _ => PluginResponse { ok: false, output: "cancelled".into() },
231 }
232}
233
234async fn take_datetime(kind: &str) -> PluginResponse {
239 use wasm_bindgen::{closure::Closure, JsCast};
240 let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
241 return PluginResponse { ok: false, output: "no document".into() };
242 };
243 let Some(input) = doc.create_element("input").ok().and_then(|e| e.dyn_into::<web_sys::HtmlInputElement>().ok()) else {
244 return PluginResponse { ok: false, output: "no input element".into() };
245 };
246 input.set_type(kind); let _ = input.set_attribute("style", "position:fixed;left:-9999px;opacity:0");
249 if let Some(body) = doc.body() {
250 let _ = body.append_child(&input);
251 }
252
253 let (tx, rx) = futures_channel::oneshot::channel::<Option<String>>();
254 let tx = std::rc::Rc::new(std::cell::RefCell::new(Some(tx)));
255 let input_for_change = input.clone();
256 let tx_change = tx.clone();
257 let on_change = Closure::wrap(Box::new(move || {
258 let v = input_for_change.value();
259 if let Some(tx) = tx_change.borrow_mut().take() {
260 let _ = tx.send(if v.is_empty() { None } else { Some(v) });
261 }
262 }) as Box<dyn FnMut()>);
263 let tx_cancel = tx.clone();
264 let on_cancel = Closure::wrap(Box::new(move || {
265 if let Some(tx) = tx_cancel.borrow_mut().take() {
266 let _ = tx.send(None);
267 }
268 }) as Box<dyn FnMut()>);
269 let _ = input.add_event_listener_with_callback("change", on_change.as_ref().unchecked_ref());
270 let _ = input.add_event_listener_with_callback("cancel", on_cancel.as_ref().unchecked_ref());
271 if input.show_picker().is_err() {
272 input.click(); }
274 on_change.forget(); on_cancel.forget();
276
277 let result = rx.await;
278 input.remove();
279 match result {
280 Ok(Some(v)) => PluginResponse { ok: true, output: v },
281 _ => PluginResponse { ok: false, output: "cancelled".into() },
282 }
283}
284
285const STORAGE_KEY: &str = "mobiler.state";
286
287fn local_storage() -> Option<web_sys::Storage> {
289 web_sys::window()?.local_storage().ok().flatten()
290}
291
292fn perform_notify(notify: &PluginNotify) {
296 let win = match web_sys::window() {
297 Some(w) => w,
298 None => return,
299 };
300 match (notify.plugin.as_str(), notify.op.as_str()) {
301 ("storage", "save") => {
303 if let Some(s) = local_storage() {
304 let _ = s.set_item(STORAGE_KEY, ¬ify.input);
305 }
306 }
307 ("clipboard", "copy") => {
309 let _ = win.navigator().clipboard().write_text(¬ify.input);
310 }
311 ("browser", "open") => {
313 let _ = win.open_with_url_and_target(¬ify.input, "_blank");
314 }
315 ("share", _) => {
318 let _ = win.navigator().clipboard().write_text(¬ify.input);
319 }
320 ("toast", _) => show_toast(¬ify.input),
322 ("haptics", style) => {
324 let ms = match style {
325 "light" => 15,
326 "heavy" => 50,
327 _ => 30, };
329 let _ = win.navigator().vibrate_with_duration(ms);
330 }
331 _ => {} }
333}
334
335fn show_toast(text: &str) {
338 let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return };
339 let (Ok(el), Some(body)) = (doc.create_element("div"), doc.body()) else { return };
340 el.set_class_name("toast");
341 el.set_text_content(Some(text));
342 let _ = body.append_child(&el);
343 gloo_timers::callback::Timeout::new(2600, move || el.remove()).forget();
344}
345
346fn render(widget: &Widget, send: &Dispatch) -> AnyView {
353 match widget {
354 Widget::Text { content, style } => {
356 let (class, content) = (text_class(*style), content.clone());
357 view! { <p class=class>{content}</p> }.into_any()
358 }
359 Widget::Image { source, shape, ratio } => {
360 let (class, source) = (image_class(*shape, *ratio), source.clone());
361 view! { <img class=class src=source /> }.into_any()
362 }
363 Widget::Badge { label, tone } => {
364 let (class, label) = (format!("badge {}", tone_class(*tone)), label.clone());
365 view! { <span class=class>{label}</span> }.into_any()
366 }
367 Widget::ColorDot { color } => {
368 view! { <span class=format!("dot {}", dot_class(*color))></span> }.into_any()
369 }
370 Widget::Avatar { source, status } => {
371 let dot = status.map(|t| view! { <span class=format!("avatar-status {}", tone_class(t))></span> });
372 view! {
373 <span class="avatar">
374 <img class="avatar-img" src=source.clone() />
375 {dot}
376 </span>
377 }
378 .into_any()
379 }
380 Widget::Rating { value, max, on_rate } => {
381 let value = *value;
382 let stars: Vec<AnyView> = (1..=*max)
383 .map(|i| {
384 let threshold = u32::from(i) * 10;
385 let glyph = if value >= threshold { "★" } else if value + 5 >= threshold { "⯨" } else { "☆" };
387 match on_rate {
388 Some(tokens) => {
389 let (send, token) = (send.clone(), tokens.get(usize::from(i - 1)).cloned().unwrap_or_default());
390 view! {
391 <button class="star star-tappable" on:click=move |_| send(Action::Fired { token: token.clone() })>
392 {glyph}
393 </button>
394 }
395 .into_any()
396 }
397 None => view! { <span class="star">{glyph}</span> }.into_any(),
398 }
399 })
400 .collect();
401 view! { <span class="rating">{stars}</span> }.into_any()
402 }
403 Widget::Divider => view! { <hr class="divider" /> }.into_any(),
404 Widget::Progress { value } => match value {
405 Some(v) => {
406 let pct = (v.clamp(0.0, 1.0) * 100.0) as u32;
407 view! { <div class="progress"><div class="progress-bar" style=format!("width:{pct}%")></div></div> }.into_any()
408 }
409 None => view! { <div class="progress progress-indeterminate"><div class="progress-bar"></div></div> }.into_any(),
410 },
411 Widget::Skeleton => view! { <div class="skeleton"></div> }.into_any(),
412 Widget::Chart { values, labels, style } => {
413 let max = values.iter().copied().fold(0.0_f32, f32::max).max(1e-6);
414 let n = values.len().max(1);
415 let label_row = if labels.is_empty() {
416 None
417 } else {
418 let items: Vec<_> = labels.iter().map(|l| view! { <span class="chart-label">{l.clone()}</span> }).collect();
419 Some(view! { <div class="chart-labels">{items}</div> })
420 };
421 let svg = match style {
422 ChartStyle::Bar => {
423 let bw = 100.0 / n as f32;
424 let bars: Vec<_> = values.iter().enumerate().map(|(i, v)| {
425 let h = (v / max).clamp(0.0, 1.0) * 48.0;
426 let x = i as f32 * bw + bw * 0.15;
427 let w = bw * 0.7;
428 let y = 50.0 - h;
429 view! { <rect x=format!("{x}") y=format!("{y}") width=format!("{w}") height=format!("{h}") class="chart-bar"></rect> }
430 }).collect();
431 view! { <svg viewBox="0 0 100 50" preserveAspectRatio="none" class="chart-svg">{bars}</svg> }.into_any()
432 }
433 ChartStyle::Line => {
434 let pts = values.iter().enumerate().map(|(i, v)| {
435 let x = if n == 1 { 50.0 } else { i as f32 * (100.0 / (n as f32 - 1.0)) };
436 let y = 50.0 - (v / max).clamp(0.0, 1.0) * 48.0;
437 format!("{x},{y}")
438 }).collect::<Vec<_>>().join(" ");
439 view! { <svg viewBox="0 0 100 50" preserveAspectRatio="none" class="chart-svg"><polyline points=pts class="chart-line"></polyline></svg> }.into_any()
440 }
441 };
442 view! { <div class="chart">{svg}{label_row}</div> }.into_any()
443 }
444 Widget::Calendar { year, month, first_weekday, selected, on_day } => {
445 const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June",
446 "July", "August", "September", "October", "November", "December"];
447 let head_label = format!("{} {year}", MONTHS.get((*month as usize).saturating_sub(1)).copied().unwrap_or(""));
448 let weekdays = ["S", "M", "T", "W", "T", "F", "S"];
449 let heads: Vec<_> = weekdays.iter().map(|w| view! { <div class="cal-head">{*w}</div> }).collect();
450 let blanks: Vec<_> = (0..*first_weekday).map(|_| view! { <div class="cal-blank"></div> }).collect();
451 let selected = *selected;
452 let days: Vec<_> = on_day.iter().enumerate().map(|(i, token)| {
453 let day = (i + 1) as u8;
454 let token = token.clone();
455 let send = send.clone();
456 let cls = if selected == Some(day) { "cal-day cal-sel" } else { "cal-day" };
457 view! { <button class=cls on:click=move |_| send(Action::Fired { token: token.clone() })>{day.to_string()}</button> }
458 }).collect();
459 view! {
460 <div class="calendar">
461 <div class="cal-title">{head_label}</div>
462 <div class="cal-grid">{heads}{blanks}{days}</div>
463 </div>
464 }.into_any()
465 }
466 Widget::SwipeAction { child, actions } => {
467 let acts: Vec<_> = actions.iter().map(|a| {
469 let token = a.on_tap.clone();
470 let send = send.clone();
471 let cls = format!("swipe-act {}", tone_class(a.tone));
472 let label = a.label.clone();
473 view! { <button class=cls on:click=move |_| send(Action::Fired { token: token.clone() })>{label}</button> }
474 }).collect();
475 view! {
476 <div class="swipe-row">
477 <div class="swipe-content">{render(child, send)}</div>
478 <div class="swipe-actions">{acts}</div>
479 </div>
480 }.into_any()
481 }
482 Widget::Spacer { size } => {
483 view! { <div class=format!("spacer {}", spacer_class(*size))></div> }.into_any()
484 }
485
486 Widget::Row { children } => {
488 let kids = render_all(children, send);
489 view! { <div class="row">{kids}</div> }.into_any()
490 }
491 Widget::Column { children } => {
492 let kids = render_all(children, send);
493 view! { <div class="col">{kids}</div> }.into_any()
494 }
495 Widget::Card { child, style, on_press } => {
496 let class = format!("card {}", card_class(*style));
497 let body = render(child, send);
498 match on_press {
499 Some(token) => {
500 let (send, token) = (send.clone(), token.clone());
501 view! {
502 <button
503 class=format!("{class} card-tappable")
504 on:click=move |_| send(Action::Fired { token: token.clone() })
505 >
506 {body}
507 </button>
508 }
509 .into_any()
510 }
511 None => view! { <div class=class>{body}</div> }.into_any(),
512 }
513 }
514 Widget::Box { children, align, scrim } => {
518 let acls = align_class(*align);
519 if *scrim && children.len() > 1 {
520 let bg = render(&children[0], send);
521 let content = render_all(&children[1..], send);
522 view! {
523 <div class=format!("box box-scrim {acls}")>
524 {bg}
525 <div class="scrim"></div>
526 <div class="box-content">{content}</div>
527 </div>
528 }
529 .into_any()
530 } else {
531 let kids = render_all(children, send);
532 view! { <div class=format!("box {acls}")>{kids}</div> }.into_any()
533 }
534 }
535 Widget::Grid { children } => {
536 let kids = render_all(children, send);
537 view! { <div class="grid">{kids}</div> }.into_any()
538 }
539 Widget::Scroller { children } => {
540 let kids = render_all(children, send);
541 view! { <div class="scroller">{kids}</div> }.into_any()
542 }
543
544 Widget::Button { label, style, on_press } => {
546 let (send, token, label) = (send.clone(), on_press.clone(), label.clone());
547 let class = format!("btn {}", button_class(*style));
548 view! {
549 <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
550 {label}
551 </button>
552 }
553 .into_any()
554 }
555 Widget::IconButton { icon, on_press } => {
556 let (send, token) = (send.clone(), on_press.clone());
557 let glyph = icon_glyph(*icon);
558 view! {
559 <button class="iconbtn" on:click=move |_| send(Action::Fired { token: token.clone() })>
560 {glyph}
561 </button>
562 }
563 .into_any()
564 }
565 Widget::Chip { label, selected, on_press } => {
566 let (send, token, label) = (send.clone(), on_press.clone(), label.clone());
567 let class = if *selected { "chip selected" } else { "chip" };
568 view! {
569 <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
570 {label}
571 </button>
572 }
573 .into_any()
574 }
575 Widget::TextField { id, placeholder, value } => {
576 let (send, id) = (send.clone(), id.clone());
577 let (placeholder, value) = (placeholder.clone(), value.clone());
578 view! {
579 <input
580 class="field"
581 placeholder=placeholder
582 prop:value=value
583 on:input=move |ev| send(Action::Input {
584 id: id.clone(),
585 value: InputValue::Text(event_target_value(&ev)),
586 })
587 />
588 }
589 .into_any()
590 }
591 Widget::SearchField { id, placeholder, value } => {
592 let (send, id) = (send.clone(), id.clone());
593 let (placeholder, value) = (placeholder.clone(), value.clone());
594 view! {
595 <div class="searchfield">
596 <span class="search-icon">{icon_glyph(Icon::Search)}</span>
597 <input
598 class="search-input"
599 placeholder=placeholder
600 prop:value=value
601 on:input=move |ev| send(Action::Input {
602 id: id.clone(),
603 value: InputValue::Text(event_target_value(&ev)),
604 })
605 />
606 </div>
607 }
608 .into_any()
609 }
610 Widget::Segmented { segments } => {
611 let segs: Vec<AnyView> = segments
612 .iter()
613 .map(|s| {
614 let (send, token) = (send.clone(), s.on_select.clone());
615 let class = if s.selected { "segment selected" } else { "segment" };
616 let label = s.label.clone();
617 view! {
618 <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
619 {label}
620 </button>
621 }
622 .into_any()
623 })
624 .collect();
625 view! { <div class="segmented">{segs}</div> }.into_any()
626 }
627 Widget::Toggle { id, label, value } => {
628 let (send, id, label, checked) = (send.clone(), id.clone(), label.clone(), *value);
629 view! {
630 <label class="toggle">
631 {label}
632 <input
633 type="checkbox"
634 role="switch"
635 prop:checked=checked
636 on:change=move |ev| send(Action::Input {
637 id: id.clone(),
638 value: InputValue::Bool(event_target_checked(&ev)),
639 })
640 />
641 </label>
642 }
643 .into_any()
644 }
645 Widget::Checkbox { id, label, value } => {
646 let (send, id, label, checked) = (send.clone(), id.clone(), label.clone(), *value);
647 view! {
648 <label class="check">
649 <input
650 type="checkbox"
651 prop:checked=checked
652 on:change=move |ev| send(Action::Input {
653 id: id.clone(),
654 value: InputValue::Bool(event_target_checked(&ev)),
655 })
656 />
657 {label}
658 </label>
659 }
660 .into_any()
661 }
662 Widget::Slider { id, value, max } => {
663 let (send, id, value, max) = (send.clone(), id.clone(), *value, *max);
664 view! {
665 <input
666 class="slider"
667 type="range"
668 min="0"
669 max=max
670 prop:value=value
671 on:input=move |ev| send(Action::Input {
672 id: id.clone(),
673 value: InputValue::Int(event_target_value(&ev).parse().unwrap_or(0)),
674 })
675 />
676 }
677 .into_any()
678 }
679 Widget::Stepper { value, on_decrement, on_increment } => {
680 let send_dec = send.clone();
681 let send_inc = send.clone();
682 let (dec, inc) = (on_decrement.clone(), on_increment.clone());
683 view! {
684 <div class="stepper">
685 <button on:click=move |_| send_dec(Action::Fired { token: dec.clone() })>"−"</button>
686 <span class="stepper-value">{*value}</span>
687 <button on:click=move |_| send_inc(Action::Fired { token: inc.clone() })>"+"</button>
688 </div>
689 }
690 .into_any()
691 }
692
693 Widget::Scaffold { title, body, tabs, back, dark_mode, theme, fab, sheet, on_refresh, refreshing, route, depth } => {
695 let back_btn = back.clone().map(|token| {
696 let send = send.clone();
697 view! {
698 <button class="back" on:click=move |_| send(Action::Fired { token: token.clone() })>
699 "‹"
700 </button>
701 }
702 });
703 let tabbar = (!tabs.is_empty()).then(|| {
704 let tabs: Vec<AnyView> = tabs
705 .iter()
706 .map(|tab| {
707 let (send, token) = (send.clone(), tab.on_select.clone());
708 let class = if tab.selected { "tab selected" } else { "tab" };
709 let label = tab.label.clone();
710 let icon = tab.icon.map(|i| view! { <span class="tab-icon">{icon_glyph(i)}</span> });
712 view! {
713 <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
714 {icon}
715 <span class="tab-label">{label}</span>
716 </button>
717 }
718 .into_any()
719 })
720 .collect();
721 view! { <div class="tabbar">{tabs}</div> }
722 });
723 let fab_btn = fab.clone().map(|f| {
725 let (send, token) = (send.clone(), f.on_press.clone());
726 view! {
727 <button class="fab" on:click=move |_| send(Action::Fired { token: token.clone() })>
728 {icon_glyph(f.icon)}
729 </button>
730 }
731 });
732 let sheet_overlay = sheet.as_ref().map(|s| {
734 let (send_scrim, dismiss) = (send.clone(), s.on_dismiss.clone());
735 let (title, child) = (s.title.clone(), render(&s.child, send));
736 view! {
737 <div class="sheet-scrim" on:click=move |_| send_scrim(Action::Fired { token: dismiss.clone() })></div>
738 <div class="sheet">
739 <div class="sheet-handle"></div>
740 <div class="sheet-title">{title}</div>
741 {child}
742 </div>
743 }
744 });
745 let class = if *dark_mode { "scaffold theme-dark" } else { "scaffold" };
748 let refresh_btn = on_refresh.clone().map(|token| {
751 let send = send.clone();
752 view! {
753 <button class="refresh-btn" on:click=move |_| send(Action::Fired { token: token.clone() })>"↻"</button>
754 }
755 });
756 let refresh_bar = refreshing.then(|| {
757 view! { <div class="progress progress-indeterminate"><div class="progress-bar"></div></div> }
758 });
759 let body_class = format!("scaffold-body {}", nav_class(route, *depth));
760 let theme_style = theme.as_ref().map(theme_css).unwrap_or_default();
763 let (title, body) = (title.clone(), render(body, send));
764 view! {
765 <div class=class style=theme_style>
766 <div class="topbar">
767 {back_btn}
768 <span class="title">{title}</span>
769 {refresh_btn}
770 </div>
771 <div class=body_class data-route=route.clone()>{refresh_bar}{body}</div>
772 {fab_btn}
773 {tabbar}
774 {sheet_overlay}
775 </div>
776 }
777 .into_any()
778 }
779 }
780}
781
782fn render_all(children: &[Widget], send: &Dispatch) -> Vec<AnyView> {
784 children.iter().map(|c| render(c, send)).collect()
785}
786
787thread_local! {
788 static NAV: RefCell<(String, u32, bool)> = const { RefCell::new((String::new(), 0, false)) };
793}
794
795fn theme_css(t: &Theme) -> String {
800 let (r, g, b) = (t.seed.r, t.seed.g, t.seed.b);
801 let radius = match t.corner {
802 Corner::None => "0px",
803 Corner::Small => "8px",
804 Corner::Medium => "14px",
805 Corner::Large => "22px",
806 };
807 let (gap, pad) = match t.density {
808 Density::Compact => ("8px", "10px"),
809 Density::Comfortable => ("12px", "14px"),
810 };
811 let font = match t.font {
812 FontFamily::System => "system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif",
813 FontFamily::Rounded => "ui-rounded, \"SF Pro Rounded\", \"Segoe UI\", system-ui, sans-serif",
814 FontFamily::Serif => "ui-serif, Georgia, \"Times New Roman\", serif",
815 FontFamily::Monospace => "ui-monospace, \"SF Mono\", \"Cascadia Code\", Menlo, monospace",
816 };
817 let (ar, ag, ab) = t.accent.map_or((r, g, b), |a| (a.r, a.g, a.b));
819 format!(
820 "--primary:rgb({r},{g},{b});--accent:rgb({r},{g},{b});\
821 --accent2:rgb({ar},{ag},{ab});\
822 --accent-soft:rgba({r},{g},{b},0.16);--radius:{radius};\
823 --gap:{gap};--pad:{pad};--font:{font};"
824 )
825}
826
827fn nav_class(route: &str, depth: u32) -> &'static str {
834 NAV.with_borrow_mut(|(prev_route, prev_depth, toggle)| {
835 if route == prev_route {
836 return "";
837 }
838 let dir = if depth > *prev_depth {
839 ["nav-push-a", "nav-push-b"]
840 } else if depth < *prev_depth {
841 ["nav-pop-a", "nav-pop-b"]
842 } else {
843 ["nav-fade-a", "nav-fade-b"]
844 };
845 *toggle = !*toggle;
846 *prev_route = route.to_string();
847 *prev_depth = depth;
848 dir[usize::from(*toggle)]
849 })
850}
851
852fn text_class(s: TextStyle) -> &'static str {
855 match s {
856 TextStyle::Title => "t-title",
857 TextStyle::Subtitle => "t-subtitle",
858 TextStyle::Caption => "t-caption",
859 TextStyle::Emphasis => "t-emphasis",
860 TextStyle::Body => "t-body",
861 }
862}
863
864fn button_class(s: ButtonStyle) -> &'static str {
865 match s {
866 ButtonStyle::Filled => "btn-filled",
867 ButtonStyle::Outlined => "btn-outlined",
868 ButtonStyle::Text => "btn-text",
869 }
870}
871
872fn card_class(s: CardStyle) -> &'static str {
873 match s {
874 CardStyle::Elevated => "card-elevated",
875 CardStyle::Outlined => "card-outlined",
876 CardStyle::Filled => "card-filled",
877 CardStyle::Brand => "card-brand",
878 }
879}
880
881fn tone_class(t: Tone) -> &'static str {
882 match t {
883 Tone::Neutral => "tone-neutral",
884 Tone::Success => "tone-success",
885 Tone::Warning => "tone-warning",
886 Tone::Danger => "tone-danger",
887 Tone::Info => "tone-info",
888 }
889}
890
891fn spacer_class(s: Spacing) -> &'static str {
892 match s {
893 Spacing::Xs => "sp-xs",
894 Spacing::Sm => "sp-sm",
895 Spacing::Md => "sp-md",
896 Spacing::Lg => "sp-lg",
897 Spacing::Xl => "sp-xl",
898 }
899}
900
901fn icon_glyph(i: Icon) -> &'static str {
902 match i {
903 Icon::Delete => "🗑",
904 Icon::Add => "+",
905 Icon::Edit => "✏️",
906 Icon::Close => "✕",
907 Icon::Settings => "⚙",
908 Icon::Check => "✓",
909 Icon::Star => "★",
910 Icon::Info => "ℹ",
911 Icon::Home => "⌂",
912 Icon::Search => "🔍",
913 Icon::Menu => "☰",
914 Icon::Filter => "⚟",
915 Icon::Back => "‹",
916 Icon::Forward => "›",
917 Icon::Down => "⌄",
918 Icon::Bell => "🔔",
919 Icon::Cart => "🛒",
920 Icon::Share => "↗",
921 Icon::Heart => "♡",
922 Icon::HeartFilled => "♥",
923 Icon::Person => "👤",
924 Icon::People => "👥",
925 Icon::Phone => "📞",
926 Icon::Mail => "✉",
927 Icon::Calendar => "📅",
928 Icon::Clock => "🕑",
929 Icon::MapPin => "📍",
930 Icon::Camera => "📷",
931 Icon::Photo => "🖼",
932 Icon::Play => "▶",
933 Icon::Scissors => "✂",
934 }
935}
936
937fn image_class(shape: ImageShape, ratio: ImageRatio) -> String {
938 let shape = match shape {
939 ImageShape::Square => "img-square",
940 ImageShape::Rounded => "img-rounded",
941 ImageShape::Circle => "img-circle",
942 };
943 let ratio = match ratio {
944 ImageRatio::Wide => "ratio-wide",
945 ImageRatio::Square => "ratio-square",
946 ImageRatio::Tall => "ratio-tall",
947 };
948 format!("img {shape} {ratio}")
949}
950
951fn dot_class(c: ProjectColor) -> &'static str {
952 match c {
953 ProjectColor::Indigo => "dot-indigo",
954 ProjectColor::Teal => "dot-teal",
955 ProjectColor::Coral => "dot-coral",
956 ProjectColor::Amber => "dot-amber",
957 ProjectColor::Lime => "dot-lime",
958 ProjectColor::Pink => "dot-pink",
959 }
960}
961
962fn align_class(a: BoxAlign) -> &'static str {
963 match a {
964 BoxAlign::TopStart => "align-top-start",
965 BoxAlign::TopEnd => "align-top-end",
966 BoxAlign::Center => "align-center",
967 BoxAlign::BottomStart => "align-bottom-start",
968 BoxAlign::BottomCenter => "align-bottom-center",
969 BoxAlign::BottomEnd => "align-bottom-end",
970 }
971}