1use std::cell::RefCell;
18use std::collections::HashMap;
19use std::rc::Rc;
20use std::sync::Arc;
21
22use crux_core::{App, Core, Request};
23use leptos::prelude::*;
24use mobiler_core::{
25 A11yRole, Action, BoxAlign, ButtonStyle, CardStyle, ChartBracket, ChartLegendItem, ChartRefLine, ChartRegion,
26 ChartSeries, ChartStyle, ChartTick, Corner, Density, Effect, FieldKind, FontFamily, Icon,
27 ImageRatio, ImageShape, InputValue, PluginCall, PluginNotify, PluginResponse, PluginStreamCall, ProjectColor,
28 Rgb, Spacing, TextStyle, Theme, Tone, Widget,
29};
30use wasm_bindgen_futures::spawn_local;
31
32const STYLE: &str = include_str!("mobiler.css");
38
39type Dispatch = Arc<dyn Fn(Action) + Send + Sync>;
42
43pub trait WebApp:
47 App<Event = Action, ViewModel = Widget, Effect = Effect> + Default + Send + Sync + 'static
48where
49 Self::Model: Default + Send + Sync,
50{
51}
52impl<T> WebApp for T
53where
54 T: App<Event = Action, ViewModel = Widget, Effect = Effect> + Default + Send + Sync + 'static,
55 T::Model: Default + Send + Sync,
56{
57}
58
59pub fn run<A: WebApp>()
61where
62 A::Model: Default + Send + Sync,
63{
64 console_error_panic_hook::set_once();
65 inject_default_style();
66 inject_hls_support();
67 inject_maplibre_support();
68 leptos::mount::mount_to_body(shell::<A>);
69}
70
71fn inject_hls_support() {
81 const BOOTSTRAP: &str = r#"(function(){
82 function ensureHls(cb){
83 if(window.Hls){return cb();}
84 if(window.__mobilerHlsLoading){(window.__mobilerHlsCbs=window.__mobilerHlsCbs||[]).push(cb);return;}
85 window.__mobilerHlsLoading=true;window.__mobilerHlsCbs=[cb];
86 var s=document.createElement('script');
87 s.src='https://cdn.jsdelivr.net/npm/hls.js@1';
88 var flush=function(){var cbs=window.__mobilerHlsCbs||[];window.__mobilerHlsCbs=[];cbs.forEach(function(f){f();});};
89 s.onload=flush;s.onerror=flush;
90 document.head.appendChild(s);
91 }
92 function attach(v){
93 if(v.__mobilerHlsDone){return;}v.__mobilerHlsDone=true;
94 var url=v.getAttribute('data-hls-src');if(!url){return;}
95 if(v.canPlayType('application/vnd.apple.mpegurl')){v.src=url;return;}
96 ensureHls(function(){
97 if(window.Hls&&window.Hls.isSupported()){var h=new window.Hls();h.loadSource(url);h.attachMedia(v);v.__mobilerHls=h;}
98 else{v.src=url;}
99 });
100 }
101 function scan(root){if(root&&root.querySelectorAll){root.querySelectorAll('video[data-hls-src]').forEach(attach);}}
102 new MutationObserver(function(muts){muts.forEach(function(m){m.addedNodes.forEach(function(n){if(n.nodeType===1){if(n.matches&&n.matches('video[data-hls-src]')){attach(n);}scan(n);}});});}).observe(document.documentElement,{childList:true,subtree:true});
103 scan(document);
104})();"#;
105 let document = leptos::prelude::document();
106 let Some(head) = document.head() else { return };
107 let Ok(script) = document.create_element("script") else { return };
108 script.set_text_content(Some(BOOTSTRAP));
109 let _ = head.append_child(&script);
110}
111
112fn inject_maplibre_support() {
121 const BOOTSTRAP: &str = r#"(function(){
122 function ensureML(cb){
123 if(window.maplibregl){return cb();}
124 if(window.__mobilerMlLoading){(window.__mobilerMlCbs=window.__mobilerMlCbs||[]).push(cb);return;}
125 window.__mobilerMlLoading=true;window.__mobilerMlCbs=[cb];
126 var l=document.createElement('link');l.rel='stylesheet';l.href='https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.css';document.head.appendChild(l);
127 var s=document.createElement('script');s.src='https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.js';
128 var flush=function(){var cbs=window.__mobilerMlCbs||[];window.__mobilerMlCbs=[];cbs.forEach(function(f){f();});};
129 s.onload=flush;s.onerror=flush;document.head.appendChild(s);
130 }
131 function emit(el,payload){
132 var sink=el.parentElement&&el.parentElement.querySelector('.mobiler-map-sink');
133 if(sink){sink.value=payload;sink.dispatchEvent(new Event('input',{bubbles:true}));}
134 }
135 function init(el){
136 if(el.__mobilerMap){return;}el.__mobilerMap=true;
137 ensureML(function(){
138 try{
139 var c=(el.getAttribute('data-center')||'0,0').split(',');
140 var center=[parseFloat(c[1])||0,parseFloat(c[0])||0];
141 var zoom=parseFloat(el.getAttribute('data-zoom'))||2;
142 var style=el.getAttribute('data-style')||'https://tiles.openfreemap.org/styles/liberty';
143 var interactive=el.getAttribute('data-interactive')!=='false';
144 var map=new maplibregl.Map({container:el,style:style,center:center,zoom:zoom,interactive:interactive});
145 el.__mobilerMapInstance=map;
146 map.on('click',function(e){emit(el,'tap|'+e.lngLat.lat.toFixed(6)+','+e.lngLat.lng.toFixed(6));});
147 var markers=[];try{markers=JSON.parse(el.getAttribute('data-markers')||'[]');}catch(_){}
148 markers.forEach(function(mk){
149 var m=new maplibregl.Marker().setLngLat([mk.lng,mk.lat]);
150 if(mk.title){m.setPopup(new maplibregl.Popup({offset:24}).setText(mk.title));}
151 m.addTo(map);
152 m.getElement().addEventListener('click',function(ev){ev.stopPropagation();emit(el,'marker|'+mk.id);});
153 });
154 }catch(_){}
155 });
156 }
157 function scan(root){if(root&&root.querySelectorAll){root.querySelectorAll('.mobiler-map[data-map]').forEach(init);}}
158 new MutationObserver(function(muts){muts.forEach(function(m){
159 m.addedNodes.forEach(function(n){if(n.nodeType===1){if(n.matches&&n.matches('.mobiler-map[data-map]')){init(n);}scan(n);}});
160 m.removedNodes.forEach(function(n){if(n.nodeType===1){
161 if(n.__mobilerMapInstance){try{n.__mobilerMapInstance.remove();}catch(_){}}
162 if(n.querySelectorAll){n.querySelectorAll('.mobiler-map').forEach(function(x){if(x.__mobilerMapInstance){try{x.__mobilerMapInstance.remove();}catch(_){}}});}
163 }});
164 });}).observe(document.documentElement,{childList:true,subtree:true});
165 scan(document);
166})();"#;
167 let document = leptos::prelude::document();
168 let Some(head) = document.head() else { return };
169 let Ok(script) = document.create_element("script") else { return };
170 script.set_text_content(Some(BOOTSTRAP));
171 let _ = head.append_child(&script);
172}
173
174fn inject_default_style() {
178 let document = leptos::prelude::document();
179 let Some(head) = document.head() else { return };
180 let Ok(style) = document.create_element("style") else { return };
181 let _ = style.set_attribute("data-mobiler", "shell");
182 style.set_text_content(Some(STYLE));
183 let _ = head.insert_before(&style, head.first_child().as_ref());
184}
185
186fn shell<A: WebApp>() -> impl IntoView
187where
188 A::Model: Default + Send + Sync,
189{
190 let core = Arc::new(Core::<A>::new());
191 let (view, set_view) = signal(core.view());
192
193 let send: Dispatch = {
194 let core = core.clone();
195 Arc::new(move |action: Action| {
196 let effects = core.process_event(action);
197 drive(&core, set_view, effects);
198 })
199 };
200
201 let saved = local_storage().and_then(|s| s.get_item(STORAGE_KEY).ok().flatten()).unwrap_or_default();
204 if !saved.is_empty() {
205 send(Action::Restore { data: saved });
206 }
207 send(Action::Start);
208
209 let send_for_view = send.clone();
210 view! {
211 <div class="app">
212 {move || render(&view.get(), &send_for_view)}
213 </div>
214 }
215}
216
217fn drive<A: WebApp>(core: &Arc<Core<A>>, set_view: WriteSignal<Widget>, effects: Vec<Effect>)
219where
220 A::Model: Default + Send + Sync,
221{
222 for effect in effects {
223 match effect {
224 Effect::Render(_) => set_view.set(core.view()),
225 Effect::PluginNotify(notify) => perform_notify(¬ify.operation),
226 Effect::Plugin(mut request) => {
227 let core = core.clone();
228 spawn_local(async move {
229 let response = perform(&request.operation).await;
230 if let Ok(next) = core.resolve(&mut request, response) {
231 drive(&core, set_view, next);
232 }
233 });
234 }
235 Effect::PluginStream(request) => start_stream(core, set_view, request),
238 }
239 }
240}
241
242fn start_stream<A: WebApp>(
251 core: &Arc<Core<A>>,
252 set_view: WriteSignal<Widget>,
253 request: Request<PluginStreamCall>,
254) where
255 A::Model: Default + Send + Sync,
256{
257 use wasm_bindgen::{closure::Closure, JsCast};
258
259 let call = request.operation.clone();
260
261 let request = Rc::new(RefCell::new(request));
264 let core = core.clone();
265 let emit = move |resp: PluginResponse| {
266 if let Ok(next) = core.resolve(&mut *request.borrow_mut(), resp) {
267 drive(&core, set_view, next);
268 }
269 };
270
271 let handle = match (call.plugin.as_str(), call.op.as_str()) {
272 ("ticker", "start") => {
275 let ms: u32 = call.input.parse().unwrap_or(1000);
276 let count = std::cell::Cell::new(0u32);
277 let interval = gloo_timers::callback::Interval::new(ms, move || {
278 count.set(count.get() + 1);
279 emit(PluginResponse { ok: true, output: count.get().to_string() });
280 });
281 StreamHandle::Ticker { _interval: interval }
282 }
283 ("websocket", "stream") => {
284 let Ok(ws) = web_sys::WebSocket::new(&call.input) else { return };
285 let onmessage = {
286 let emit = emit.clone();
287 Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |e: web_sys::MessageEvent| {
288 emit(PluginResponse { ok: true, output: e.data().as_string().unwrap_or_default() });
289 })
290 };
291 let onclose = Closure::<dyn FnMut(web_sys::CloseEvent)>::new(move |_e| {
292 emit(PluginResponse { ok: false, output: "closed".into() });
293 });
294 ws.set_onmessage(Some(onmessage.as_ref().unchecked_ref()));
295 ws.set_onclose(Some(onclose.as_ref().unchecked_ref()));
296 StreamHandle::Ws(WsStream { ws, _onmessage: onmessage, _onclose: onclose })
297 }
298 ("system", "events") => {
302 let win = web_sys::window().expect("window");
303 let doc = win.document().expect("document");
304 if let Ok(href) = win.location().href() {
306 emit(PluginResponse { ok: true, output: system_deeplink(&href) });
307 }
308 emit(PluginResponse { ok: true, output: system_lifecycle(&doc) });
309 let onpop = {
310 let (emit, win) = (emit.clone(), win.clone());
311 Closure::<dyn FnMut(web_sys::Event)>::new(move |_e: web_sys::Event| {
312 if let Ok(href) = win.location().href() {
313 emit(PluginResponse { ok: true, output: system_deeplink(&href) });
314 }
315 })
316 };
317 let onvis = {
318 let (emit, doc) = (emit.clone(), doc.clone());
319 Closure::<dyn FnMut(web_sys::Event)>::new(move |_e: web_sys::Event| {
320 emit(PluginResponse { ok: true, output: system_lifecycle(&doc) });
321 })
322 };
323 let _ = win.add_event_listener_with_callback("popstate", onpop.as_ref().unchecked_ref());
324 let _ = doc.add_event_listener_with_callback("visibilitychange", onvis.as_ref().unchecked_ref());
325 StreamHandle::System(SystemStream { win, doc, _onpop: onpop, _onvis: onvis })
326 }
327 _ => return, };
329
330 STREAMS.with(|m| {
331 m.borrow_mut().insert(call.key.clone(), handle);
332 });
333}
334
335fn system_deeplink(url: &str) -> String {
337 format!("{{\"type\":\"deeplink\",\"url\":{}}}", serde_json::to_string(url).unwrap_or_else(|_| "\"\"".into()))
338}
339fn system_lifecycle(doc: &web_sys::Document) -> String {
341 let state = if doc.visibility_state() == web_sys::VisibilityState::Visible { "active" } else { "background" };
342 format!("{{\"type\":\"lifecycle\",\"state\":\"{state}\"}}")
343}
344
345enum StreamHandle {
349 Ticker { _interval: gloo_timers::callback::Interval },
351 Ws(WsStream),
352 #[allow(dead_code)]
355 System(SystemStream),
356}
357
358struct SystemStream {
360 win: web_sys::Window,
361 doc: web_sys::Document,
362 _onpop: wasm_bindgen::closure::Closure<dyn FnMut(web_sys::Event)>,
363 _onvis: wasm_bindgen::closure::Closure<dyn FnMut(web_sys::Event)>,
364}
365impl Drop for SystemStream {
366 fn drop(&mut self) {
367 use wasm_bindgen::JsCast;
368 let _ = self.win.remove_event_listener_with_callback("popstate", self._onpop.as_ref().unchecked_ref());
369 let _ = self.doc.remove_event_listener_with_callback("visibilitychange", self._onvis.as_ref().unchecked_ref());
370 }
371}
372
373struct WsStream {
375 ws: web_sys::WebSocket,
376 _onmessage: wasm_bindgen::closure::Closure<dyn FnMut(web_sys::MessageEvent)>,
377 _onclose: wasm_bindgen::closure::Closure<dyn FnMut(web_sys::CloseEvent)>,
378}
379
380async fn perform(call: &PluginCall) -> PluginResponse {
383 if call.plugin == "device" {
384 let nav = web_sys::window().map(|w| w.navigator());
385 let output = if call.op == "locale" {
386 nav.and_then(|n| n.language()).unwrap_or_else(|| "en-US".into())
388 } else {
389 nav.and_then(|n| n.user_agent().ok()).unwrap_or_default()
390 };
391 return PluginResponse { ok: true, output };
392 }
393 if call.plugin == "photo" && call.op == "pick" {
394 return take_image(false).await;
395 }
396 if call.plugin == "camera" && call.op == "capture" {
397 return take_image(true).await;
398 }
399 if call.plugin == "datetime" {
400 return match call.op.as_str() {
401 "date" => take_datetime("date").await,
402 "time" => take_datetime("time").await,
403 other => PluginResponse { ok: false, output: format!("unknown datetime op '{other}'") },
404 };
405 }
406 if call.plugin == "dialog" && call.op == "confirm" {
407 let v: serde_json::Value = serde_json::from_str(&call.input).unwrap_or(serde_json::Value::Null);
408 let title = v.get("title").and_then(serde_json::Value::as_str).unwrap_or("");
409 let message = v.get("message").and_then(serde_json::Value::as_str).unwrap_or("");
410 let prompt = if title.is_empty() { message.to_string() } else { format!("{title}\n\n{message}") };
411 let ok = web_sys::window()
412 .and_then(|w| w.confirm_with_message(&prompt).ok())
413 .unwrap_or(false);
414 return PluginResponse { ok, output: if ok { "ok".into() } else { "cancel".into() } };
415 }
416 if call.plugin != "http" {
417 return PluginResponse { ok: false, output: format!("plugin '{}' not available", call.plugin) };
418 }
419 let v: serde_json::Value = serde_json::from_str(&call.input).unwrap_or(serde_json::Value::Null);
420 let url = v.get("url").and_then(serde_json::Value::as_str).unwrap_or("");
421 let body = v.get("body").and_then(serde_json::Value::as_str);
422
423 use gloo_net::http::Request;
424 let builder = match call.op.as_str() {
425 "POST" => Request::post(url),
426 "PATCH" => Request::patch(url),
427 "DELETE" => Request::delete(url),
428 _ => Request::get(url),
429 };
430 let request = match body {
431 Some(b) => builder.header("Content-Type", "application/json").body(b),
432 None => builder.build(),
433 };
434 let request = match request {
435 Ok(r) => r,
436 Err(e) => return PluginResponse { ok: false, output: e.to_string() },
437 };
438 match request.send().await {
439 Ok(resp) => PluginResponse { ok: resp.ok(), output: resp.text().await.unwrap_or_default() },
440 Err(e) => PluginResponse { ok: false, output: e.to_string() },
441 }
442}
443
444async fn take_image(capture: bool) -> PluginResponse {
451 use wasm_bindgen::{closure::Closure, JsCast};
452 let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
453 return PluginResponse { ok: false, output: "no document".into() };
454 };
455 let Some(input) = doc.create_element("input").ok().and_then(|e| e.dyn_into::<web_sys::HtmlInputElement>().ok()) else {
456 return PluginResponse { ok: false, output: "no input element".into() };
457 };
458 input.set_type("file");
459 input.set_accept("image/*");
460 if capture {
461 let _ = input.set_attribute("capture", "environment");
463 }
464
465 let (tx, rx) = futures_channel::oneshot::channel::<Option<String>>();
466 let tx = std::cell::RefCell::new(Some(tx));
467 let input_for_cb = input.clone();
468 let on_change = Closure::wrap(Box::new(move || {
469 let url = input_for_cb
470 .files()
471 .and_then(|files| files.get(0))
472 .and_then(|file| web_sys::Url::create_object_url_with_blob(&file).ok());
473 if let Some(tx) = tx.borrow_mut().take() {
474 let _ = tx.send(url);
475 }
476 }) as Box<dyn FnMut()>);
477 input.set_onchange(Some(on_change.as_ref().unchecked_ref()));
478 input.click();
479 on_change.forget(); match rx.await {
482 Ok(Some(url)) => PluginResponse { ok: true, output: url },
483 _ => PluginResponse { ok: false, output: "cancelled".into() },
484 }
485}
486
487async fn take_datetime(kind: &str) -> PluginResponse {
492 use wasm_bindgen::{closure::Closure, JsCast};
493 let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
494 return PluginResponse { ok: false, output: "no document".into() };
495 };
496 let Some(input) = doc.create_element("input").ok().and_then(|e| e.dyn_into::<web_sys::HtmlInputElement>().ok()) else {
497 return PluginResponse { ok: false, output: "no input element".into() };
498 };
499 input.set_type(kind); let _ = input.set_attribute("style", "position:fixed;left:-9999px;opacity:0");
502 if let Some(body) = doc.body() {
503 let _ = body.append_child(&input);
504 }
505
506 let (tx, rx) = futures_channel::oneshot::channel::<Option<String>>();
507 let tx = std::rc::Rc::new(std::cell::RefCell::new(Some(tx)));
508 let input_for_change = input.clone();
509 let tx_change = tx.clone();
510 let on_change = Closure::wrap(Box::new(move || {
511 let v = input_for_change.value();
512 if let Some(tx) = tx_change.borrow_mut().take() {
513 let _ = tx.send(if v.is_empty() { None } else { Some(v) });
514 }
515 }) as Box<dyn FnMut()>);
516 let tx_cancel = tx.clone();
517 let on_cancel = Closure::wrap(Box::new(move || {
518 if let Some(tx) = tx_cancel.borrow_mut().take() {
519 let _ = tx.send(None);
520 }
521 }) as Box<dyn FnMut()>);
522 let _ = input.add_event_listener_with_callback("change", on_change.as_ref().unchecked_ref());
523 let _ = input.add_event_listener_with_callback("cancel", on_cancel.as_ref().unchecked_ref());
524 if input.show_picker().is_err() {
525 input.click(); }
527 on_change.forget(); on_cancel.forget();
529
530 let result = rx.await;
531 input.remove();
532 match result {
533 Ok(Some(v)) => PluginResponse { ok: true, output: v },
534 _ => PluginResponse { ok: false, output: "cancelled".into() },
535 }
536}
537
538const STORAGE_KEY: &str = "mobiler.state";
539
540fn local_storage() -> Option<web_sys::Storage> {
542 web_sys::window()?.local_storage().ok().flatten()
543}
544
545fn perform_notify(notify: &PluginNotify) {
549 let win = match web_sys::window() {
550 Some(w) => w,
551 None => return,
552 };
553 match (notify.plugin.as_str(), notify.op.as_str()) {
554 ("storage", "save") => {
556 if let Some(s) = local_storage() {
557 let _ = s.set_item(STORAGE_KEY, ¬ify.input);
558 }
559 }
560 ("clipboard", "copy") => {
562 let _ = win.navigator().clipboard().write_text(¬ify.input);
563 }
564 ("browser", "open") => {
566 let _ = win.open_with_url_and_target(¬ify.input, "_blank");
567 }
568 ("share", _) => {
571 let _ = win.navigator().clipboard().write_text(¬ify.input);
572 }
573 ("stream", "unsubscribe") => {
577 if let Some(StreamHandle::Ws(ws)) = STREAMS.with(|m| m.borrow_mut().remove(¬ify.input)) {
580 let _ = ws.ws.close();
581 }
582 }
583 ("toast", _) => show_toast(¬ify.input),
585 ("haptics", style) => {
587 let ms = match style {
588 "light" => 15,
589 "heavy" => 50,
590 _ => 30, };
592 let _ = win.navigator().vibrate_with_duration(ms);
593 }
594 _ => {} }
596}
597
598fn show_toast(text: &str) {
601 let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return };
602 let (Ok(el), Some(body)) = (doc.create_element("div"), doc.body()) else { return };
603 el.set_class_name("toast");
604 el.set_text_content(Some(text));
605 let _ = body.append_child(&el);
606 gloo_timers::callback::Timeout::new(2600, move || el.remove()).forget();
607}
608
609fn render(widget: &Widget, send: &Dispatch) -> AnyView {
616 match widget {
617 Widget::Text { content, style } => {
619 let (class, content) = (text_class(*style), content.clone());
620 view! { <p class=class>{content}</p> }.into_any()
621 }
622 Widget::Image { source, shape, ratio } => {
623 let (class, source) = (image_class(*shape, *ratio), source.clone());
624 view! { <img class=class src=source /> }.into_any()
625 }
626 Widget::Badge { label, tone } => {
627 let (class, label) = (format!("badge {}", tone_class(*tone)), label.clone());
628 view! { <span class=class>{label}</span> }.into_any()
629 }
630 Widget::ColorDot { color } => {
631 view! { <span class=format!("dot {}", dot_class(*color))></span> }.into_any()
632 }
633 Widget::Avatar { source, status } => {
634 let dot = status.map(|t| view! { <span class=format!("avatar-status {}", tone_class(t))></span> });
635 view! {
636 <span class="avatar">
637 <img class="avatar-img" src=source.clone() />
638 {dot}
639 </span>
640 }
641 .into_any()
642 }
643 Widget::PdfView { url } => {
644 view! { <iframe class="pdfview" src=url.clone() title="PDF"></iframe> }.into_any()
646 }
647 Widget::WebView { url } => {
648 view! {
651 <iframe
652 class="webview"
653 src=url.clone()
654 title="Web"
655 allow="autoplay; fullscreen; picture-in-picture; encrypted-media"
656 allowfullscreen=true
657 ></iframe>
658 }.into_any()
659 }
660 Widget::Map { id, center_lat, center_lng, zoom, markers, style_url, interactive } => {
664 let send = send.clone();
665 let id = id.clone();
666 let center = format!("{center_lat},{center_lng}");
667 let markers_json = serde_json::to_string(markers).unwrap_or_else(|_| "[]".to_string());
668 let style = style_url.clone().unwrap_or_default();
669 view! {
670 <div class="mobiler-map-wrap">
671 <div
672 class="mobiler-map"
673 data-map="1"
674 data-center=center
675 data-zoom=zoom.to_string()
676 data-style=style
677 data-markers=markers_json
678 data-interactive=interactive.to_string()
679 ></div>
680 <input
681 class="mobiler-map-sink"
682 type="text"
683 tabindex="-1"
684 aria-hidden="true"
685 on:input=move |ev| {
686 let raw = event_target_value(&ev);
687 if let Some((suffix, value)) = raw.split_once('|') {
688 send(Action::Input {
689 id: format!("{id}.{suffix}"),
690 value: InputValue::Text(value.to_string()),
691 });
692 }
693 }
694 />
695 </div>
696 }.into_any()
697 }
698 Widget::Video { url, playing, controls, looping, muted, on_ended, poster, start_at_ms, captions, rate, volume, urls, start_index, .. } => {
699 use wasm_bindgen::JsCast;
707 let (send, ended) = (send.clone(), on_ended.clone());
708 let autoplay = *playing && *muted;
709 let playlist = urls.clone();
710 let start_index = (*start_index).max(0) as usize;
711 let effective = if playlist.is_empty() { url.clone() }
712 else { playlist.get(start_index).cloned().unwrap_or_else(|| url.clone()) };
713 let is_hls = effective.to_ascii_lowercase().ends_with(".m3u8");
714 let src = (!is_hls).then(|| effective.clone());
715 let hls_src = is_hls.then(|| effective.clone());
716 let poster_attr = poster.clone();
717 let start_at = *start_at_ms;
718 let rate = *rate as f64;
719 let volume = (*volume as f64).clamp(0.0, 1.0);
720 let tracks: Vec<_> = captions.iter().map(|c| view! {
721 <track kind="subtitles" src=c.url.clone() srclang=c.language.clone() label=c.label.clone() default=c.default_on />
722 }).collect();
723 let next_idx = std::rc::Rc::new(std::cell::Cell::new(start_index));
724 view! {
725 <video
726 class="video"
727 src=src
728 data-hls-src=hls_src
729 poster=poster_attr
730 controls=*controls
731 autoplay=autoplay
732 prop:loop=*looping
733 prop:playbackRate=rate
734 prop:volume=volume
735 muted=*muted
736 playsinline=true
737 on:loadedmetadata=move |ev| {
738 if start_at >= 0 {
739 if let Some(v) = ev.target().and_then(|t| t.dyn_into::<web_sys::HtmlVideoElement>().ok()) {
740 v.set_current_time(start_at as f64 / 1000.0);
741 }
742 }
743 }
744 on:ended=move |ev| {
745 let nxt = next_idx.get() + 1;
746 if !playlist.is_empty() && nxt < playlist.len() {
747 next_idx.set(nxt);
748 if let Some(v) = ev.target().and_then(|t| t.dyn_into::<web_sys::HtmlVideoElement>().ok()) {
749 v.set_src(&playlist[nxt]);
750 let _ = v.play();
751 }
752 } else if let Some(t) = ended.clone() {
753 send(Action::Fired { token: t });
754 }
755 }
756 >{tracks}</video>
757 }.into_any()
758 }
759 Widget::Rating { value, max, on_rate } => {
760 let value = *value;
761 let stars: Vec<AnyView> = (1..=*max)
762 .map(|i| {
763 let threshold = u32::from(i) * 10;
764 let glyph = if value >= threshold { "★" } else if value + 5 >= threshold { "⯨" } else { "☆" };
766 match on_rate {
767 Some(tokens) => {
768 let (send, token) = (send.clone(), tokens.get(usize::from(i - 1)).cloned().unwrap_or_default());
769 view! {
770 <button class="star star-tappable" on:click=move |_| send(Action::Fired { token: token.clone() })>
771 {glyph}
772 </button>
773 }
774 .into_any()
775 }
776 None => view! { <span class="star">{glyph}</span> }.into_any(),
777 }
778 })
779 .collect();
780 view! { <span class="rating">{stars}</span> }.into_any()
781 }
782 Widget::Divider => view! { <hr class="divider" /> }.into_any(),
783 Widget::Progress { value } => match value {
784 Some(v) => {
785 let pct = (v.clamp(0.0, 1.0) * 100.0) as u32;
786 view! { <div class="progress"><div class="progress-bar" style=format!("width:{pct}%")></div></div> }.into_any()
787 }
788 None => view! { <div class="progress progress-indeterminate"><div class="progress-bar"></div></div> }.into_any(),
789 },
790 Widget::Skeleton => view! { <div class="skeleton"></div> }.into_any(),
791 Widget::Chart { series, labels, style, axis, legend } => {
792 chart_view(series, labels, *style, *axis, *legend)
793 }
794 Widget::RegionChart { regions, ticks, x_max, y_max, ref_lines, bracket, legend } => {
795 region_chart_view(regions, ticks, *x_max, *y_max, ref_lines, bracket, legend)
796 }
797 Widget::Calendar { year, month, first_weekday, selected, on_day } => {
798 const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June",
799 "July", "August", "September", "October", "November", "December"];
800 let head_label = format!("{} {year}", MONTHS.get((*month as usize).saturating_sub(1)).copied().unwrap_or(""));
801 let weekdays = ["S", "M", "T", "W", "T", "F", "S"];
802 let heads: Vec<_> = weekdays.iter().map(|w| view! { <div class="cal-head">{*w}</div> }).collect();
803 let blanks: Vec<_> = (0..*first_weekday).map(|_| view! { <div class="cal-blank"></div> }).collect();
804 let selected = *selected;
805 let days: Vec<_> = on_day.iter().enumerate().map(|(i, token)| {
806 let day = (i + 1) as u8;
807 let token = token.clone();
808 let send = send.clone();
809 let cls = if selected == Some(day) { "cal-day cal-sel" } else { "cal-day" };
810 view! { <button class=cls on:click=move |_| send(Action::Fired { token: token.clone() })>{day.to_string()}</button> }
811 }).collect();
812 view! {
813 <div class="calendar">
814 <div class="cal-title">{head_label}</div>
815 <div class="cal-grid">{heads}{blanks}{days}</div>
816 </div>
817 }.into_any()
818 }
819 Widget::SwipeAction { child, actions } => {
820 let acts: Vec<_> = actions.iter().map(|a| {
822 let token = a.on_tap.clone();
823 let send = send.clone();
824 let cls = format!("swipe-act {}", tone_class(a.tone));
825 let label = a.label.clone();
826 view! { <button class=cls on:click=move |_| send(Action::Fired { token: token.clone() })>{label}</button> }
827 }).collect();
828 view! {
829 <div class="swipe-row">
830 <div class="swipe-content">{render(child, send)}</div>
831 <div class="swipe-actions">{acts}</div>
832 </div>
833 }.into_any()
834 }
835 Widget::Spacer { size } => {
836 view! { <div class=format!("spacer {}", spacer_class(*size))></div> }.into_any()
837 }
838
839 Widget::Row { children } => {
841 let kids = render_all(children, send);
842 view! { <div class="row">{kids}</div> }.into_any()
843 }
844 Widget::Column { children } => {
845 let kids = render_all(children, send);
846 view! { <div class="col">{kids}</div> }.into_any()
847 }
848 Widget::Card { child, style, on_press, on_long_press } => {
849 let class = format!("card {}", card_class(*style));
850 let body = render(child, send);
851 match (on_press, on_long_press) {
852 (None, None) => view! { <div class=class>{body}</div> }.into_any(),
854 (tap, long) => {
856 let send = send.clone();
857 let timer: Rc<RefCell<Option<gloo_timers::callback::Timeout>>> =
861 Rc::new(RefCell::new(None));
862 let long_fired = Rc::new(RefCell::new(false));
863
864 let on_pointerdown = {
865 let (send, long, timer, long_fired) =
866 (send.clone(), long.clone(), timer.clone(), long_fired.clone());
867 move |_: web_sys::PointerEvent| {
868 let Some(token) = long.clone() else { return };
869 *long_fired.borrow_mut() = false;
870 let (send, long_fired) = (send.clone(), long_fired.clone());
871 *timer.borrow_mut() = Some(gloo_timers::callback::Timeout::new(
872 500,
873 move || {
874 *long_fired.borrow_mut() = true;
875 send(Action::Fired { token: token.clone() });
876 },
877 ));
878 }
879 };
880 let cancel = {
881 let timer = timer.clone();
882 move |_: web_sys::PointerEvent| { timer.borrow_mut().take(); }
884 };
885 let on_click = {
886 let (send, tap, long_fired) = (send.clone(), tap.clone(), long_fired.clone());
887 move |_| {
888 if std::mem::take(&mut *long_fired.borrow_mut()) {
890 return;
891 }
892 if let Some(token) = tap.clone() {
893 send(Action::Fired { token });
894 }
895 }
896 };
897 view! {
898 <button
899 class=format!("{class} card-tappable")
900 on:pointerdown=on_pointerdown
901 on:pointerup=cancel.clone()
902 on:pointerleave=cancel.clone()
903 on:pointercancel=cancel
904 on:click=on_click
905 >
906 {body}
907 </button>
908 }
909 .into_any()
910 }
911 }
912 }
913 Widget::Box { children, align, scrim } => {
917 let acls = align_class(*align);
918 if *scrim && children.len() > 1 {
919 let bg = render(&children[0], send);
920 let content = render_all(&children[1..], send);
921 view! {
922 <div class=format!("box box-scrim {acls}")>
923 {bg}
924 <div class="scrim"></div>
925 <div class="box-content">{content}</div>
926 </div>
927 }
928 .into_any()
929 } else {
930 let kids = render_all(children, send);
931 view! { <div class=format!("box {acls}")>{kids}</div> }.into_any()
932 }
933 }
934 Widget::Grid { children } => {
935 let kids = render_all(children, send);
936 view! { <div class="grid">{kids}</div> }.into_any()
937 }
938 Widget::Scroller { children } => {
939 let kids = render_all(children, send);
940 view! { <div class="scroller">{kids}</div> }.into_any()
941 }
942 Widget::Split { primary, detail, show_detail, on_back } => {
946 let p = render(primary, send);
947 let d = render(detail, send);
948 let back_btn = on_back.clone().map(|t| {
949 let send = send.clone();
950 view! { <button class="split-back" on:click=move |_| send(Action::Fired { token: t.clone() })>"‹ Back"</button> }
951 });
952 view! {
953 <div class="split" data-detail=show_detail.then_some("1")>
954 <div class="split-primary">{p}</div>
955 <div class="split-detail">{back_btn}{d}</div>
956 </div>
957 }.into_any()
958 }
959 Widget::A11y { child, label, hint, role } => {
962 let body = render(child, send);
963 let role_attr = role.map(a11y_role_aria).unwrap_or("group");
964 view! {
965 <div class="a11y" role=role_attr aria-label=label.clone() title=hint.clone()>
966 {body}
967 </div>
968 }.into_any()
969 }
970 Widget::LazyList { children, on_load_more, loading, has_more, on_refresh, refreshing } => {
975 let kids = render_all(children, send);
976 let refresh_btn = on_refresh.clone().map(|token| {
977 let send = send.clone();
978 view! { <button class="refresh-btn" on:click=move |_| send(Action::Fired { token: token.clone() })>"↻ Refresh"</button> }
979 });
980 let refresh_bar = refreshing.then(|| view! { <div class="progress progress-indeterminate"><div class="progress-bar"></div></div> });
981 let loading_bar = loading.then(|| view! { <div class="progress progress-indeterminate"><div class="progress-bar"></div></div> });
982 let load_more_btn = (!*loading && *has_more)
983 .then(|| on_load_more.clone())
984 .flatten()
985 .map(|token| {
986 let send = send.clone();
987 view! { <button class="btn btn-outlined lazylist-more" on:click=move |_| send(Action::Fired { token: token.clone() })>"Load more"</button> }
988 });
989 let end_cap = (!*has_more && on_load_more.is_some()).then(|| view! { <div class="lazylist-end">"End of list"</div> });
990 view! {
991 <div class="lazylist">
992 {refresh_btn}
993 {refresh_bar}
994 {kids}
995 {loading_bar}
996 {load_more_btn}
997 {end_cap}
998 </div>
999 }.into_any()
1000 }
1001
1002 Widget::Button { label, style, on_press } => {
1004 let (send, token, label) = (send.clone(), on_press.clone(), label.clone());
1005 let class = format!("btn {}", button_class(*style));
1006 view! {
1007 <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
1008 {label}
1009 </button>
1010 }
1011 .into_any()
1012 }
1013 Widget::IconButton { icon, on_press } => {
1014 let (send, token) = (send.clone(), on_press.clone());
1015 let glyph = icon_glyph(*icon);
1016 view! {
1017 <button class="iconbtn" on:click=move |_| send(Action::Fired { token: token.clone() })>
1018 {glyph}
1019 </button>
1020 }
1021 .into_any()
1022 }
1023 Widget::Chip { label, selected, on_press } => {
1024 let (send, token, label) = (send.clone(), on_press.clone(), label.clone());
1025 let class = if *selected { "chip selected" } else { "chip" };
1026 view! {
1027 <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
1028 {label}
1029 </button>
1030 }
1031 .into_any()
1032 }
1033 Widget::TextField { id, placeholder, value, kind, error } => {
1034 let (send, id) = (send.clone(), id.clone());
1035 let (placeholder, value) = (placeholder.clone(), value.clone());
1036 let invalid = error.is_some();
1037 let err_view = error.clone().map(|m| view! { <div class="field-error">{m}</div> });
1038 let (itype, imode): (&str, &str) = match kind {
1040 FieldKind::Secure => ("password", ""),
1041 FieldKind::Email => ("email", "email"),
1042 FieldKind::Number => ("text", "numeric"),
1043 FieldKind::Decimal => ("text", "decimal"),
1044 FieldKind::Phone => ("tel", "tel"),
1045 FieldKind::Url => ("url", "url"),
1046 FieldKind::Text | FieldKind::Multiline => ("text", ""),
1047 };
1048 let field_class = if invalid { "field field-invalid" } else { "field" };
1049 let control = if matches!(kind, FieldKind::Multiline) {
1050 view! {
1051 <textarea
1052 class=field_class
1053 rows="3"
1054 placeholder=placeholder
1055 prop:value=value
1056 on:input=move |ev| send(Action::Input {
1057 id: id.clone(),
1058 value: InputValue::Text(event_target_value(&ev)),
1059 })
1060 ></textarea>
1061 }
1062 .into_any()
1063 } else {
1064 view! {
1065 <input
1066 class=field_class
1067 r#type=itype
1068 inputmode=imode
1069 placeholder=placeholder
1070 prop:value=value
1071 on:input=move |ev| send(Action::Input {
1072 id: id.clone(),
1073 value: InputValue::Text(event_target_value(&ev)),
1074 })
1075 />
1076 }
1077 .into_any()
1078 };
1079 view! { <div class="field-wrap">{control}{err_view}</div> }.into_any()
1080 }
1081 Widget::SearchField { id, placeholder, value } => {
1082 let (send, id) = (send.clone(), id.clone());
1083 let (placeholder, value) = (placeholder.clone(), value.clone());
1084 view! {
1085 <div class="searchfield">
1086 <span class="search-icon">{icon_glyph(Icon::Search)}</span>
1087 <input
1088 class="search-input"
1089 placeholder=placeholder
1090 prop:value=value
1091 on:input=move |ev| send(Action::Input {
1092 id: id.clone(),
1093 value: InputValue::Text(event_target_value(&ev)),
1094 })
1095 />
1096 </div>
1097 }
1098 .into_any()
1099 }
1100 Widget::Segmented { segments } => {
1101 let segs: Vec<AnyView> = segments
1102 .iter()
1103 .map(|s| {
1104 let (send, token) = (send.clone(), s.on_select.clone());
1105 let class = if s.selected { "segment selected" } else { "segment" };
1106 let label = s.label.clone();
1107 view! {
1108 <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
1109 {label}
1110 </button>
1111 }
1112 .into_any()
1113 })
1114 .collect();
1115 view! { <div class="segmented">{segs}</div> }.into_any()
1116 }
1117 Widget::Toggle { id, label, value } => {
1118 let (send, id, label, checked) = (send.clone(), id.clone(), label.clone(), *value);
1119 view! {
1120 <label class="toggle">
1121 {label}
1122 <input
1123 type="checkbox"
1124 role="switch"
1125 prop:checked=checked
1126 on:change=move |ev| send(Action::Input {
1127 id: id.clone(),
1128 value: InputValue::Bool(event_target_checked(&ev)),
1129 })
1130 />
1131 </label>
1132 }
1133 .into_any()
1134 }
1135 Widget::Checkbox { id, label, value } => {
1136 let (send, id, label, checked) = (send.clone(), id.clone(), label.clone(), *value);
1137 view! {
1138 <label class="check">
1139 <input
1140 type="checkbox"
1141 prop:checked=checked
1142 on:change=move |ev| send(Action::Input {
1143 id: id.clone(),
1144 value: InputValue::Bool(event_target_checked(&ev)),
1145 })
1146 />
1147 {label}
1148 </label>
1149 }
1150 .into_any()
1151 }
1152 Widget::Slider { id, value, max } => {
1153 let (send, id, value, max) = (send.clone(), id.clone(), *value, *max);
1154 view! {
1155 <input
1156 class="slider"
1157 type="range"
1158 min="0"
1159 max=max
1160 prop:value=value
1161 on:input=move |ev| send(Action::Input {
1162 id: id.clone(),
1163 value: InputValue::Int(event_target_value(&ev).parse().unwrap_or(0)),
1164 })
1165 />
1166 }
1167 .into_any()
1168 }
1169 Widget::Stepper { value, on_decrement, on_increment } => {
1170 let send_dec = send.clone();
1171 let send_inc = send.clone();
1172 let (dec, inc) = (on_decrement.clone(), on_increment.clone());
1173 view! {
1174 <div class="stepper">
1175 <button on:click=move |_| send_dec(Action::Fired { token: dec.clone() })>"−"</button>
1176 <span class="stepper-value">{*value}</span>
1177 <button on:click=move |_| send_inc(Action::Fired { token: inc.clone() })>"+"</button>
1178 </div>
1179 }
1180 .into_any()
1181 }
1182
1183 Widget::Scaffold { title, body, tabs, back, dark_mode, theme, fab, sheet, on_refresh, refreshing, route, depth } => {
1185 let back_btn = back.clone().map(|token| {
1186 let send = send.clone();
1187 view! {
1188 <button class="back" on:click=move |_| send(Action::Fired { token: token.clone() })>
1189 "‹"
1190 </button>
1191 }
1192 });
1193 let tabbar = (!tabs.is_empty()).then(|| {
1194 let tabs: Vec<AnyView> = tabs
1195 .iter()
1196 .map(|tab| {
1197 let (send, token) = (send.clone(), tab.on_select.clone());
1198 let class = if tab.selected { "tab selected" } else { "tab" };
1199 let label = tab.label.clone();
1200 let icon = tab.icon.map(|i| view! { <span class="tab-icon">{icon_glyph(i)}</span> });
1202 view! {
1203 <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
1204 {icon}
1205 <span class="tab-label">{label}</span>
1206 </button>
1207 }
1208 .into_any()
1209 })
1210 .collect();
1211 view! { <div class="tabbar">{tabs}</div> }
1212 });
1213 let fab_btn = fab.clone().map(|f| {
1215 let (send, token) = (send.clone(), f.on_press.clone());
1216 view! {
1217 <button class="fab" on:click=move |_| send(Action::Fired { token: token.clone() })>
1218 {icon_glyph(f.icon)}
1219 </button>
1220 }
1221 });
1222 let sheet_overlay = sheet.as_ref().map(|s| {
1224 let (send_scrim, dismiss) = (send.clone(), s.on_dismiss.clone());
1225 let (title, child) = (s.title.clone(), render(&s.child, send));
1226 view! {
1227 <div class="sheet-scrim" on:click=move |_| send_scrim(Action::Fired { token: dismiss.clone() })></div>
1228 <div class="sheet">
1229 <div class="sheet-handle"></div>
1230 <div class="sheet-title">{title}</div>
1231 {child}
1232 </div>
1233 }
1234 });
1235 let class = if *dark_mode { "scaffold theme-dark" } else { "scaffold" };
1238 let refresh_btn = on_refresh.clone().map(|token| {
1241 let send = send.clone();
1242 view! {
1243 <button class="refresh-btn" on:click=move |_| send(Action::Fired { token: token.clone() })>"↻"</button>
1244 }
1245 });
1246 let refresh_bar = refreshing.then(|| {
1247 view! { <div class="progress progress-indeterminate"><div class="progress-bar"></div></div> }
1248 });
1249 let body_class = format!("scaffold-body {}", nav_class(route, *depth));
1250 let theme_style = theme.as_ref().map(theme_css).unwrap_or_default();
1253 let (title, body) = (title.clone(), render(body, send));
1254 view! {
1255 <div class=class style=theme_style>
1256 <div class="topbar">
1257 {back_btn}
1258 <span class="title">{title}</span>
1259 {refresh_btn}
1260 </div>
1261 <div class=body_class data-route=route.clone()>{refresh_bar}{body}</div>
1262 {fab_btn}
1263 {tabbar}
1264 {sheet_overlay}
1265 </div>
1266 }
1267 .into_any()
1268 }
1269 }
1270}
1271
1272fn render_all(children: &[Widget], send: &Dispatch) -> Vec<AnyView> {
1274 children.iter().map(|c| render(c, send)).collect()
1275}
1276
1277thread_local! {
1278 static NAV: RefCell<(String, u32, bool)> = const { RefCell::new((String::new(), 0, false)) };
1283
1284 static STREAMS: RefCell<HashMap<String, StreamHandle>> = RefCell::new(HashMap::new());
1288}
1289
1290fn theme_css(t: &Theme) -> String {
1295 let (r, g, b) = (t.seed.r, t.seed.g, t.seed.b);
1296 let radius = match t.corner {
1297 Corner::None => "0px",
1298 Corner::Small => "8px",
1299 Corner::Medium => "14px",
1300 Corner::Large => "22px",
1301 };
1302 let (gap, pad) = match t.density {
1303 Density::Compact => ("8px", "10px"),
1304 Density::Comfortable => ("12px", "14px"),
1305 };
1306 let font = match t.font {
1307 FontFamily::System => "system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif",
1308 FontFamily::Rounded => "ui-rounded, \"SF Pro Rounded\", \"Segoe UI\", system-ui, sans-serif",
1309 FontFamily::Serif => "ui-serif, Georgia, \"Times New Roman\", serif",
1310 FontFamily::Monospace => "ui-monospace, \"SF Mono\", \"Cascadia Code\", Menlo, monospace",
1311 };
1312 let (ar, ag, ab) = t.accent.map_or((r, g, b), |a| (a.r, a.g, a.b));
1314 format!(
1315 "--primary:rgb({r},{g},{b});--accent:rgb({r},{g},{b});\
1316 --accent2:rgb({ar},{ag},{ab});\
1317 --accent-soft:rgba({r},{g},{b},0.16);--radius:{radius};\
1318 --gap:{gap};--pad:{pad};--font:{font};"
1319 )
1320}
1321
1322fn nav_class(route: &str, depth: u32) -> &'static str {
1329 NAV.with_borrow_mut(|(prev_route, prev_depth, toggle)| {
1330 if route == prev_route {
1331 return "";
1332 }
1333 let dir = if depth > *prev_depth {
1334 ["nav-push-a", "nav-push-b"]
1335 } else if depth < *prev_depth {
1336 ["nav-pop-a", "nav-pop-b"]
1337 } else {
1338 ["nav-fade-a", "nav-fade-b"]
1339 };
1340 *toggle = !*toggle;
1341 *prev_route = route.to_string();
1342 *prev_depth = depth;
1343 dir[usize::from(*toggle)]
1344 })
1345}
1346
1347fn text_class(s: TextStyle) -> &'static str {
1350 match s {
1351 TextStyle::Title => "t-title",
1352 TextStyle::Subtitle => "t-subtitle",
1353 TextStyle::Caption => "t-caption",
1354 TextStyle::Emphasis => "t-emphasis",
1355 TextStyle::Body => "t-body",
1356 }
1357}
1358
1359fn button_class(s: ButtonStyle) -> &'static str {
1360 match s {
1361 ButtonStyle::Filled => "btn-filled",
1362 ButtonStyle::Outlined => "btn-outlined",
1363 ButtonStyle::Text => "btn-text",
1364 }
1365}
1366
1367fn card_class(s: CardStyle) -> &'static str {
1368 match s {
1369 CardStyle::Elevated => "card-elevated",
1370 CardStyle::Outlined => "card-outlined",
1371 CardStyle::Filled => "card-filled",
1372 CardStyle::Brand => "card-brand",
1373 }
1374}
1375
1376fn a11y_role_aria(role: A11yRole) -> &'static str {
1377 match role {
1378 A11yRole::Button => "button",
1379 A11yRole::Link => "link",
1380 A11yRole::Image => "img",
1381 A11yRole::Header => "heading",
1382 A11yRole::Adjustable => "slider",
1383 }
1384}
1385
1386fn tone_class(t: Tone) -> &'static str {
1387 match t {
1388 Tone::Neutral => "tone-neutral",
1389 Tone::Success => "tone-success",
1390 Tone::Warning => "tone-warning",
1391 Tone::Danger => "tone-danger",
1392 Tone::Info => "tone-info",
1393 }
1394}
1395
1396fn spacer_class(s: Spacing) -> &'static str {
1397 match s {
1398 Spacing::Xs => "sp-xs",
1399 Spacing::Sm => "sp-sm",
1400 Spacing::Md => "sp-md",
1401 Spacing::Lg => "sp-lg",
1402 Spacing::Xl => "sp-xl",
1403 }
1404}
1405
1406fn icon_glyph(i: Icon) -> &'static str {
1407 match i {
1408 Icon::Delete => "🗑",
1409 Icon::Add => "+",
1410 Icon::Edit => "✏️",
1411 Icon::Close => "✕",
1412 Icon::Settings => "⚙",
1413 Icon::Check => "✓",
1414 Icon::Star => "★",
1415 Icon::Info => "ℹ",
1416 Icon::Home => "⌂",
1417 Icon::Search => "🔍",
1418 Icon::Menu => "☰",
1419 Icon::Filter => "⚟",
1420 Icon::Back => "‹",
1421 Icon::Forward => "›",
1422 Icon::Down => "⌄",
1423 Icon::Bell => "🔔",
1424 Icon::Cart => "🛒",
1425 Icon::Share => "↗",
1426 Icon::Heart => "♡",
1427 Icon::HeartFilled => "♥",
1428 Icon::Person => "👤",
1429 Icon::People => "👥",
1430 Icon::Phone => "📞",
1431 Icon::Mail => "✉",
1432 Icon::Calendar => "📅",
1433 Icon::Clock => "🕑",
1434 Icon::MapPin => "📍",
1435 Icon::Camera => "📷",
1436 Icon::Photo => "🖼",
1437 Icon::Play => "▶",
1438 Icon::Scissors => "✂",
1439 }
1440}
1441
1442fn image_class(shape: ImageShape, ratio: ImageRatio) -> String {
1443 let shape = match shape {
1444 ImageShape::Square => "img-square",
1445 ImageShape::Rounded => "img-rounded",
1446 ImageShape::Circle => "img-circle",
1447 };
1448 let ratio = match ratio {
1449 ImageRatio::Wide => "ratio-wide",
1450 ImageRatio::Square => "ratio-square",
1451 ImageRatio::Tall => "ratio-tall",
1452 };
1453 format!("img {shape} {ratio}")
1454}
1455
1456fn dot_class(c: ProjectColor) -> &'static str {
1457 match c {
1458 ProjectColor::Indigo => "dot-indigo",
1459 ProjectColor::Teal => "dot-teal",
1460 ProjectColor::Coral => "dot-coral",
1461 ProjectColor::Amber => "dot-amber",
1462 ProjectColor::Lime => "dot-lime",
1463 ProjectColor::Pink => "dot-pink",
1464 }
1465}
1466
1467fn align_class(a: BoxAlign) -> &'static str {
1468 match a {
1469 BoxAlign::TopStart => "align-top-start",
1470 BoxAlign::TopEnd => "align-top-end",
1471 BoxAlign::Center => "align-center",
1472 BoxAlign::BottomStart => "align-bottom-start",
1473 BoxAlign::BottomCenter => "align-bottom-center",
1474 BoxAlign::BottomEnd => "align-bottom-end",
1475 }
1476}
1477
1478const CHART_PALETTE: [&str; 6] = ["#E0772C", "#2EA06A", "#C0466B", "#8A5CC0", "#C9A227", "#3FA7D6"];
1482
1483fn hex(c: Rgb) -> String {
1484 format!("#{:02x}{:02x}{:02x}", c.r, c.g, c.b)
1485}
1486
1487fn chart_color(i: usize, s: &ChartSeries) -> String {
1489 match s.color {
1490 Some(c) => hex(c),
1491 None if i == 0 => "var(--accent, #5C6BC0)".to_string(),
1492 None => CHART_PALETTE[(i - 1) % CHART_PALETTE.len()].to_string(),
1493 }
1494}
1495
1496fn chart_mag(s: &ChartSeries) -> f32 {
1498 s.values.iter().copied().sum()
1499}
1500
1501fn polar(cx: f32, cy: f32, r: f32, ang: f32) -> (f32, f32) {
1503 (cx + r * ang.sin(), cy - r * ang.cos())
1504}
1505
1506fn arc_path(cx: f32, cy: f32, r: f32, a0: f32, a1: f32) -> String {
1508 let (x0, y0) = polar(cx, cy, r, a0);
1509 let (x1, y1) = polar(cx, cy, r, a1);
1510 let large = if (a1 - a0).abs() > std::f32::consts::PI { 1 } else { 0 };
1511 format!("M {x0:.2} {y0:.2} A {r:.2} {r:.2} 0 {large} 1 {x1:.2} {y1:.2}")
1512}
1513
1514fn wedge_path(cx: f32, cy: f32, r: f32, a0: f32, a1: f32) -> String {
1516 let (x0, y0) = polar(cx, cy, r, a0);
1517 let (x1, y1) = polar(cx, cy, r, a1);
1518 let large = if (a1 - a0).abs() > std::f32::consts::PI { 1 } else { 0 };
1519 format!("M {cx:.2} {cy:.2} L {x0:.2} {y0:.2} A {r:.2} {r:.2} 0 {large} 1 {x1:.2} {y1:.2} Z")
1520}
1521
1522fn fmt_tick(v: f32) -> String {
1523 if (v - v.round()).abs() < 0.05 { format!("{}", v.round() as i64) } else { format!("{v:.1}") }
1524}
1525
1526fn is_cartesian(style: ChartStyle) -> bool {
1527 matches!(style, ChartStyle::Bar | ChartStyle::Line | ChartStyle::StackedBar | ChartStyle::StackedBar100)
1528}
1529
1530fn cartesian_max(series: &[ChartSeries], style: ChartStyle, nslots: usize) -> f32 {
1532 match style {
1533 ChartStyle::StackedBar => (0..nslots)
1534 .map(|j| series.iter().map(|s| *s.values.get(j).unwrap_or(&0.0)).sum::<f32>())
1535 .fold(0.0, f32::max)
1536 .max(1e-6),
1537 ChartStyle::StackedBar100 => 1.0,
1538 _ => series.iter().flat_map(|s| s.values.iter().copied()).fold(0.0, f32::max).max(1e-6),
1539 }
1540}
1541
1542fn cartesian_svg(series: &[ChartSeries], style: ChartStyle, axis: bool, max: f32, nslots: usize) -> AnyView {
1543 let mut nodes: Vec<AnyView> = Vec::new();
1545 if axis {
1546 for k in 0..=4 {
1547 let y = 2.0 + k as f32 * (46.0 / 4.0);
1548 nodes.push(view! { <line x1="0" y1=format!("{y:.2}") x2="100" y2=format!("{y:.2}") class="chart-gridline"></line> }.into_any());
1549 }
1550 }
1551 match style {
1552 ChartStyle::Line => {
1553 for (i, s) in series.iter().enumerate() {
1554 let n = s.values.len().max(1);
1555 let pts = s.values.iter().enumerate().map(|(j, v)| {
1556 let x = if n == 1 { 50.0 } else { j as f32 * (100.0 / (n as f32 - 1.0)) };
1557 let y = 2.0 + (1.0 - (v / max).clamp(0.0, 1.0)) * 46.0;
1558 format!("{x:.2},{y:.2}")
1559 }).collect::<Vec<_>>().join(" ");
1560 let st = format!("fill:none;stroke:{};stroke-width:1.5;vector-effect:non-scaling-stroke", chart_color(i, s));
1561 nodes.push(view! { <polyline points=pts style=st></polyline> }.into_any());
1562 }
1563 }
1564 ChartStyle::Bar => {
1565 let sw = 100.0 / nslots as f32;
1566 let ns = series.len().max(1);
1567 for (i, s) in series.iter().enumerate() {
1568 let st = format!("fill:{}", chart_color(i, s));
1569 for (j, v) in s.values.iter().enumerate() {
1570 let h = (v / max).clamp(0.0, 1.0) * 46.0;
1571 let bw = sw * 0.8 / ns as f32;
1572 let x = j as f32 * sw + sw * 0.1 + i as f32 * bw;
1573 let y = 48.0 - h;
1574 nodes.push(view! { <rect x=format!("{x:.2}") y=format!("{y:.2}") width=format!("{bw:.2}") height=format!("{h:.2}") style=st.clone()></rect> }.into_any());
1575 }
1576 }
1577 }
1578 ChartStyle::StackedBar | ChartStyle::StackedBar100 => {
1579 let sw = 100.0 / nslots as f32;
1580 for j in 0..nslots {
1581 let slot_total = series.iter().map(|s| *s.values.get(j).unwrap_or(&0.0)).sum::<f32>().max(1e-6);
1582 let denom = if matches!(style, ChartStyle::StackedBar100) { slot_total } else { max };
1583 let mut acc = 0.0_f32;
1584 for (i, s) in series.iter().enumerate() {
1585 let v = *s.values.get(j).unwrap_or(&0.0);
1586 let h = (v / denom).clamp(0.0, 1.0) * 46.0;
1587 let x = j as f32 * sw + sw * 0.15;
1588 let bw = sw * 0.7;
1589 let y = 48.0 - acc - h;
1590 let st = format!("fill:{}", chart_color(i, s));
1591 nodes.push(view! { <rect x=format!("{x:.2}") y=format!("{y:.2}") width=format!("{bw:.2}") height=format!("{h:.2}") style=st></rect> }.into_any());
1592 acc += h;
1593 }
1594 }
1595 }
1596 _ => {}
1597 }
1598 view! { <svg viewBox="0 0 100 50" preserveAspectRatio="none" class="chart-svg">{nodes}</svg> }.into_any()
1599}
1600
1601fn circular_svg(series: &[ChartSeries], style: ChartStyle) -> AnyView {
1602 use std::f32::consts::PI;
1603 let mut nodes: Vec<AnyView> = Vec::new();
1604 match style {
1605 ChartStyle::Pie | ChartStyle::Donut => {
1606 let total = series.iter().map(chart_mag).sum::<f32>().max(1e-6);
1607 let mut a = 0.0_f32;
1608 for (i, s) in series.iter().enumerate() {
1609 let frac = chart_mag(s) / total;
1610 let st = format!("fill:{}", chart_color(i, s));
1611 if frac >= 0.999 {
1612 nodes.push(view! { <circle cx="50" cy="50" r="45" style=st></circle> }.into_any());
1613 } else if frac > 0.0 {
1614 let d = wedge_path(50.0, 50.0, 45.0, a, a + frac * 2.0 * PI);
1615 nodes.push(view! { <path d=d style=st></path> }.into_any());
1616 }
1617 a += frac * 2.0 * PI;
1618 }
1619 if matches!(style, ChartStyle::Donut) {
1620 nodes.push(view! { <circle cx="50" cy="50" r="24" style="fill:var(--surface, #ffffff)"></circle> }.into_any());
1621 }
1622 }
1623 ChartStyle::Rings => {
1624 let n = series.len().max(1);
1625 for (i, s) in series.iter().enumerate() {
1626 let r = 45.0 - i as f32 * (34.0 / n as f32);
1627 let goal = s.goal.unwrap_or_else(|| chart_mag(s)).max(1e-6);
1628 let prog = (chart_mag(s) / goal).clamp(0.0, 1.0);
1629 nodes.push(view! { <circle cx="50" cy="50" r=format!("{r:.2}") style="fill:none;stroke:var(--border, #e6e6e6);stroke-width:6"></circle> }.into_any());
1630 let st = format!("fill:none;stroke:{};stroke-width:6;stroke-linecap:round", chart_color(i, s));
1631 if prog >= 0.999 {
1632 nodes.push(view! { <circle cx="50" cy="50" r=format!("{r:.2}") style=st></circle> }.into_any());
1633 } else if prog > 0.0 {
1634 let d = arc_path(50.0, 50.0, r, 0.0, prog * 2.0 * PI);
1635 nodes.push(view! { <path d=d style=st></path> }.into_any());
1636 }
1637 }
1638 }
1639 ChartStyle::Gauge => {
1640 let s = match series.first() { Some(s) => s, None => return view! { <svg viewBox="0 0 100 100" class="chart-svg"></svg> }.into_any() };
1641 let goal = s.goal.unwrap_or_else(|| chart_mag(s)).max(1e-6);
1642 let prog = (chart_mag(s) / goal).clamp(0.0, 1.0);
1643 let a0 = -0.75 * PI; let a1 = 0.75 * PI;
1645 nodes.push(view! { <path d=arc_path(50.0, 50.0, 42.0, a0, a1) style="fill:none;stroke:var(--border, #e6e6e6);stroke-width:8;stroke-linecap:round"></path> }.into_any());
1646 if prog > 0.0 {
1647 let st = format!("fill:none;stroke:{};stroke-width:8;stroke-linecap:round", chart_color(0, s));
1648 nodes.push(view! { <path d=arc_path(50.0, 50.0, 42.0, a0, a0 + prog * 1.5 * PI) style=st></path> }.into_any());
1649 }
1650 let pct = format!("{}%", (prog * 100.0).round() as i64);
1651 nodes.push(view! { <text x="50" y="56" style="fill:var(--fg, #222);font-size:20px;font-weight:700;text-anchor:middle">{pct}</text> }.into_any());
1652 }
1653 _ => {}
1654 }
1655 view! { <svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet" class="chart-svg">{nodes}</svg> }.into_any()
1656}
1657
1658fn chart_view(series: &[ChartSeries], labels: &[String], style: ChartStyle, axis: bool, legend: bool) -> AnyView {
1659 let cartesian = is_cartesian(style);
1660 let nslots = series.iter().map(|s| s.values.len()).max().unwrap_or(0).max(1);
1661 let max = cartesian_max(series, style, nslots);
1662
1663 let plot = if cartesian {
1664 let svg = cartesian_svg(series, style, axis, max, nslots);
1665 let yaxis = if axis {
1666 let ticks: Vec<_> = [max, max / 2.0, 0.0].iter()
1667 .map(|t| view! { <span class="chart-tick">{fmt_tick(*t)}</span> })
1668 .collect();
1669 Some(view! { <div class="chart-yaxis">{ticks}</div> })
1670 } else {
1671 None
1672 };
1673 view! { <div class="chart-plot">{yaxis}{svg}</div> }.into_any()
1674 } else {
1675 circular_svg(series, style).into_any()
1676 };
1677
1678 let label_row = if cartesian && !labels.is_empty() {
1679 let items: Vec<_> = labels.iter().map(|l| view! { <span class="chart-label">{l.clone()}</span> }).collect();
1680 Some(view! { <div class="chart-labels">{items}</div> })
1681 } else {
1682 None
1683 };
1684
1685 let legend_row = if legend {
1686 let items: Vec<_> = series.iter().enumerate().map(|(i, s)| {
1687 let sw = format!("background:{}", chart_color(i, s));
1688 let name = s.name.clone();
1689 view! { <span class="chart-legend-item"><span class="chart-swatch" style=sw></span>{name}</span> }
1690 }).collect();
1691 Some(view! { <div class="chart-legend">{items}</div> })
1692 } else {
1693 None
1694 };
1695
1696 view! { <div class="chart">{plot}{label_row}{legend_row}</div> }.into_any()
1697}
1698
1699const CHART_PALETTE_RGB: [(u8, u8, u8); 6] =
1703 [(0xE0, 0x77, 0x2C), (0x2E, 0xA0, 0x6A), (0xC0, 0x46, 0x6B), (0x8A, 0x5C, 0xC0), (0xC9, 0xA2, 0x27), (0x3F, 0xA7, 0xD6)];
1704
1705fn region_rgb(i: usize, r: &ChartRegion) -> (u8, u8, u8) {
1707 match r.color {
1708 Some(c) => (c.r, c.g, c.b),
1709 None => CHART_PALETTE_RGB[i % CHART_PALETTE_RGB.len()],
1710 }
1711}
1712
1713fn contrast_text((r, g, b): (u8, u8, u8)) -> &'static str {
1715 let lum = 0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32;
1716 if lum > 140.0 { "#1a1a1a" } else { "#f5f5f5" }
1717}
1718
1719fn region_color(i: usize, r: &ChartRegion) -> String {
1720 let (r8, g8, b8) = region_rgb(i, r);
1721 format!("#{r8:02x}{g8:02x}{b8:02x}")
1722}
1723
1724fn region_chart_view(
1728 regions: &[ChartRegion],
1729 ticks: &[ChartTick],
1730 x_max: f32,
1731 y_max: f32,
1732 ref_lines: &[ChartRefLine],
1733 bracket: &Option<ChartBracket>,
1734 legend: &[ChartLegendItem],
1735) -> AnyView {
1736 let xm = x_max.max(1e-6);
1737 let ym = y_max.max(1e-6);
1738
1739 let region_divs: Vec<_> = regions.iter().enumerate().map(|(i, r)| {
1740 let left = (r.x0 / xm * 100.0).clamp(0.0, 100.0);
1741 let width = ((r.x1 - r.x0) / xm * 100.0).clamp(0.0, 100.0);
1742 let bottom = (r.y0 / ym * 100.0).clamp(0.0, 100.0);
1743 let height = ((r.y1 - r.y0) / ym * 100.0).clamp(0.0, 100.0);
1744 let style = format!("left:{left:.3}%;width:{width:.3}%;bottom:{bottom:.3}%;height:{height:.3}%;background:{}", region_color(i, r));
1745 let label_class = if r.vertical { "rchart-label rchart-label-v" } else { "rchart-label" };
1746 let label_style = format!("color:{}", contrast_text(region_rgb(i, r)));
1747 let label = r.label.clone();
1748 view! { <div class="rchart-region" style=style><span class=label_class style=label_style>{label}</span></div> }
1749 }).collect();
1750
1751 let ref_line_divs: Vec<_> = ref_lines.iter().map(|rl| {
1754 let style = format!("bottom:{:.3}%", (rl.value / ym * 100.0).clamp(0.0, 100.0));
1755 let cls = if rl.dashed { "rchart-refline rchart-refline-dashed" } else { "rchart-refline" };
1756 view! { <div class=cls style=style></div> }
1757 }).collect();
1758 let chip_divs: Vec<_> = ref_lines.iter().map(|rl| {
1759 let style = format!("bottom:{:.3}%", (rl.value / ym * 100.0).clamp(0.0, 100.0));
1760 let label = rl.label.clone();
1761 view! { <div class="rchart-chip" style=style>{label}</div> }
1762 }).collect();
1763
1764 let bracket_div = bracket.as_ref().map(|b| {
1765 let bottom = (b.y0 / ym * 100.0).clamp(0.0, 100.0);
1766 let height = ((b.y1 - b.y0) / ym * 100.0).clamp(0.0, 100.0);
1767 let style = format!("bottom:{bottom:.3}%;height:{height:.3}%");
1768 let label = if b.info { format!("ⓘ\n{}", b.label) } else { b.label.clone() };
1769 view! { <div class="rchart-bracket" style=style><span>{label}</span></div> }
1770 });
1771
1772 let yticks: Vec<_> = (0..=4).rev().map(|k| {
1773 let v = ym * k as f32 / 4.0;
1774 view! { <span class="chart-tick">{fmt_tick(v)}</span> }
1775 }).collect();
1776
1777 let xticks: Vec<_> = ticks.iter().map(|t| {
1778 let style = format!("left:{:.3}%", (t.at / xm * 100.0).clamp(0.0, 100.0));
1779 let label = t.label.clone();
1780 view! { <span class="rchart-xtick" style=style>{label}</span> }
1781 }).collect();
1782
1783 let ytick_marks: Vec<_> = (0..=4).map(|k| {
1786 let style = format!("bottom:{:.3}%", k as f32 * 25.0);
1787 view! { <div class="rchart-ytick" style=style></div> }
1788 }).collect();
1789 let xtick_marks: Vec<_> = ticks.iter().map(|t| {
1790 let style = format!("left:{:.3}%", (t.at / xm * 100.0).clamp(0.0, 100.0));
1791 view! { <div class="rchart-xtickmark" style=style></div> }
1792 }).collect();
1793
1794 let legend_row = if legend.is_empty() {
1795 None
1796 } else {
1797 let items: Vec<_> = legend.iter().map(|l| {
1798 let sw = format!("background:{}", hex(l.color));
1799 let name = l.label.clone();
1800 view! { <span class="chart-legend-item"><span class="chart-swatch" style=sw></span>{name}</span> }
1801 }).collect();
1802 Some(view! { <div class="chart-legend">{items}</div> })
1803 };
1804
1805 view! {
1806 <div class="rchart">
1807 <div class="rchart-row">
1808 <div class="rchart-yaxis">{yticks}</div>
1809 <div class="rchart-plotwrap">
1810 <div class="rchart-plot">{region_divs}{ytick_marks}{xtick_marks}{ref_line_divs}</div>
1811 {chip_divs}{bracket_div}
1812 </div>
1813 </div>
1814 <div class="rchart-xaxis">{xticks}</div>
1815 {legend_row}
1816 </div>
1817 }.into_any()
1818}