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, HttpHeader, HttpOutcome, Icon,
27 ImageRatio, ImageShape, InputValue, PluginCall, PluginNotify, PluginResponse, PluginStreamCall, ProjectColor,
28 Rgb, ShellLabels, Spacing, TextStyle, Theme, Tone, TransferEvent, 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 || {
213 let widget = view.get();
214 ACTIVE_LABELS.with(|l| {
219 *l.borrow_mut() = match &widget {
220 Widget::Scaffold { labels, .. } => labels.clone(),
221 _ => None,
222 };
223 });
224 render(&widget, &send_for_view)
225 }}
226 </div>
227 }
228}
229
230fn drive<A: WebApp>(core: &Arc<Core<A>>, set_view: WriteSignal<Widget>, effects: Vec<Effect>)
232where
233 A::Model: Default + Send + Sync,
234{
235 for effect in effects {
236 match effect {
237 Effect::Render(_) => set_view.set(core.view()),
238 Effect::PluginNotify(notify) => perform_notify(¬ify.operation),
239 Effect::Plugin(mut request) => {
240 let core = core.clone();
241 spawn_local(async move {
242 let response = perform(&request.operation).await;
243 if let Ok(next) = core.resolve(&mut request, response) {
244 drive(&core, set_view, next);
245 }
246 });
247 }
248 Effect::PluginStream(request) => start_stream(core, set_view, request),
251 }
252 }
253}
254
255fn start_stream<A: WebApp>(
264 core: &Arc<Core<A>>,
265 set_view: WriteSignal<Widget>,
266 request: Request<PluginStreamCall>,
267) where
268 A::Model: Default + Send + Sync,
269{
270 use wasm_bindgen::{closure::Closure, JsCast};
271
272 let call = request.operation.clone();
273
274 let request = Rc::new(RefCell::new(request));
277 let core = core.clone();
278 let emit = move |resp: PluginResponse| {
279 if let Ok(next) = core.resolve(&mut *request.borrow_mut(), resp) {
280 drive(&core, set_view, next);
281 }
282 };
283
284 let handle = match (call.plugin.as_str(), call.op.as_str()) {
285 ("ticker", "start") => {
288 let ms: u32 = call.input.parse().unwrap_or(1000);
289 let count = std::cell::Cell::new(0u32);
290 let interval = gloo_timers::callback::Interval::new(ms, move || {
291 count.set(count.get() + 1);
292 emit(PluginResponse::text(true, count.get().to_string()));
293 });
294 StreamHandle::Ticker { _interval: interval }
295 }
296 ("websocket", "stream") => {
297 let Ok(ws) = web_sys::WebSocket::new(&call.input) else { return };
298 let onmessage = {
299 let emit = emit.clone();
300 Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |e: web_sys::MessageEvent| {
301 emit(PluginResponse::text(true, e.data().as_string().unwrap_or_default()));
302 })
303 };
304 let onclose = Closure::<dyn FnMut(web_sys::CloseEvent)>::new(move |_e| {
305 emit(PluginResponse::text(false, "closed"));
306 });
307 ws.set_onmessage(Some(onmessage.as_ref().unchecked_ref()));
308 ws.set_onclose(Some(onclose.as_ref().unchecked_ref()));
309 StreamHandle::Ws(WsStream { ws, _onmessage: onmessage, _onclose: onclose })
310 }
311 ("system", "events") => {
315 let win = web_sys::window().expect("window");
316 let doc = win.document().expect("document");
317 if let Ok(href) = win.location().href() {
319 emit(PluginResponse::text(true, system_deeplink(&href)));
320 }
321 emit(PluginResponse::text(true, system_lifecycle(&doc)));
322 let onpop = {
323 let (emit, win) = (emit.clone(), win.clone());
324 Closure::<dyn FnMut(web_sys::Event)>::new(move |_e: web_sys::Event| {
325 if let Ok(href) = win.location().href() {
326 emit(PluginResponse::text(true, system_deeplink(&href)));
327 }
328 })
329 };
330 let onvis = {
331 let (emit, doc) = (emit.clone(), doc.clone());
332 Closure::<dyn FnMut(web_sys::Event)>::new(move |_e: web_sys::Event| {
333 emit(PluginResponse::text(true, system_lifecycle(&doc)));
334 })
335 };
336 let _ = win.add_event_listener_with_callback("popstate", onpop.as_ref().unchecked_ref());
337 let _ = doc.add_event_listener_with_callback("visibilitychange", onvis.as_ref().unchecked_ref());
338 StreamHandle::System(SystemStream { win, doc, _onpop: onpop, _onvis: onvis })
339 }
340 ("transfer", op @ ("upload" | "download")) => {
345 let v: serde_json::Value = serde_json::from_str(&call.input).unwrap_or(serde_json::Value::Null);
346 let url = v.get("url").and_then(|x| x.as_str()).unwrap_or("").to_string();
347 let headers: Vec<(String, String)> = v
348 .get("headers")
349 .and_then(|x| x.as_array())
350 .map(|hs| {
351 hs.iter()
352 .filter_map(|h| Some((h.get("name")?.as_str()?.to_string(), h.get("value")?.as_str()?.to_string())))
353 .collect()
354 })
355 .unwrap_or_default();
356
357 if op == "upload" {
358 let method = v.get("method").and_then(|x| x.as_str()).unwrap_or("PUT").to_string();
359 let source = v.get("source").and_then(|x| x.as_str()).unwrap_or("").to_string();
360 let multipart = v.get("multipart").map(|m| WebMultipart {
361 field: m.get("field").and_then(|x| x.as_str()).unwrap_or("").to_string(),
362 filename: m.get("filename").and_then(|x| x.as_str()).map(|s| s.to_string()),
363 fields: m
364 .get("fields")
365 .and_then(|x| x.as_array())
366 .map(|fs| {
367 fs.iter()
368 .filter_map(|f| Some((f.get("name")?.as_str()?.to_string(), f.get("value")?.as_str()?.to_string())))
369 .collect()
370 })
371 .unwrap_or_default(),
372 });
373 start_web_upload(url, method, headers, source, multipart, emit.clone())
374 } else {
375 start_web_download(url, headers, emit.clone())
376 }
377 }
378 _ => return, };
380
381 STREAMS.with(|m| {
382 m.borrow_mut().insert(call.key.clone(), handle);
383 });
384}
385
386fn js_now() -> f64 {
388 web_sys::window().and_then(|w| w.performance()).map(|p| p.now()).unwrap_or(0.0)
389}
390
391struct WebMultipart {
396 field: String,
398 filename: Option<String>,
400 fields: Vec<(String, String)>,
402}
403
404fn infer_filename(source: &str) -> String {
406 let s = source.split(['?', '#']).next().unwrap_or(source);
407 let name = s.rsplit('/').next().unwrap_or("");
408 if name.is_empty() {
409 "file".to_string()
410 } else {
411 name.to_string()
412 }
413}
414
415fn parse_header_block(raw: &str) -> Vec<HttpHeader> {
419 raw.split("\r\n")
420 .filter_map(|line| {
421 let (name, value) = line.split_once(':')?;
422 let name = name.trim();
423 if name.is_empty() {
424 return None;
425 }
426 Some(HttpHeader { name: name.to_string(), value: value.trim().to_string() })
427 })
428 .collect()
429}
430
431fn start_web_upload(
439 url: String,
440 method: String,
441 headers: Vec<(String, String)>,
442 source: String,
443 multipart: Option<WebMultipart>,
444 emit: impl Fn(PluginResponse) + Clone + 'static,
445) -> StreamHandle {
446 use wasm_bindgen::{closure::Closure, JsCast};
447 let xhr = web_sys::XmlHttpRequest::new().expect("xhr");
448 let _ = xhr.open_with_async(&method, &url, true);
449 for (n, val) in &headers {
450 if multipart.is_some() && n.eq_ignore_ascii_case("content-type") {
455 continue;
456 }
457 let _ = xhr.set_request_header(n, val);
458 }
459
460 let last = std::rc::Rc::new(std::cell::Cell::new(0.0f64));
466 let on_prog = {
467 let (emit, last) = (emit.clone(), last.clone());
468 Closure::<dyn FnMut(web_sys::ProgressEvent)>::new(move |e: web_sys::ProgressEvent| {
469 let now = js_now();
470 if now - last.get() < 100.0 {
471 return;
472 }
473 last.set(now);
474 let total = if e.length_computable() { Some(e.total() as u64) } else { None };
475 emit(transfer_response(&TransferEvent::Progress { transferred: e.loaded() as u64, total }));
476 })
477 };
478 if let Ok(upload) = xhr.upload() {
479 upload.set_onprogress(Some(on_prog.as_ref().unchecked_ref()));
480 }
481
482 let on_done = {
485 let (emit, xhr_c) = (emit.clone(), xhr.clone());
486 Closure::<dyn FnMut()>::new(move || {
487 let status = xhr_c.status().unwrap_or(0);
488 let outcome = if status == 0 {
489 HttpOutcome::TransportError { message: "upload failed".into() }
490 } else {
491 let headers = xhr_c
497 .get_all_response_headers()
498 .ok()
499 .map(|raw| parse_header_block(&raw))
500 .unwrap_or_default();
501 HttpOutcome::Response { status, headers, body: vec![] }
502 };
503 emit(transfer_response(&TransferEvent::Done { outcome, handle: None }));
504 })
505 };
506 xhr.set_onload(Some(on_done.as_ref().unchecked_ref()));
507 let on_err = {
508 let emit = emit.clone();
509 Closure::<dyn FnMut()>::new(move || {
510 emit(transfer_response(&TransferEvent::Done {
511 outcome: HttpOutcome::TransportError { message: "upload error".into() },
512 handle: None,
513 }));
514 })
515 };
516 xhr.set_onerror(Some(on_err.as_ref().unchecked_ref()));
517 let on_abort = {
518 let emit = emit.clone();
519 Closure::<dyn FnMut()>::new(move || {
520 emit(transfer_response(&TransferEvent::Done {
521 outcome: HttpOutcome::TransportError { message: "upload aborted".into() },
522 handle: None,
523 }));
524 })
525 };
526 xhr.set_onabort(Some(on_abort.as_ref().unchecked_ref()));
527
528 let xhr_send = xhr.clone();
540 let cancelled = std::rc::Rc::new(std::cell::Cell::new(false));
541 let cancelled_send = cancelled.clone();
542 wasm_bindgen_futures::spawn_local(async move {
543 let blob = fetch_blob(&source).await;
544 if cancelled_send.get() {
545 return;
546 }
547 match (blob, &multipart) {
548 (Some(blob), Some(mp)) => {
549 let form = web_sys::FormData::new().expect("FormData");
551 for (name, value) in &mp.fields {
552 let _ = form.append_with_str(name, value);
553 }
554 let filename = mp.filename.clone().unwrap_or_else(|| infer_filename(&source));
555 let _ = form.append_with_blob_and_filename(&mp.field, &blob, &filename);
556 let _ = xhr_send.send_with_opt_form_data(Some(&form));
557 }
558 (Some(blob), None) => {
559 let _ = xhr_send.send_with_opt_blob(Some(&blob));
560 }
561 (None, _) => {
562 let _ = xhr_send.send();
563 }
564 }
565 });
566
567 StreamHandle::Transfer(TransferHandle {
568 xhr: Some(xhr),
569 abort: None,
570 cancelled: Some(cancelled),
571 _on_prog: Some(on_prog),
572 _on_done: Some(on_done),
573 _on_err: Some(on_err),
574 _on_abort: Some(on_abort),
575 })
576}
577
578async fn fetch_blob(url: &str) -> Option<web_sys::Blob> {
582 use wasm_bindgen::JsCast;
583 let win = web_sys::window()?;
584 let resp_value = wasm_bindgen_futures::JsFuture::from(win.fetch_with_str(url)).await.ok()?;
585 let resp: web_sys::Response = resp_value.dyn_into().ok()?;
586 let blob_promise = resp.blob().ok()?;
587 let blob_value = wasm_bindgen_futures::JsFuture::from(blob_promise).await.ok()?;
588 blob_value.dyn_into().ok()
589}
590
591fn start_web_download(
600 url: String,
601 headers: Vec<(String, String)>,
602 emit: impl Fn(PluginResponse) + Clone + 'static,
603) -> StreamHandle {
604 let ctrl = web_sys::AbortController::new().expect("abortcontroller");
605 let signal = ctrl.signal();
606 let emit2 = emit.clone();
607 wasm_bindgen_futures::spawn_local(async move {
608 match fetch_stream(&url, &headers, &signal).await {
609 Ok((status, resp_headers, total, mut reader)) => {
610 let mut got: u64 = 0;
611 let mut chunks: Vec<u8> = Vec::new();
612 let mut last = js_now();
613 loop {
614 match reader.next().await {
615 Ok(Some(chunk)) => {
616 got += chunk.len() as u64;
617 chunks.extend_from_slice(&chunk);
618 let now = js_now();
619 if now - last >= 100.0 {
621 last = now;
622 emit2(transfer_response(&TransferEvent::Progress { transferred: got, total }));
623 }
624 }
625 Ok(None) => break, Err(msg) => {
627 emit2(transfer_response(&TransferEvent::Done {
628 outcome: HttpOutcome::TransportError { message: msg },
629 handle: None,
630 }));
631 return;
632 }
633 }
634 }
635 let handle = make_blob_url(&chunks);
636 let outcome = HttpOutcome::Response { status, headers: resp_headers, body: vec![] };
637 emit2(transfer_response(&TransferEvent::Done { outcome, handle: Some(handle) }));
638 }
639 Err(msg) => emit2(transfer_response(&TransferEvent::Done {
640 outcome: HttpOutcome::TransportError { message: msg },
641 handle: None,
642 })),
643 }
644 });
645 StreamHandle::Transfer(TransferHandle {
646 xhr: None,
647 abort: Some(ctrl),
648 cancelled: None,
649 _on_prog: None,
650 _on_done: None,
651 _on_err: None,
652 _on_abort: None,
653 })
654}
655
656async fn fetch_stream(
660 url: &str,
661 headers: &[(String, String)],
662 signal: &web_sys::AbortSignal,
663) -> Result<(u16, Vec<HttpHeader>, Option<u64>, Reader), String> {
664 use wasm_bindgen::JsCast;
665 let win = web_sys::window().ok_or_else(|| "no window".to_string())?;
666 let js_headers = web_sys::Headers::new().map_err(|e| js_err(&e))?;
667 for (n, v) in headers {
668 js_headers.append(n, v).map_err(|e| js_err(&e))?;
669 }
670 let init = web_sys::RequestInit::new();
671 init.set_method("GET");
672 init.set_headers_headers(&js_headers);
673 init.set_signal(Some(signal));
674 let request = web_sys::Request::new_with_str_and_init(url, &init).map_err(|e| js_err(&e))?;
675
676 let resp_value = wasm_bindgen_futures::JsFuture::from(win.fetch_with_request(&request))
677 .await
678 .map_err(|e| js_err(&e))?;
679 let resp: web_sys::Response = resp_value.dyn_into().map_err(|_| "fetch: not a Response".to_string())?;
680 let status = resp.status();
681 let resp_headers = response_headers(&resp.headers());
682 let total = resp_headers
683 .iter()
684 .find(|h| h.name.eq_ignore_ascii_case("content-length"))
685 .and_then(|h| h.value.parse().ok());
686
687 let Some(stream) = resp.body() else {
688 return Ok((status, resp_headers, total, Reader::empty()));
691 };
692 let reader = web_sys::ReadableStreamDefaultReader::new(&stream).map_err(|e| js_err(&e))?;
693 Ok((status, resp_headers, total, Reader::new(reader)))
694}
695
696fn response_headers(headers: &web_sys::Headers) -> Vec<HttpHeader> {
699 use wasm_bindgen::JsCast;
700 let mut out = Vec::new();
701 if let Ok(Some(iter)) = js_sys::try_iter(headers) {
702 for entry in iter.flatten() {
703 let arr: js_sys::Array = entry.unchecked_into();
704 let name = arr.get(0).as_string().unwrap_or_default();
705 let value = arr.get(1).as_string().unwrap_or_default();
706 out.push(HttpHeader { name, value });
707 }
708 }
709 out
710}
711
712fn js_err(e: &wasm_bindgen::JsValue) -> String {
715 use wasm_bindgen::JsCast;
716 e.as_string()
717 .or_else(|| e.dyn_ref::<js_sys::Error>().map(|err| String::from(err.message())))
718 .unwrap_or_else(|| "transfer error".to_string())
719}
720
721struct Reader(Option<web_sys::ReadableStreamDefaultReader>);
725impl Reader {
726 fn new(reader: web_sys::ReadableStreamDefaultReader) -> Self {
727 Self(Some(reader))
728 }
729 fn empty() -> Self {
731 Self(None)
732 }
733 async fn next(&mut self) -> Result<Option<Vec<u8>>, String> {
734 use wasm_bindgen::JsCast;
735 let Some(reader) = &self.0 else { return Ok(None) };
736 let result = wasm_bindgen_futures::JsFuture::from(reader.read()).await.map_err(|e| js_err(&e))?;
737 let result: web_sys::ReadableStreamReadResult = result.unchecked_into();
738 if result.get_done().unwrap_or(true) {
739 return Ok(None);
740 }
741 let value = result.get_value();
742 let bytes = js_sys::Uint8Array::new(&value).to_vec();
743 Ok(Some(bytes))
744 }
745}
746
747fn make_blob_url(bytes: &[u8]) -> String {
751 let array = js_sys::Uint8Array::from(bytes);
752 let parts = js_sys::Array::new();
753 parts.push(&array);
754 web_sys::Blob::new_with_u8_array_sequence(&parts)
755 .ok()
756 .and_then(|blob| web_sys::Url::create_object_url_with_blob(&blob).ok())
757 .unwrap_or_default()
758}
759
760fn system_deeplink(url: &str) -> String {
762 format!("{{\"type\":\"deeplink\",\"url\":{}}}", serde_json::to_string(url).unwrap_or_else(|_| "\"\"".into()))
763}
764fn system_lifecycle(doc: &web_sys::Document) -> String {
766 let state = if doc.visibility_state() == web_sys::VisibilityState::Visible { "active" } else { "background" };
767 format!("{{\"type\":\"lifecycle\",\"state\":\"{state}\"}}")
768}
769
770enum StreamHandle {
774 Ticker { _interval: gloo_timers::callback::Interval },
776 Ws(WsStream),
777 #[allow(dead_code)]
780 System(SystemStream),
781 #[allow(dead_code)]
784 Transfer(TransferHandle),
785}
786
787struct TransferHandle {
791 xhr: Option<web_sys::XmlHttpRequest>,
792 abort: Option<web_sys::AbortController>,
793 cancelled: Option<std::rc::Rc<std::cell::Cell<bool>>>,
798 _on_prog: Option<wasm_bindgen::closure::Closure<dyn FnMut(web_sys::ProgressEvent)>>,
803 _on_done: Option<wasm_bindgen::closure::Closure<dyn FnMut()>>,
804 _on_err: Option<wasm_bindgen::closure::Closure<dyn FnMut()>>,
805 _on_abort: Option<wasm_bindgen::closure::Closure<dyn FnMut()>>,
806}
807impl Drop for TransferHandle {
808 fn drop(&mut self) {
809 if let Some(c) = &self.cancelled {
810 c.set(true);
811 }
812 if let Some(x) = &self.xhr {
813 let _ = x.abort();
814 }
815 if let Some(a) = &self.abort {
816 a.abort();
817 }
818 }
819}
820
821fn transfer_response(ev: &TransferEvent) -> PluginResponse {
823 PluginResponse {
824 ok: matches!(ev, TransferEvent::Done { outcome, .. } if outcome.is_success()),
825 output: ev.encode(),
826 }
827}
828
829struct SystemStream {
831 win: web_sys::Window,
832 doc: web_sys::Document,
833 _onpop: wasm_bindgen::closure::Closure<dyn FnMut(web_sys::Event)>,
834 _onvis: wasm_bindgen::closure::Closure<dyn FnMut(web_sys::Event)>,
835}
836impl Drop for SystemStream {
837 fn drop(&mut self) {
838 use wasm_bindgen::JsCast;
839 let _ = self.win.remove_event_listener_with_callback("popstate", self._onpop.as_ref().unchecked_ref());
840 let _ = self.doc.remove_event_listener_with_callback("visibilitychange", self._onvis.as_ref().unchecked_ref());
841 }
842}
843
844struct WsStream {
846 ws: web_sys::WebSocket,
847 _onmessage: wasm_bindgen::closure::Closure<dyn FnMut(web_sys::MessageEvent)>,
848 _onclose: wasm_bindgen::closure::Closure<dyn FnMut(web_sys::CloseEvent)>,
849}
850
851async fn perform(call: &PluginCall) -> PluginResponse {
854 if call.plugin == "device" {
855 let nav = web_sys::window().map(|w| w.navigator());
856 let output = if call.op == "locale" {
857 nav.and_then(|n| n.language()).unwrap_or_else(|| "en-US".into())
859 } else {
860 nav.and_then(|n| n.user_agent().ok()).unwrap_or_default()
861 };
862 return PluginResponse::text(true, output);
863 }
864 if call.plugin == "photo" && call.op == "pick" {
865 return take_image(false).await;
866 }
867 if call.plugin == "camera" && call.op == "capture" {
868 return take_image(true).await;
869 }
870 if call.plugin == "datetime" {
871 return match call.op.as_str() {
872 "date" => take_datetime("date").await,
873 "time" => take_datetime("time").await,
874 other => PluginResponse::text(false, format!("unknown datetime op '{other}'")),
875 };
876 }
877 if call.plugin == "dialog" && call.op == "confirm" {
878 let ok = confirm_modal(ConfirmAsk::parse(&call.input)).await;
879 return PluginResponse::text(ok, if ok { "ok" } else { "cancel" });
880 }
881 if call.plugin != "http" {
882 return PluginResponse::text(false, format!("plugin '{}' not available", call.plugin));
883 }
884 let v: serde_json::Value = serde_json::from_str(&call.input).unwrap_or(serde_json::Value::Null);
885 let url = v.get("url").and_then(serde_json::Value::as_str).unwrap_or("");
886 let body = v.get("body").and_then(serde_json::Value::as_str);
887 let req_headers: Vec<(String, String)> = v
888 .get("headers")
889 .and_then(serde_json::Value::as_array)
890 .map(|hs| {
891 hs.iter()
892 .filter_map(|h| {
893 Some((
894 h.get("name")?.as_str()?.to_string(),
895 h.get("value")?.as_str()?.to_string(),
896 ))
897 })
898 .collect()
899 })
900 .unwrap_or_default();
901
902 use gloo_net::http::{Method, Request};
903
904 let builder = match call.op.as_str() {
907 "GET" => Request::get(url),
908 "POST" => Request::post(url),
909 "PUT" => Request::put(url),
910 "PATCH" => Request::patch(url),
911 "DELETE" => Request::delete(url),
912 "HEAD" => Request::get(url).method(Method::HEAD),
913 "OPTIONS" => Request::get(url).method(Method::OPTIONS),
914 other => return http_transport_error(format!("unsupported HTTP method '{other}'")),
915 };
916
917 let caller_set_content_type =
919 req_headers.iter().any(|(n, _)| n.eq_ignore_ascii_case("content-type"));
920
921 let gloo_headers = gloo_net::http::Headers::new();
927 for (name, value) in &req_headers {
928 gloo_headers.append(name, value);
929 }
930 if body.is_some() && !caller_set_content_type {
931 gloo_headers.append("Content-Type", "application/json");
932 }
933 let builder = builder.headers(gloo_headers);
934
935 let request = match body {
936 Some(b) => builder.body(b),
937 None => builder.build(),
938 };
939 let request = match request {
940 Ok(r) => r,
941 Err(e) => return http_transport_error(e.to_string()),
942 };
943
944 match request.send().await {
945 Ok(resp) => {
946 let status = resp.status();
947 let headers = resp
948 .headers()
949 .entries()
950 .map(|(name, value)| HttpHeader { name, value })
951 .collect();
952 match resp.binary().await {
953 Ok(bytes) => {
954 let outcome = HttpOutcome::Response { status, headers, body: bytes };
955 PluginResponse { ok: (200..300).contains(&status), output: outcome.encode() }
956 }
957 Err(e) => http_transport_error(e.to_string()),
960 }
961 }
962 Err(e) => http_transport_error(e.to_string()),
963 }
964}
965
966fn http_transport_error(message: String) -> PluginResponse {
968 PluginResponse { ok: false, output: HttpOutcome::TransportError { message }.encode() }
969}
970
971async fn take_image(capture: bool) -> PluginResponse {
978 use wasm_bindgen::{closure::Closure, JsCast};
979 let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
980 return PluginResponse::text(false, "no document");
981 };
982 let Some(input) = doc.create_element("input").ok().and_then(|e| e.dyn_into::<web_sys::HtmlInputElement>().ok()) else {
983 return PluginResponse::text(false, "no input element");
984 };
985 input.set_type("file");
986 input.set_accept("image/*");
987 if capture {
988 let _ = input.set_attribute("capture", "environment");
990 }
991
992 let (tx, rx) = futures_channel::oneshot::channel::<Option<String>>();
993 let tx = std::cell::RefCell::new(Some(tx));
994 let input_for_cb = input.clone();
995 let on_change = Closure::wrap(Box::new(move || {
996 let url = input_for_cb
997 .files()
998 .and_then(|files| files.get(0))
999 .and_then(|file| web_sys::Url::create_object_url_with_blob(&file).ok());
1000 if let Some(tx) = tx.borrow_mut().take() {
1001 let _ = tx.send(url);
1002 }
1003 }) as Box<dyn FnMut()>);
1004 input.set_onchange(Some(on_change.as_ref().unchecked_ref()));
1005 input.click();
1006 on_change.forget(); match rx.await {
1009 Ok(Some(url)) => PluginResponse::text(true, url),
1010 _ => PluginResponse::text(false, "cancelled"),
1011 }
1012}
1013
1014async fn take_datetime(kind: &str) -> PluginResponse {
1019 use wasm_bindgen::{closure::Closure, JsCast};
1020 let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
1021 return PluginResponse::text(false, "no document");
1022 };
1023 let Some(input) = doc.create_element("input").ok().and_then(|e| e.dyn_into::<web_sys::HtmlInputElement>().ok()) else {
1024 return PluginResponse::text(false, "no input element");
1025 };
1026 input.set_type(kind); let _ = input.set_attribute("style", "position:fixed;left:-9999px;opacity:0");
1029 if let Some(body) = doc.body() {
1030 let _ = body.append_child(&input);
1031 }
1032
1033 let (tx, rx) = futures_channel::oneshot::channel::<Option<String>>();
1034 let tx = std::rc::Rc::new(std::cell::RefCell::new(Some(tx)));
1035 let input_for_change = input.clone();
1036 let tx_change = tx.clone();
1037 let on_change = Closure::wrap(Box::new(move || {
1038 let v = input_for_change.value();
1039 if let Some(tx) = tx_change.borrow_mut().take() {
1040 let _ = tx.send(if v.is_empty() { None } else { Some(v) });
1041 }
1042 }) as Box<dyn FnMut()>);
1043 let tx_cancel = tx.clone();
1044 let on_cancel = Closure::wrap(Box::new(move || {
1045 if let Some(tx) = tx_cancel.borrow_mut().take() {
1046 let _ = tx.send(None);
1047 }
1048 }) as Box<dyn FnMut()>);
1049 let _ = input.add_event_listener_with_callback("change", on_change.as_ref().unchecked_ref());
1050 let _ = input.add_event_listener_with_callback("cancel", on_cancel.as_ref().unchecked_ref());
1051 if input.show_picker().is_err() {
1052 input.click(); }
1054 on_change.forget(); on_cancel.forget();
1056
1057 let result = rx.await;
1058 input.remove();
1059 match result {
1060 Ok(Some(v)) => PluginResponse::text(true, v),
1061 _ => PluginResponse::text(false, "cancelled"),
1062 }
1063}
1064
1065const STORAGE_KEY: &str = "mobiler.state";
1066
1067fn local_storage() -> Option<web_sys::Storage> {
1069 web_sys::window()?.local_storage().ok().flatten()
1070}
1071
1072fn perform_notify(notify: &PluginNotify) {
1076 let win = match web_sys::window() {
1077 Some(w) => w,
1078 None => return,
1079 };
1080 match (notify.plugin.as_str(), notify.op.as_str()) {
1081 ("storage", "save") => {
1083 if let Some(s) = local_storage() {
1084 let _ = s.set_item(STORAGE_KEY, ¬ify.input);
1085 }
1086 }
1087 ("clipboard", "copy") => {
1089 let _ = win.navigator().clipboard().write_text(¬ify.input);
1090 }
1091 ("browser", "open") => {
1093 let _ = win.open_with_url_and_target(¬ify.input, "_blank");
1094 }
1095 ("share", _) => {
1098 let _ = win.navigator().clipboard().write_text(¬ify.input);
1099 }
1100 ("stream", "unsubscribe") => {
1104 if let Some(StreamHandle::Ws(ws)) = STREAMS.with(|m| m.borrow_mut().remove(¬ify.input)) {
1107 let _ = ws.ws.close();
1108 }
1109 }
1110 ("toast", _) => show_toast(¬ify.input),
1112 ("haptics", style) => {
1114 let ms = match style {
1115 "light" => 15,
1116 "heavy" => 50,
1117 _ => 30, };
1119 let _ = win.navigator().vibrate_with_duration(ms);
1120 }
1121 _ => {} }
1123}
1124
1125fn show_toast(text: &str) {
1128 let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return };
1129 let (Ok(el), Some(body)) = (doc.create_element("div"), doc.body()) else { return };
1130 el.set_class_name("toast");
1131 el.set_text_content(Some(text));
1132 let _ = body.append_child(&el);
1133 gloo_timers::callback::Timeout::new(2600, move || el.remove()).forget();
1134}
1135
1136struct ConfirmAsk {
1139 title: String,
1140 message: String,
1141 confirm_label: String,
1142 cancel_label: String,
1143 destructive: bool,
1144}
1145
1146impl ConfirmAsk {
1147 fn parse(input: &str) -> Self {
1148 let v: serde_json::Value = serde_json::from_str(input).unwrap_or(serde_json::Value::Null);
1149 let s = |k: &str, d: &str| v.get(k).and_then(serde_json::Value::as_str).filter(|s| !s.is_empty()).unwrap_or(d).to_string();
1150 let ok_default = shell_label(|l| l.ok.clone(), "OK");
1153 let cancel_default = shell_label(|l| l.cancel.clone(), "Cancel");
1154 Self {
1155 title: s("title", ""),
1156 message: s("message", ""),
1157 confirm_label: s("confirm_label", &ok_default),
1158 cancel_label: s("cancel_label", &cancel_default),
1159 destructive: v.get("destructive").and_then(serde_json::Value::as_bool).unwrap_or(false),
1160 }
1161 }
1162}
1163
1164static CONFIRM_DIALOG_ID: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
1167
1168struct OpenConfirm {
1172 tx: std::rc::Rc<std::cell::RefCell<Option<futures_channel::oneshot::Sender<bool>>>>,
1173 superseded: std::rc::Rc<std::cell::Cell<bool>>,
1174 restore_to: Option<web_sys::Element>,
1175}
1176thread_local! {
1177 static OPEN_CONFIRM: std::cell::RefCell<Option<OpenConfirm>> = const { std::cell::RefCell::new(None) };
1178}
1179
1180async fn confirm_modal(ask: ConfirmAsk) -> bool {
1191 use wasm_bindgen::{closure::Closure, JsCast};
1192 let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return false };
1193 let Some(body) = doc.body() else { return false };
1194 let el = |tag: &str, class: &str| -> Option<web_sys::HtmlElement> {
1195 let e = doc.create_element(tag).ok()?.dyn_into::<web_sys::HtmlElement>().ok()?;
1196 e.set_class_name(class);
1197 Some(e)
1198 };
1199 let confirm_class = if ask.destructive { "btn btn-filled btn-danger" } else { "btn btn-filled" };
1200 let (Some(scrim), Some(card), Some(actions), Some(cancel), Some(confirm)) = (
1201 el("div", "confirm-scrim"),
1202 el("div", "confirm-card"),
1203 el("div", "confirm-actions"),
1204 el("button", "btn btn-text"),
1205 el("button", confirm_class),
1206 ) else {
1207 return false;
1208 };
1209
1210 if let Some(scaffold) = doc.query_selector(".scaffold").ok().flatten() {
1212 if let Some(style) = scaffold.get_attribute("style") {
1213 let _ = scrim.set_attribute("style", &style);
1214 }
1215 let classes = scaffold.class_list();
1216 for c in ["theme-dark", "density-large"] {
1217 if classes.contains(c) {
1218 let _ = scrim.class_list().add_1(c);
1219 }
1220 }
1221 }
1222
1223 let _ = card.set_attribute("role", "alertdialog");
1224 let _ = card.set_attribute("aria-modal", "true");
1225 let call_id = CONFIRM_DIALOG_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1226 if !ask.title.is_empty() {
1227 if let Some(title) = el("div", "confirm-title") {
1228 let title_id = format!("mobiler-confirm-title-{call_id}");
1229 title.set_text_content(Some(&ask.title));
1230 let _ = title.set_attribute("id", &title_id);
1231 let _ = card.append_child(&title);
1232 let _ = card.set_attribute("aria-labelledby", &title_id);
1233 }
1234 }
1235 if !ask.message.is_empty() {
1238 if let Some(message) = el("p", "confirm-message") {
1239 let msg_id = format!("mobiler-confirm-msg-{call_id}");
1240 message.set_text_content(Some(&ask.message));
1241 let _ = message.set_attribute("id", &msg_id);
1242 let _ = card.append_child(&message);
1243 let _ = card.set_attribute("aria-describedby", &msg_id);
1244 }
1245 }
1246 cancel.set_text_content(Some(&ask.cancel_label));
1247 confirm.set_text_content(Some(&ask.confirm_label));
1248 let _ = actions.append_child(&cancel);
1249 let _ = actions.append_child(&confirm);
1250 let _ = card.append_child(&actions);
1251 let _ = scrim.append_child(&card);
1252
1253 let previously_focused = OPEN_CONFIRM.with(|c| c.borrow_mut().take()).map(|old| {
1259 old.superseded.set(true);
1260 if let Some(tx) = old.tx.borrow_mut().take() {
1261 let _ = tx.send(false);
1262 }
1263 old.restore_to
1264 }).unwrap_or_else(|| doc.active_element());
1265
1266 let _ = body.append_child(&scrim);
1267 let _ = if ask.destructive { cancel.focus() } else { confirm.focus() };
1268
1269 let (tx, rx) = futures_channel::oneshot::channel::<bool>();
1270 let tx = std::rc::Rc::new(std::cell::RefCell::new(Some(tx)));
1271 let superseded = std::rc::Rc::new(std::cell::Cell::new(false));
1272 OPEN_CONFIRM.with(|c| {
1273 *c.borrow_mut() = Some(OpenConfirm { tx: tx.clone(), superseded: superseded.clone(), restore_to: previously_focused.clone() });
1274 });
1275 let answer = move |tx: &std::rc::Rc<std::cell::RefCell<Option<futures_channel::oneshot::Sender<bool>>>>, ok: bool| {
1276 if let Some(tx) = tx.borrow_mut().take() {
1277 let _ = tx.send(ok);
1278 }
1279 };
1280 let (t1, t2, t3, t4) = (tx.clone(), tx.clone(), tx.clone(), tx.clone());
1281 let on_cancel = Closure::wrap(Box::new(move || answer(&t1, false)) as Box<dyn FnMut()>);
1282 let on_confirm = Closure::wrap(Box::new(move || answer(&t2, true)) as Box<dyn FnMut()>);
1283
1284 let press_started_on_scrim = std::rc::Rc::new(std::cell::Cell::new(false));
1290 let scrim_node: web_sys::EventTarget = scrim.clone().into();
1291 let press_flag = press_started_on_scrim.clone();
1292 let pointerdown_target = scrim_node.clone();
1293 let on_pointerdown = Closure::wrap(Box::new(move |e: web_sys::Event| {
1294 press_flag.set(e.target().as_ref() == Some(&pointerdown_target));
1295 }) as Box<dyn FnMut(web_sys::Event)>);
1296 let on_backdrop = Closure::wrap(Box::new(move |e: web_sys::Event| {
1297 if press_started_on_scrim.get() && e.target().as_ref() == Some(&scrim_node) {
1298 answer(&t3, false);
1299 }
1300 }) as Box<dyn FnMut(web_sys::Event)>);
1301 let (doc_for_key, card_for_key, cancel_for_key, confirm_for_key) = (doc.clone(), card.clone(), cancel.clone(), confirm.clone());
1304 let on_key = Closure::wrap(Box::new(move |e: web_sys::KeyboardEvent| {
1305 if e.key() == "Escape" {
1306 answer(&t4, false);
1307 } else if e.key() == "Tab" {
1308 let first: web_sys::Element = cancel_for_key.clone().into();
1309 let last: web_sys::Element = confirm_for_key.clone().into();
1310 let active = doc_for_key.active_element();
1311 let inside = active.as_ref().is_some_and(|a| card_for_key.contains(a.dyn_ref::<web_sys::Node>()));
1312 let (at_first, at_last) = (active.as_ref() == Some(&first), active.as_ref() == Some(&last));
1313 if e.shift_key() {
1314 if at_first || !inside { e.prevent_default(); let _ = confirm_for_key.focus(); }
1315 } else if at_last || !inside {
1316 e.prevent_default();
1317 let _ = cancel_for_key.focus();
1318 }
1319 }
1320 }) as Box<dyn FnMut(web_sys::KeyboardEvent)>);
1321 cancel.set_onclick(Some(on_cancel.as_ref().unchecked_ref()));
1322 confirm.set_onclick(Some(on_confirm.as_ref().unchecked_ref()));
1323 let _ = scrim.add_event_listener_with_callback("pointerdown", on_pointerdown.as_ref().unchecked_ref());
1324 let _ = scrim.add_event_listener_with_callback("click", on_backdrop.as_ref().unchecked_ref());
1325 let _ = doc.add_event_listener_with_callback("keydown", on_key.as_ref().unchecked_ref());
1326
1327 let ok = rx.await.unwrap_or(false);
1328 let _ = doc.remove_event_listener_with_callback("keydown", on_key.as_ref().unchecked_ref());
1335 let _ = scrim.remove_event_listener_with_callback("click", on_backdrop.as_ref().unchecked_ref());
1336 let _ = scrim.remove_event_listener_with_callback("pointerdown", on_pointerdown.as_ref().unchecked_ref());
1337 cancel.set_onclick(None);
1338 confirm.set_onclick(None);
1339 scrim.remove();
1340 OPEN_CONFIRM.with(|c| {
1343 let mut open = c.borrow_mut();
1344 if open.as_ref().is_some_and(|o| std::rc::Rc::ptr_eq(&o.tx, &tx)) {
1345 *open = None;
1346 }
1347 });
1348 if !superseded.get() {
1351 if let Some(focus_target) = previously_focused.and_then(|e| e.dyn_into::<web_sys::HtmlElement>().ok()) {
1352 let _ = focus_target.focus();
1353 }
1354 }
1355 ok
1356}
1357
1358fn body_fill_index(body: &Widget) -> Option<Option<usize>> {
1363 match body {
1364 Widget::LazyList { fill: true, .. } => Some(None),
1365 Widget::Column { children } => {
1366 children.iter().position(|c| matches!(c, Widget::LazyList { fill: true, .. })).map(Some)
1367 }
1368 _ => None,
1369 }
1370}
1371
1372fn render(widget: &Widget, send: &Dispatch) -> AnyView {
1377 match widget {
1378 Widget::Text { content, style } => {
1380 let (class, content) = (text_class(*style), content.clone());
1381 view! { <p class=class>{content}</p> }.into_any()
1382 }
1383 Widget::Image { source, shape, ratio } => {
1384 let (class, source) = (image_class(*shape, *ratio), source.clone());
1385 view! { <img class=class src=source /> }.into_any()
1386 }
1387 Widget::Badge { label, tone } => {
1388 let (class, label) = (format!("badge {}", tone_class(*tone)), label.clone());
1389 view! { <span class=class>{label}</span> }.into_any()
1390 }
1391 Widget::ColorDot { color } => {
1392 view! { <span class=format!("dot {}", dot_class(*color))></span> }.into_any()
1393 }
1394 Widget::Avatar { source, status } => {
1395 let dot = status.map(|t| view! { <span class=format!("avatar-status {}", tone_class(t))></span> });
1396 view! {
1397 <span class="avatar">
1398 <img class="avatar-img" src=source.clone() />
1399 {dot}
1400 </span>
1401 }
1402 .into_any()
1403 }
1404 Widget::PdfView { url } => {
1405 let pdf_title = shell_label(|l| l.pdf_title.clone(), "PDF");
1407 view! { <iframe class="pdfview" src=url.clone() title=pdf_title></iframe> }.into_any()
1408 }
1409 Widget::WebView { url } => {
1410 let web_title = shell_label(|l| l.web_title.clone(), "Web");
1413 view! {
1414 <iframe
1415 class="webview"
1416 src=url.clone()
1417 title=web_title
1418 allow="autoplay; fullscreen; picture-in-picture; encrypted-media"
1419 allowfullscreen=true
1420 ></iframe>
1421 }.into_any()
1422 }
1423 Widget::Map { id, center_lat, center_lng, zoom, markers, style_url, interactive } => {
1427 let send = send.clone();
1428 let id = id.clone();
1429 let center = format!("{center_lat},{center_lng}");
1430 let markers_json = serde_json::to_string(markers).unwrap_or_else(|_| "[]".to_string());
1431 let style = style_url.clone().unwrap_or_default();
1432 view! {
1433 <div class="mobiler-map-wrap">
1434 <div
1435 class="mobiler-map"
1436 data-map="1"
1437 data-center=center
1438 data-zoom=zoom.to_string()
1439 data-style=style
1440 data-markers=markers_json
1441 data-interactive=interactive.to_string()
1442 ></div>
1443 <input
1444 class="mobiler-map-sink"
1445 type="text"
1446 tabindex="-1"
1447 aria-hidden="true"
1448 on:input=move |ev| {
1449 let raw = event_target_value(&ev);
1450 if let Some((suffix, value)) = raw.split_once('|') {
1451 send(Action::Input {
1452 id: format!("{id}.{suffix}"),
1453 value: InputValue::Text(value.to_string()),
1454 });
1455 }
1456 }
1457 />
1458 </div>
1459 }.into_any()
1460 }
1461 Widget::Video { url, playing, controls, looping, muted, on_ended, poster, start_at_ms, captions, rate, volume, urls, start_index, .. } => {
1462 use wasm_bindgen::JsCast;
1470 let (send, ended) = (send.clone(), on_ended.clone());
1471 let autoplay = *playing && *muted;
1472 let playlist = urls.clone();
1473 let start_index = (*start_index).max(0) as usize;
1474 let effective = if playlist.is_empty() { url.clone() }
1475 else { playlist.get(start_index).cloned().unwrap_or_else(|| url.clone()) };
1476 let is_hls = effective.to_ascii_lowercase().ends_with(".m3u8");
1477 let src = (!is_hls).then(|| effective.clone());
1478 let hls_src = is_hls.then(|| effective.clone());
1479 let poster_attr = poster.clone();
1480 let start_at = *start_at_ms;
1481 let rate = *rate as f64;
1482 let volume = (*volume as f64).clamp(0.0, 1.0);
1483 let tracks: Vec<_> = captions.iter().map(|c| view! {
1484 <track kind="subtitles" src=c.url.clone() srclang=c.language.clone() label=c.label.clone() default=c.default_on />
1485 }).collect();
1486 let next_idx = std::rc::Rc::new(std::cell::Cell::new(start_index));
1487 view! {
1488 <video
1489 class="video"
1490 src=src
1491 data-hls-src=hls_src
1492 poster=poster_attr
1493 controls=*controls
1494 autoplay=autoplay
1495 prop:loop=*looping
1496 prop:playbackRate=rate
1497 prop:volume=volume
1498 muted=*muted
1499 playsinline=true
1500 on:loadedmetadata=move |ev| {
1501 if start_at >= 0 {
1502 if let Some(v) = ev.target().and_then(|t| t.dyn_into::<web_sys::HtmlVideoElement>().ok()) {
1503 v.set_current_time(start_at as f64 / 1000.0);
1504 }
1505 }
1506 }
1507 on:ended=move |ev| {
1508 let nxt = next_idx.get() + 1;
1509 if !playlist.is_empty() && nxt < playlist.len() {
1510 next_idx.set(nxt);
1511 if let Some(v) = ev.target().and_then(|t| t.dyn_into::<web_sys::HtmlVideoElement>().ok()) {
1512 v.set_src(&playlist[nxt]);
1513 let _ = v.play();
1514 }
1515 } else if let Some(t) = ended.clone() {
1516 send(Action::Fired { token: t });
1517 }
1518 }
1519 >{tracks}</video>
1520 }.into_any()
1521 }
1522 Widget::Rating { value, max, on_rate } => {
1523 let value = *value;
1524 let stars: Vec<AnyView> = (1..=*max)
1525 .map(|i| {
1526 let threshold = u32::from(i) * 10;
1527 let glyph = if value >= threshold { "★" } else if value + 5 >= threshold { "⯨" } else { "☆" };
1529 match on_rate {
1530 Some(tokens) => {
1531 let (send, token) = (send.clone(), tokens.get(usize::from(i - 1)).cloned().unwrap_or_default());
1532 view! {
1533 <button class="star star-tappable" on:click=move |_| send(Action::Fired { token: token.clone() })>
1534 {glyph}
1535 </button>
1536 }
1537 .into_any()
1538 }
1539 None => view! { <span class="star">{glyph}</span> }.into_any(),
1540 }
1541 })
1542 .collect();
1543 view! { <span class="rating">{stars}</span> }.into_any()
1544 }
1545 Widget::Divider => view! { <hr class="divider" /> }.into_any(),
1546 Widget::Progress { value } => match value {
1547 Some(v) => {
1548 let pct = (v.clamp(0.0, 1.0) * 100.0) as u32;
1549 view! { <div class="progress"><div class="progress-bar" style=format!("width:{pct}%")></div></div> }.into_any()
1550 }
1551 None => view! { <div class="progress progress-indeterminate"><div class="progress-bar"></div></div> }.into_any(),
1552 },
1553 Widget::Skeleton => view! { <div class="skeleton"></div> }.into_any(),
1554 Widget::Chart { series, labels, style, axis, legend } => {
1555 chart_view(series, labels, *style, *axis, *legend)
1556 }
1557 Widget::RegionChart { regions, ticks, x_max, y_max, ref_lines, bracket, legend } => {
1558 region_chart_view(regions, ticks, *x_max, *y_max, ref_lines, bracket, legend)
1559 }
1560 Widget::Calendar { title, weekday_labels, leading_blanks, selected, on_day, markers, .. } => {
1561 let heads: Vec<_> = weekday_labels.iter().map(|w| view! { <div class="cal-head">{w.clone()}</div> }).collect();
1562 let blanks: Vec<_> = (0..*leading_blanks).map(|_| view! { <div class="cal-blank"></div> }).collect();
1563 let selected = *selected;
1564 let days: Vec<_> = on_day.iter().enumerate().map(|(i, token)| {
1565 let day = (i + 1) as u8;
1566 let token = token.clone();
1567 let send = send.clone();
1568 let cls = if selected == Some(day) { "cal-day cal-sel" } else { "cal-day" };
1569 let level = markers.get(i).copied().unwrap_or(0).min(3);
1571 let dots = (level > 0).then(|| {
1572 let d: Vec<_> = (0..level).map(|_| view! { <span class="cal-dot"></span> }).collect();
1573 view! { <span class="cal-dots">{d}</span> }
1574 });
1575 view! { <button class=cls on:click=move |_| send(Action::Fired { token: token.clone() })>{day.to_string()}{dots}</button> }
1576 }).collect();
1577 view! {
1578 <div class="calendar">
1579 <div class="cal-title">{title.clone()}</div>
1580 <div class="cal-grid">{heads}{blanks}{days}</div>
1581 </div>
1582 }.into_any()
1583 }
1584 Widget::SwipeAction { child, actions } => {
1585 let acts: Vec<_> = actions.iter().map(|a| {
1587 let token = a.on_tap.clone();
1588 let send = send.clone();
1589 let cls = format!("swipe-act {}", tone_class(a.tone));
1590 let label = a.label.clone();
1591 view! { <button class=cls on:click=move |_| send(Action::Fired { token: token.clone() })>{label}</button> }
1592 }).collect();
1593 view! {
1594 <div class="swipe-row">
1595 <div class="swipe-content">{render(child, send)}</div>
1596 <div class="swipe-actions">{acts}</div>
1597 </div>
1598 }.into_any()
1599 }
1600 Widget::Spacer { size } => {
1601 view! { <div class=format!("spacer {}", spacer_class(*size))></div> }.into_any()
1602 }
1603
1604 Widget::Row { children } => {
1606 let kids = render_all(children, send);
1607 view! { <div class="row">{kids}</div> }.into_any()
1608 }
1609 Widget::Column { children } => {
1610 let kids = render_all(children, send);
1611 view! { <div class="col">{kids}</div> }.into_any()
1612 }
1613 Widget::Card { child, style, on_press, on_long_press } => {
1614 let class = format!("card {}", card_class(*style));
1615 let body = render(child, send);
1616 match (on_press, on_long_press) {
1617 (None, None) => view! { <div class=class>{body}</div> }.into_any(),
1619 (tap, long) => {
1621 let send = send.clone();
1622 let timer: Rc<RefCell<Option<gloo_timers::callback::Timeout>>> =
1626 Rc::new(RefCell::new(None));
1627 let long_fired = Rc::new(RefCell::new(false));
1628
1629 let on_pointerdown = {
1630 let (send, long, timer, long_fired) =
1631 (send.clone(), long.clone(), timer.clone(), long_fired.clone());
1632 move |_: web_sys::PointerEvent| {
1633 let Some(token) = long.clone() else { return };
1634 *long_fired.borrow_mut() = false;
1635 let (send, long_fired) = (send.clone(), long_fired.clone());
1636 *timer.borrow_mut() = Some(gloo_timers::callback::Timeout::new(
1637 500,
1638 move || {
1639 *long_fired.borrow_mut() = true;
1640 send(Action::Fired { token: token.clone() });
1641 },
1642 ));
1643 }
1644 };
1645 let cancel = {
1646 let timer = timer.clone();
1647 move |_: web_sys::PointerEvent| { timer.borrow_mut().take(); }
1649 };
1650 let on_click = {
1651 let (send, tap, long_fired) = (send.clone(), tap.clone(), long_fired.clone());
1652 move |_| {
1653 if std::mem::take(&mut *long_fired.borrow_mut()) {
1655 return;
1656 }
1657 if let Some(token) = tap.clone() {
1658 send(Action::Fired { token });
1659 }
1660 }
1661 };
1662 view! {
1663 <button
1664 class=format!("{class} card-tappable")
1665 on:pointerdown=on_pointerdown
1666 on:pointerup=cancel.clone()
1667 on:pointerleave=cancel.clone()
1668 on:pointercancel=cancel
1669 on:click=on_click
1670 >
1671 {body}
1672 </button>
1673 }
1674 .into_any()
1675 }
1676 }
1677 }
1678 Widget::Box { children, align, scrim } => {
1682 let acls = align_class(*align);
1683 if *scrim && children.len() > 1 {
1684 let bg = render(&children[0], send);
1685 let content = render_all(&children[1..], send);
1686 view! {
1687 <div class=format!("box box-scrim {acls}")>
1688 {bg}
1689 <div class="scrim"></div>
1690 <div class="box-content">{content}</div>
1691 </div>
1692 }
1693 .into_any()
1694 } else {
1695 let kids = render_all(children, send);
1696 view! { <div class=format!("box {acls}")>{kids}</div> }.into_any()
1697 }
1698 }
1699 Widget::Grid { children } => {
1700 let kids = render_all(children, send);
1701 view! { <div class="grid">{kids}</div> }.into_any()
1702 }
1703 Widget::Scroller { children, edge_fade } => {
1704 let kids = render_all(children, send);
1705 if *edge_fade {
1706 view! { <div class="scroller scroller-fade">{kids}<div class="scroller-end"></div></div> }.into_any()
1707 } else {
1708 view! { <div class="scroller">{kids}</div> }.into_any()
1709 }
1710 }
1711 Widget::Split { primary, detail, show_detail, on_back } => {
1715 let p = render(primary, send);
1716 let d = render(detail, send);
1717 let back_text = format!("‹ {}", shell_label(|l| l.back.clone(), "Back"));
1718 let back_btn = on_back.clone().map(|t| {
1719 let send = send.clone();
1720 view! { <button class="split-back" on:click=move |_| send(Action::Fired { token: t.clone() })>{back_text}</button> }
1721 });
1722 view! {
1723 <div class="split" data-detail=show_detail.then_some("1")>
1724 <div class="split-primary">{p}</div>
1725 <div class="split-detail">{back_btn}{d}</div>
1726 </div>
1727 }.into_any()
1728 }
1729 Widget::A11y { child, label, hint, role } => {
1732 let body = render(child, send);
1733 let role_attr = role.map(a11y_role_aria).unwrap_or("group");
1734 view! {
1735 <div class="a11y" role=role_attr aria-label=label.clone() title=hint.clone()>
1736 {body}
1737 </div>
1738 }.into_any()
1739 }
1740 Widget::LazyList { children, on_load_more, loading, has_more, on_refresh, refreshing, end_label, fill } => {
1746 let kids = render_all(children, send);
1747 let is_fill_target =
1750 *fill && FILL_TARGET.with(|t| t.borrow().is_some_and(|p| std::ptr::eq(p, widget)));
1751 let list_class = if is_fill_target { "lazylist lazylist-fill" } else { "lazylist" };
1752 let refresh_text = format!("↻ {}", shell_label(|l| l.refresh.clone(), "Refresh"));
1753 let refresh_btn = on_refresh.clone().map(|token| {
1754 let send = send.clone();
1755 view! { <button class="refresh-btn" on:click=move |_| send(Action::Fired { token: token.clone() })>{refresh_text}</button> }
1756 });
1757 let refresh_bar = refreshing.then(|| view! { <div class="progress progress-indeterminate"><div class="progress-bar"></div></div> });
1758 let loading_bar = loading.then(|| view! { <div class="progress progress-indeterminate"><div class="progress-bar"></div></div> });
1759 let load_more_text = shell_label(|l| l.load_more.clone(), "Load more");
1760 let load_more_btn = (!*loading && *has_more)
1761 .then(|| on_load_more.clone())
1762 .flatten()
1763 .map(|token| {
1764 let send = send.clone();
1765 view! { <button class="btn btn-outlined lazylist-more" on:click=move |_| send(Action::Fired { token: token.clone() })>{load_more_text}</button> }
1766 });
1767 let end_cap = (!*has_more && on_load_more.is_some())
1769 .then(|| end_label.clone())
1770 .flatten()
1771 .map(|label| view! { <div class="lazylist-end">{label}</div> });
1772 view! {
1773 <div class=list_class>
1774 {refresh_btn}
1775 {refresh_bar}
1776 {kids}
1777 {loading_bar}
1778 {load_more_btn}
1779 {end_cap}
1780 </div>
1781 }.into_any()
1782 }
1783
1784 Widget::Button { label, style, on_press, tone, icon, wide } => {
1786 let (send, token, label) = (send.clone(), on_press.clone(), label.clone());
1787 let mut class = format!("btn {}", button_class(*style));
1789 if *tone != Tone::Neutral {
1790 class.push(' ');
1791 class.push_str(button_tone_class(*tone));
1792 }
1793 if *wide {
1794 class.push_str(" btn-wide");
1795 }
1796 let glyph = icon.map(|i| view! { <span class="btn-icon">{icon_glyph(i)}</span> });
1797 view! {
1798 <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
1799 {glyph}
1800 {label}
1801 </button>
1802 }
1803 .into_any()
1804 }
1805 Widget::IconButton { icon, on_press } => {
1806 let (send, token) = (send.clone(), on_press.clone());
1807 let glyph = icon_glyph(*icon);
1808 view! {
1809 <button class="iconbtn" on:click=move |_| send(Action::Fired { token: token.clone() })>
1810 {glyph}
1811 </button>
1812 }
1813 .into_any()
1814 }
1815 Widget::Chip { label, selected, on_press } => {
1816 let (send, token, label) = (send.clone(), on_press.clone(), label.clone());
1817 let class = if *selected { "chip selected" } else { "chip" };
1818 view! {
1819 <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
1820 {label}
1821 </button>
1822 }
1823 .into_any()
1824 }
1825 Widget::TextField { id, placeholder, value, kind, error } => {
1826 let (send, id) = (send.clone(), id.clone());
1827 let (placeholder, value) = (placeholder.clone(), value.clone());
1828 let invalid = error.is_some();
1829 let err_view = error.clone().map(|m| view! { <div class="field-error">{m}</div> });
1830 let (itype, imode): (&str, &str) = match kind {
1832 FieldKind::Secure => ("password", ""),
1833 FieldKind::Email => ("email", "email"),
1834 FieldKind::Number => ("text", "numeric"),
1835 FieldKind::Decimal => ("text", "decimal"),
1836 FieldKind::Phone => ("tel", "tel"),
1837 FieldKind::Url => ("url", "url"),
1838 FieldKind::Text | FieldKind::Multiline => ("text", ""),
1839 };
1840 let field_class = if invalid { "field field-invalid" } else { "field" };
1841 let control = if matches!(kind, FieldKind::Multiline) {
1842 view! {
1843 <textarea
1844 class=field_class
1845 rows="3"
1846 placeholder=placeholder
1847 prop:value=value
1848 on:input=move |ev| send(Action::Input {
1849 id: id.clone(),
1850 value: InputValue::Text(event_target_value(&ev)),
1851 })
1852 ></textarea>
1853 }
1854 .into_any()
1855 } else {
1856 view! {
1857 <input
1858 class=field_class
1859 r#type=itype
1860 inputmode=imode
1861 placeholder=placeholder
1862 prop:value=value
1863 on:input=move |ev| send(Action::Input {
1864 id: id.clone(),
1865 value: InputValue::Text(event_target_value(&ev)),
1866 })
1867 />
1868 }
1869 .into_any()
1870 };
1871 view! { <div class="field-wrap">{control}{err_view}</div> }.into_any()
1872 }
1873 Widget::SearchField { id, placeholder, value } => {
1874 let (send, id) = (send.clone(), id.clone());
1875 let (placeholder, value) = (placeholder.clone(), value.clone());
1876 view! {
1877 <div class="searchfield">
1878 <span class="search-icon">{icon_glyph(Icon::Search)}</span>
1879 <input
1880 class="search-input"
1881 placeholder=placeholder
1882 prop:value=value
1883 on:input=move |ev| send(Action::Input {
1884 id: id.clone(),
1885 value: InputValue::Text(event_target_value(&ev)),
1886 })
1887 />
1888 </div>
1889 }
1890 .into_any()
1891 }
1892 Widget::Segmented { segments } => {
1893 let segs: Vec<AnyView> = segments
1894 .iter()
1895 .map(|s| {
1896 let (send, token) = (send.clone(), s.on_select.clone());
1897 let class = if s.selected { "segment selected" } else { "segment" };
1898 let label = s.label.clone();
1899 view! {
1900 <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
1901 {label}
1902 </button>
1903 }
1904 .into_any()
1905 })
1906 .collect();
1907 view! { <div class="segmented">{segs}</div> }.into_any()
1908 }
1909 Widget::Toggle { id, label, value } => {
1910 let (send, id, label, checked) = (send.clone(), id.clone(), label.clone(), *value);
1911 view! {
1912 <label class="toggle">
1913 {label}
1914 <input
1915 type="checkbox"
1916 role="switch"
1917 prop:checked=checked
1918 on:change=move |ev| send(Action::Input {
1919 id: id.clone(),
1920 value: InputValue::Bool(event_target_checked(&ev)),
1921 })
1922 />
1923 </label>
1924 }
1925 .into_any()
1926 }
1927 Widget::Checkbox { id, label, value } => {
1928 let (send, id, label, checked) = (send.clone(), id.clone(), label.clone(), *value);
1929 view! {
1930 <label class="check">
1931 <input
1932 type="checkbox"
1933 prop:checked=checked
1934 on:change=move |ev| send(Action::Input {
1935 id: id.clone(),
1936 value: InputValue::Bool(event_target_checked(&ev)),
1937 })
1938 />
1939 {label}
1940 </label>
1941 }
1942 .into_any()
1943 }
1944 Widget::Slider { id, value, max } => {
1945 let (send, id, value, max) = (send.clone(), id.clone(), *value, *max);
1946 view! {
1947 <input
1948 class="slider"
1949 type="range"
1950 min="0"
1951 max=max
1952 prop:value=value
1953 on:input=move |ev| send(Action::Input {
1954 id: id.clone(),
1955 value: InputValue::Int(event_target_value(&ev).parse().unwrap_or(0)),
1956 })
1957 />
1958 }
1959 .into_any()
1960 }
1961 Widget::Stepper { value, on_decrement, on_increment } => {
1962 let send_dec = send.clone();
1963 let send_inc = send.clone();
1964 let (dec, inc) = (on_decrement.clone(), on_increment.clone());
1965 view! {
1966 <div class="stepper">
1967 <button on:click=move |_| send_dec(Action::Fired { token: dec.clone() })>"−"</button>
1968 <span class="stepper-value">{*value}</span>
1969 <button on:click=move |_| send_inc(Action::Fired { token: inc.clone() })>"+"</button>
1970 </div>
1971 }
1972 .into_any()
1973 }
1974
1975 Widget::Scaffold { title, body, tabs, back, dark_mode, theme, fab, sheet, on_refresh, refreshing, route, depth, labels: _ } => {
1977 let back_aria = shell_label(|l| l.back.clone(), "Back");
1982 let back_btn = back.clone().map(|token| {
1983 let send = send.clone();
1984 view! {
1985 <button class="back" aria-label=back_aria on:click=move |_| send(Action::Fired { token: token.clone() })>
1986 "‹"
1987 </button>
1988 }
1989 });
1990 let tabbar = (!tabs.is_empty()).then(|| {
1991 let tabs: Vec<AnyView> = tabs
1992 .iter()
1993 .map(|tab| {
1994 let (send, token) = (send.clone(), tab.on_select.clone());
1995 let class = if tab.selected { "tab selected" } else { "tab" };
1996 let label = tab.label.clone();
1997 let icon = tab.icon.map(|i| view! { <span class="tab-icon">{icon_glyph(i)}</span> });
1999 view! {
2000 <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
2001 {icon}
2002 <span class="tab-label">{label}</span>
2003 </button>
2004 }
2005 .into_any()
2006 })
2007 .collect();
2008 view! { <div class="tabbar">{tabs}</div> }
2009 });
2010 let fab_btn = fab.clone().map(|f| {
2012 let (send, token) = (send.clone(), f.on_press.clone());
2013 view! {
2014 <button class="fab" on:click=move |_| send(Action::Fired { token: token.clone() })>
2015 {icon_glyph(f.icon)}
2016 </button>
2017 }
2018 });
2019 let sheet_overlay = sheet.as_ref().map(|s| {
2021 let (send_scrim, dismiss) = (send.clone(), s.on_dismiss.clone());
2022 let (title, child) = (s.title.clone(), render(&s.child, send));
2023 view! {
2024 <div class="sheet-scrim" on:click=move |_| send_scrim(Action::Fired { token: dismiss.clone() })></div>
2025 <div class="sheet">
2026 <div class="sheet-handle"></div>
2027 <div class="sheet-title">{title}</div>
2028 {child}
2029 </div>
2030 }
2031 });
2032 let large = theme.as_ref().is_some_and(|t| t.density == Density::Large);
2036 let fill_index = body_fill_index(body);
2040 let class = format!(
2041 "scaffold{}{}{}",
2042 if *dark_mode { " theme-dark" } else { "" },
2043 if large { " density-large" } else { "" },
2044 if fill_index.is_some() { " scaffold-fill" } else { "" },
2045 );
2046 let refresh_aria = shell_label(|l| l.refresh.clone(), "Refresh");
2049 let refresh_btn = on_refresh.clone().map(|token| {
2050 let send = send.clone();
2051 view! {
2052 <button class="refresh-btn" aria-label=refresh_aria on:click=move |_| send(Action::Fired { token: token.clone() })>"↻"</button>
2053 }
2054 });
2055 let refresh_bar = refreshing.then(|| {
2056 view! { <div class="progress progress-indeterminate"><div class="progress-bar"></div></div> }
2057 });
2058 let body_class = format!("scaffold-body {}", nav_class(route, *depth));
2059 let theme_style = theme.as_ref().map(theme_css).unwrap_or_default();
2062 let target = fill_index.map(|idx| match idx {
2067 None => &**body as *const Widget,
2068 Some(i) => match &**body {
2069 Widget::Column { children } => &children[i] as *const Widget,
2070 _ => unreachable!("body_fill_index only returns Some(Some(_)) for a Column body"),
2071 },
2072 });
2073 let prev = FILL_TARGET.with(|t| t.replace(target));
2074 let (title, body) = (title.clone(), render(body, send));
2075 FILL_TARGET.with(|t| *t.borrow_mut() = prev);
2076 view! {
2077 <div class=class style=theme_style>
2078 <div class="topbar">
2079 {back_btn}
2080 <span class="title">{title}</span>
2081 {refresh_btn}
2082 </div>
2083 <div class=body_class data-route=route.clone()>{refresh_bar}{body}</div>
2084 {fab_btn}
2085 {tabbar}
2086 {sheet_overlay}
2087 </div>
2088 }
2089 .into_any()
2090 }
2091 }
2092}
2093
2094fn render_all(children: &[Widget], send: &Dispatch) -> Vec<AnyView> {
2096 children.iter().map(|c| render(c, send)).collect()
2097}
2098
2099thread_local! {
2100 static NAV: RefCell<(String, u32, bool)> = const { RefCell::new((String::new(), 0, false)) };
2105
2106 static STREAMS: RefCell<HashMap<String, StreamHandle>> = RefCell::new(HashMap::new());
2110
2111 static ACTIVE_LABELS: std::cell::RefCell<Option<ShellLabels>> = const { std::cell::RefCell::new(None) };
2115
2116 static FILL_TARGET: std::cell::RefCell<Option<*const Widget>> = const { std::cell::RefCell::new(None) };
2122}
2123
2124fn shell_label(pick: impl Fn(&ShellLabels) -> Option<String>, default: &str) -> String {
2127 ACTIVE_LABELS.with(|l| {
2128 l.borrow()
2129 .as_ref()
2130 .and_then(pick)
2131 .filter(|s| !s.is_empty())
2132 .unwrap_or_else(|| default.to_string())
2133 })
2134}
2135
2136fn theme_css(t: &Theme) -> String {
2141 let (r, g, b) = (t.seed.r, t.seed.g, t.seed.b);
2142 let radius = match t.corner {
2143 Corner::None => "0px",
2144 Corner::Small => "8px",
2145 Corner::Medium => "14px",
2146 Corner::Large => "22px",
2147 };
2148 let (gap, pad) = match t.density {
2149 Density::Compact => ("8px", "10px"),
2150 Density::Comfortable => ("12px", "14px"),
2151 Density::Large => ("16px", "18px"),
2152 };
2153 let font = match t.font {
2154 FontFamily::System => "system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif",
2155 FontFamily::Rounded => "ui-rounded, \"SF Pro Rounded\", \"Segoe UI\", system-ui, sans-serif",
2156 FontFamily::Serif => "ui-serif, Georgia, \"Times New Roman\", serif",
2157 FontFamily::Monospace => "ui-monospace, \"SF Mono\", \"Cascadia Code\", Menlo, monospace",
2158 };
2159 let (ar, ag, ab) = t.accent.map_or((r, g, b), |a| (a.r, a.g, a.b));
2161 format!(
2162 "--primary:rgb({r},{g},{b});--accent:rgb({r},{g},{b});\
2163 --accent2:rgb({ar},{ag},{ab});\
2164 --accent-soft:rgba({r},{g},{b},0.16);--radius:{radius};\
2165 --gap:{gap};--pad:{pad};--font:{font};"
2166 )
2167}
2168
2169fn nav_class(route: &str, depth: u32) -> &'static str {
2176 NAV.with_borrow_mut(|(prev_route, prev_depth, toggle)| {
2177 if route == prev_route {
2178 return "";
2179 }
2180 let dir = if depth > *prev_depth {
2181 ["nav-push-a", "nav-push-b"]
2182 } else if depth < *prev_depth {
2183 ["nav-pop-a", "nav-pop-b"]
2184 } else {
2185 ["nav-fade-a", "nav-fade-b"]
2186 };
2187 *toggle = !*toggle;
2188 *prev_route = route.to_string();
2189 *prev_depth = depth;
2190 dir[usize::from(*toggle)]
2191 })
2192}
2193
2194fn text_class(s: TextStyle) -> &'static str {
2197 match s {
2198 TextStyle::Title => "t-title",
2199 TextStyle::Subtitle => "t-subtitle",
2200 TextStyle::Caption => "t-caption",
2201 TextStyle::Emphasis => "t-emphasis",
2202 TextStyle::Body => "t-body",
2203 }
2204}
2205
2206fn button_class(s: ButtonStyle) -> &'static str {
2207 match s {
2208 ButtonStyle::Filled => "btn-filled",
2209 ButtonStyle::Outlined => "btn-outlined",
2210 ButtonStyle::Text => "btn-text",
2211 ButtonStyle::Tonal => "btn-tonal",
2212 }
2213}
2214
2215fn button_tone_class(t: Tone) -> &'static str {
2216 match t {
2217 Tone::Neutral => "",
2218 Tone::Success => "btn-success",
2219 Tone::Warning => "btn-warning",
2220 Tone::Danger => "btn-danger",
2221 Tone::Info => "btn-info",
2222 }
2223}
2224
2225fn card_class(s: CardStyle) -> &'static str {
2226 match s {
2227 CardStyle::Elevated => "card-elevated",
2228 CardStyle::Outlined => "card-outlined",
2229 CardStyle::Filled => "card-filled",
2230 CardStyle::Brand => "card-brand",
2231 }
2232}
2233
2234fn a11y_role_aria(role: A11yRole) -> &'static str {
2235 match role {
2236 A11yRole::Button => "button",
2237 A11yRole::Link => "link",
2238 A11yRole::Image => "img",
2239 A11yRole::Header => "heading",
2240 A11yRole::Adjustable => "slider",
2241 }
2242}
2243
2244fn tone_class(t: Tone) -> &'static str {
2245 match t {
2246 Tone::Neutral => "tone-neutral",
2247 Tone::Success => "tone-success",
2248 Tone::Warning => "tone-warning",
2249 Tone::Danger => "tone-danger",
2250 Tone::Info => "tone-info",
2251 }
2252}
2253
2254fn spacer_class(s: Spacing) -> &'static str {
2255 match s {
2256 Spacing::Xs => "sp-xs",
2257 Spacing::Sm => "sp-sm",
2258 Spacing::Md => "sp-md",
2259 Spacing::Lg => "sp-lg",
2260 Spacing::Xl => "sp-xl",
2261 }
2262}
2263
2264fn icon_glyph(i: Icon) -> &'static str {
2265 match i {
2266 Icon::Delete => "🗑",
2267 Icon::Add => "+",
2268 Icon::Edit => "✏️",
2269 Icon::Close => "✕",
2270 Icon::Settings => "⚙",
2271 Icon::Check => "✓",
2272 Icon::Star => "★",
2273 Icon::Info => "ℹ",
2274 Icon::Home => "⌂",
2275 Icon::Search => "🔍",
2276 Icon::Menu => "☰",
2277 Icon::Filter => "⚟",
2278 Icon::Back => "‹",
2279 Icon::Forward => "›",
2280 Icon::Down => "⌄",
2281 Icon::Bell => "🔔",
2282 Icon::Cart => "🛒",
2283 Icon::Share => "↗",
2284 Icon::Heart => "♡",
2285 Icon::HeartFilled => "♥",
2286 Icon::Person => "👤",
2287 Icon::People => "👥",
2288 Icon::Phone => "📞",
2289 Icon::Mail => "✉",
2290 Icon::Calendar => "📅",
2291 Icon::Clock => "🕑",
2292 Icon::MapPin => "📍",
2293 Icon::Camera => "📷",
2294 Icon::Photo => "🖼",
2295 Icon::Play => "▶",
2296 Icon::Scissors => "✂",
2297 }
2298}
2299
2300fn image_class(shape: ImageShape, ratio: ImageRatio) -> String {
2301 let shape = match shape {
2302 ImageShape::Square => "img-square",
2303 ImageShape::Rounded => "img-rounded",
2304 ImageShape::Circle => "img-circle",
2305 };
2306 let ratio = match ratio {
2307 ImageRatio::Wide => "ratio-wide",
2308 ImageRatio::Square => "ratio-square",
2309 ImageRatio::Tall => "ratio-tall",
2310 };
2311 format!("img {shape} {ratio}")
2312}
2313
2314fn dot_class(c: ProjectColor) -> &'static str {
2315 match c {
2316 ProjectColor::Indigo => "dot-indigo",
2317 ProjectColor::Teal => "dot-teal",
2318 ProjectColor::Coral => "dot-coral",
2319 ProjectColor::Amber => "dot-amber",
2320 ProjectColor::Lime => "dot-lime",
2321 ProjectColor::Pink => "dot-pink",
2322 }
2323}
2324
2325fn align_class(a: BoxAlign) -> &'static str {
2326 match a {
2327 BoxAlign::TopStart => "align-top-start",
2328 BoxAlign::TopEnd => "align-top-end",
2329 BoxAlign::Center => "align-center",
2330 BoxAlign::BottomStart => "align-bottom-start",
2331 BoxAlign::BottomCenter => "align-bottom-center",
2332 BoxAlign::BottomEnd => "align-bottom-end",
2333 }
2334}
2335
2336const CHART_PALETTE: [&str; 6] = ["#E0772C", "#2EA06A", "#C0466B", "#8A5CC0", "#C9A227", "#3FA7D6"];
2340
2341fn hex(c: Rgb) -> String {
2342 format!("#{:02x}{:02x}{:02x}", c.r, c.g, c.b)
2343}
2344
2345fn chart_color(i: usize, s: &ChartSeries) -> String {
2347 match s.color {
2348 Some(c) => hex(c),
2349 None if i == 0 => "var(--accent, #5C6BC0)".to_string(),
2350 None => CHART_PALETTE[(i - 1) % CHART_PALETTE.len()].to_string(),
2351 }
2352}
2353
2354fn chart_mag(s: &ChartSeries) -> f32 {
2356 s.values.iter().copied().sum()
2357}
2358
2359fn polar(cx: f32, cy: f32, r: f32, ang: f32) -> (f32, f32) {
2361 (cx + r * ang.sin(), cy - r * ang.cos())
2362}
2363
2364fn arc_path(cx: f32, cy: f32, r: f32, a0: f32, a1: f32) -> String {
2366 let (x0, y0) = polar(cx, cy, r, a0);
2367 let (x1, y1) = polar(cx, cy, r, a1);
2368 let large = if (a1 - a0).abs() > std::f32::consts::PI { 1 } else { 0 };
2369 format!("M {x0:.2} {y0:.2} A {r:.2} {r:.2} 0 {large} 1 {x1:.2} {y1:.2}")
2370}
2371
2372fn wedge_path(cx: f32, cy: f32, r: f32, a0: f32, a1: f32) -> String {
2374 let (x0, y0) = polar(cx, cy, r, a0);
2375 let (x1, y1) = polar(cx, cy, r, a1);
2376 let large = if (a1 - a0).abs() > std::f32::consts::PI { 1 } else { 0 };
2377 format!("M {cx:.2} {cy:.2} L {x0:.2} {y0:.2} A {r:.2} {r:.2} 0 {large} 1 {x1:.2} {y1:.2} Z")
2378}
2379
2380fn fmt_tick(v: f32) -> String {
2381 if (v - v.round()).abs() < 0.05 { format!("{}", v.round() as i64) } else { format!("{v:.1}") }
2382}
2383
2384fn is_cartesian(style: ChartStyle) -> bool {
2385 matches!(style, ChartStyle::Bar | ChartStyle::Line | ChartStyle::StackedBar | ChartStyle::StackedBar100)
2386}
2387
2388fn cartesian_max(series: &[ChartSeries], style: ChartStyle, nslots: usize) -> f32 {
2390 match style {
2391 ChartStyle::StackedBar => (0..nslots)
2392 .map(|j| series.iter().map(|s| *s.values.get(j).unwrap_or(&0.0)).sum::<f32>())
2393 .fold(0.0, f32::max)
2394 .max(1e-6),
2395 ChartStyle::StackedBar100 => 1.0,
2396 _ => series.iter().flat_map(|s| s.values.iter().copied()).fold(0.0, f32::max).max(1e-6),
2397 }
2398}
2399
2400fn cartesian_svg(series: &[ChartSeries], style: ChartStyle, axis: bool, max: f32, nslots: usize) -> AnyView {
2401 let mut nodes: Vec<AnyView> = Vec::new();
2403 if axis {
2404 for k in 0..=4 {
2405 let y = 2.0 + k as f32 * (46.0 / 4.0);
2406 nodes.push(view! { <line x1="0" y1=format!("{y:.2}") x2="100" y2=format!("{y:.2}") class="chart-gridline"></line> }.into_any());
2407 }
2408 }
2409 match style {
2410 ChartStyle::Line => {
2411 for (i, s) in series.iter().enumerate() {
2412 let n = s.values.len().max(1);
2413 let pts = s.values.iter().enumerate().map(|(j, v)| {
2414 let x = if n == 1 { 50.0 } else { j as f32 * (100.0 / (n as f32 - 1.0)) };
2415 let y = 2.0 + (1.0 - (v / max).clamp(0.0, 1.0)) * 46.0;
2416 format!("{x:.2},{y:.2}")
2417 }).collect::<Vec<_>>().join(" ");
2418 let st = format!("fill:none;stroke:{};stroke-width:1.5;vector-effect:non-scaling-stroke", chart_color(i, s));
2419 nodes.push(view! { <polyline points=pts style=st></polyline> }.into_any());
2420 }
2421 }
2422 ChartStyle::Bar => {
2423 let sw = 100.0 / nslots as f32;
2424 let ns = series.len().max(1);
2425 for (i, s) in series.iter().enumerate() {
2426 let st = format!("fill:{}", chart_color(i, s));
2427 for (j, v) in s.values.iter().enumerate() {
2428 let h = (v / max).clamp(0.0, 1.0) * 46.0;
2429 let bw = sw * 0.8 / ns as f32;
2430 let x = j as f32 * sw + sw * 0.1 + i as f32 * bw;
2431 let y = 48.0 - h;
2432 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());
2433 }
2434 }
2435 }
2436 ChartStyle::StackedBar | ChartStyle::StackedBar100 => {
2437 let sw = 100.0 / nslots as f32;
2438 for j in 0..nslots {
2439 let slot_total = series.iter().map(|s| *s.values.get(j).unwrap_or(&0.0)).sum::<f32>().max(1e-6);
2440 let denom = if matches!(style, ChartStyle::StackedBar100) { slot_total } else { max };
2441 let mut acc = 0.0_f32;
2442 for (i, s) in series.iter().enumerate() {
2443 let v = *s.values.get(j).unwrap_or(&0.0);
2444 let h = (v / denom).clamp(0.0, 1.0) * 46.0;
2445 let x = j as f32 * sw + sw * 0.15;
2446 let bw = sw * 0.7;
2447 let y = 48.0 - acc - h;
2448 let st = format!("fill:{}", chart_color(i, s));
2449 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());
2450 acc += h;
2451 }
2452 }
2453 }
2454 _ => {}
2455 }
2456 view! { <svg viewBox="0 0 100 50" preserveAspectRatio="none" class="chart-svg">{nodes}</svg> }.into_any()
2457}
2458
2459fn circular_svg(series: &[ChartSeries], style: ChartStyle) -> AnyView {
2460 use std::f32::consts::PI;
2461 let mut nodes: Vec<AnyView> = Vec::new();
2462 match style {
2463 ChartStyle::Pie | ChartStyle::Donut => {
2464 let total = series.iter().map(chart_mag).sum::<f32>().max(1e-6);
2465 let mut a = 0.0_f32;
2466 for (i, s) in series.iter().enumerate() {
2467 let frac = chart_mag(s) / total;
2468 let st = format!("fill:{}", chart_color(i, s));
2469 if frac >= 0.999 {
2470 nodes.push(view! { <circle cx="50" cy="50" r="45" style=st></circle> }.into_any());
2471 } else if frac > 0.0 {
2472 let d = wedge_path(50.0, 50.0, 45.0, a, a + frac * 2.0 * PI);
2473 nodes.push(view! { <path d=d style=st></path> }.into_any());
2474 }
2475 a += frac * 2.0 * PI;
2476 }
2477 if matches!(style, ChartStyle::Donut) {
2478 nodes.push(view! { <circle cx="50" cy="50" r="24" style="fill:var(--surface, #ffffff)"></circle> }.into_any());
2479 }
2480 }
2481 ChartStyle::Rings => {
2482 let n = series.len().max(1);
2483 for (i, s) in series.iter().enumerate() {
2484 let r = 45.0 - i as f32 * (34.0 / n as f32);
2485 let goal = s.goal.unwrap_or_else(|| chart_mag(s)).max(1e-6);
2486 let prog = (chart_mag(s) / goal).clamp(0.0, 1.0);
2487 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());
2488 let st = format!("fill:none;stroke:{};stroke-width:6;stroke-linecap:round", chart_color(i, s));
2489 if prog >= 0.999 {
2490 nodes.push(view! { <circle cx="50" cy="50" r=format!("{r:.2}") style=st></circle> }.into_any());
2491 } else if prog > 0.0 {
2492 let d = arc_path(50.0, 50.0, r, 0.0, prog * 2.0 * PI);
2493 nodes.push(view! { <path d=d style=st></path> }.into_any());
2494 }
2495 }
2496 }
2497 ChartStyle::Gauge => {
2498 let s = match series.first() { Some(s) => s, None => return view! { <svg viewBox="0 0 100 100" class="chart-svg"></svg> }.into_any() };
2499 let goal = s.goal.unwrap_or_else(|| chart_mag(s)).max(1e-6);
2500 let prog = (chart_mag(s) / goal).clamp(0.0, 1.0);
2501 let a0 = -0.75 * PI; let a1 = 0.75 * PI;
2503 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());
2504 if prog > 0.0 {
2505 let st = format!("fill:none;stroke:{};stroke-width:8;stroke-linecap:round", chart_color(0, s));
2506 nodes.push(view! { <path d=arc_path(50.0, 50.0, 42.0, a0, a0 + prog * 1.5 * PI) style=st></path> }.into_any());
2507 }
2508 let pct = format!("{}%", (prog * 100.0).round() as i64);
2509 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());
2510 }
2511 _ => {}
2512 }
2513 view! { <svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet" class="chart-svg">{nodes}</svg> }.into_any()
2514}
2515
2516fn chart_view(series: &[ChartSeries], labels: &[String], style: ChartStyle, axis: bool, legend: bool) -> AnyView {
2517 let cartesian = is_cartesian(style);
2518 let nslots = series.iter().map(|s| s.values.len()).max().unwrap_or(0).max(1);
2519 let max = cartesian_max(series, style, nslots);
2520
2521 let plot = if cartesian {
2522 let svg = cartesian_svg(series, style, axis, max, nslots);
2523 let yaxis = if axis {
2524 let ticks: Vec<_> = [max, max / 2.0, 0.0].iter()
2525 .map(|t| view! { <span class="chart-tick">{fmt_tick(*t)}</span> })
2526 .collect();
2527 Some(view! { <div class="chart-yaxis">{ticks}</div> })
2528 } else {
2529 None
2530 };
2531 view! { <div class="chart-plot">{yaxis}{svg}</div> }.into_any()
2532 } else {
2533 circular_svg(series, style).into_any()
2534 };
2535
2536 let label_row = if cartesian && !labels.is_empty() {
2537 let items: Vec<_> = labels.iter().map(|l| view! { <span class="chart-label">{l.clone()}</span> }).collect();
2538 Some(view! { <div class="chart-labels">{items}</div> })
2539 } else {
2540 None
2541 };
2542
2543 let legend_row = if legend {
2544 let items: Vec<_> = series.iter().enumerate().map(|(i, s)| {
2545 let sw = format!("background:{}", chart_color(i, s));
2546 let name = s.name.clone();
2547 view! { <span class="chart-legend-item"><span class="chart-swatch" style=sw></span>{name}</span> }
2548 }).collect();
2549 Some(view! { <div class="chart-legend">{items}</div> })
2550 } else {
2551 None
2552 };
2553
2554 view! { <div class="chart">{plot}{label_row}{legend_row}</div> }.into_any()
2555}
2556
2557const CHART_PALETTE_RGB: [(u8, u8, u8); 6] =
2561 [(0xE0, 0x77, 0x2C), (0x2E, 0xA0, 0x6A), (0xC0, 0x46, 0x6B), (0x8A, 0x5C, 0xC0), (0xC9, 0xA2, 0x27), (0x3F, 0xA7, 0xD6)];
2562
2563fn region_rgb(i: usize, r: &ChartRegion) -> (u8, u8, u8) {
2565 match r.color {
2566 Some(c) => (c.r, c.g, c.b),
2567 None => CHART_PALETTE_RGB[i % CHART_PALETTE_RGB.len()],
2568 }
2569}
2570
2571fn contrast_text((r, g, b): (u8, u8, u8)) -> &'static str {
2573 let lum = 0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32;
2574 if lum > 140.0 { "#1a1a1a" } else { "#f5f5f5" }
2575}
2576
2577fn region_color(i: usize, r: &ChartRegion) -> String {
2578 let (r8, g8, b8) = region_rgb(i, r);
2579 format!("#{r8:02x}{g8:02x}{b8:02x}")
2580}
2581
2582fn region_chart_view(
2586 regions: &[ChartRegion],
2587 ticks: &[ChartTick],
2588 x_max: f32,
2589 y_max: f32,
2590 ref_lines: &[ChartRefLine],
2591 bracket: &Option<ChartBracket>,
2592 legend: &[ChartLegendItem],
2593) -> AnyView {
2594 let xm = x_max.max(1e-6);
2595 let ym = y_max.max(1e-6);
2596
2597 let region_divs: Vec<_> = regions.iter().enumerate().map(|(i, r)| {
2598 let left = (r.x0 / xm * 100.0).clamp(0.0, 100.0);
2599 let width = ((r.x1 - r.x0) / xm * 100.0).clamp(0.0, 100.0);
2600 let bottom = (r.y0 / ym * 100.0).clamp(0.0, 100.0);
2601 let height = ((r.y1 - r.y0) / ym * 100.0).clamp(0.0, 100.0);
2602 let style = format!("left:{left:.3}%;width:{width:.3}%;bottom:{bottom:.3}%;height:{height:.3}%;background:{}", region_color(i, r));
2603 let label_class = if r.vertical { "rchart-label rchart-label-v" } else { "rchart-label" };
2604 let label_style = format!("color:{}", contrast_text(region_rgb(i, r)));
2605 let label = r.label.clone();
2606 view! { <div class="rchart-region" style=style><span class=label_class style=label_style>{label}</span></div> }
2607 }).collect();
2608
2609 let ref_line_divs: Vec<_> = ref_lines.iter().map(|rl| {
2612 let style = format!("bottom:{:.3}%", (rl.value / ym * 100.0).clamp(0.0, 100.0));
2613 let cls = if rl.dashed { "rchart-refline rchart-refline-dashed" } else { "rchart-refline" };
2614 view! { <div class=cls style=style></div> }
2615 }).collect();
2616 let chip_divs: Vec<_> = ref_lines.iter().map(|rl| {
2617 let style = format!("bottom:{:.3}%", (rl.value / ym * 100.0).clamp(0.0, 100.0));
2618 let label = rl.label.clone();
2619 view! { <div class="rchart-chip" style=style>{label}</div> }
2620 }).collect();
2621
2622 let bracket_div = bracket.as_ref().map(|b| {
2623 let bottom = (b.y0 / ym * 100.0).clamp(0.0, 100.0);
2624 let height = ((b.y1 - b.y0) / ym * 100.0).clamp(0.0, 100.0);
2625 let style = format!("bottom:{bottom:.3}%;height:{height:.3}%");
2626 let label = if b.info { format!("ⓘ\n{}", b.label) } else { b.label.clone() };
2627 view! { <div class="rchart-bracket" style=style><span>{label}</span></div> }
2628 });
2629
2630 let yticks: Vec<_> = (0..=4).rev().map(|k| {
2631 let v = ym * k as f32 / 4.0;
2632 view! { <span class="chart-tick">{fmt_tick(v)}</span> }
2633 }).collect();
2634
2635 let xticks: Vec<_> = ticks.iter().map(|t| {
2636 let style = format!("left:{:.3}%", (t.at / xm * 100.0).clamp(0.0, 100.0));
2637 let label = t.label.clone();
2638 view! { <span class="rchart-xtick" style=style>{label}</span> }
2639 }).collect();
2640
2641 let ytick_marks: Vec<_> = (0..=4).map(|k| {
2644 let style = format!("bottom:{:.3}%", k as f32 * 25.0);
2645 view! { <div class="rchart-ytick" style=style></div> }
2646 }).collect();
2647 let xtick_marks: Vec<_> = ticks.iter().map(|t| {
2648 let style = format!("left:{:.3}%", (t.at / xm * 100.0).clamp(0.0, 100.0));
2649 view! { <div class="rchart-xtickmark" style=style></div> }
2650 }).collect();
2651
2652 let legend_row = if legend.is_empty() {
2653 None
2654 } else {
2655 let items: Vec<_> = legend.iter().map(|l| {
2656 let sw = format!("background:{}", hex(l.color));
2657 let name = l.label.clone();
2658 view! { <span class="chart-legend-item"><span class="chart-swatch" style=sw></span>{name}</span> }
2659 }).collect();
2660 Some(view! { <div class="chart-legend">{items}</div> })
2661 };
2662
2663 view! {
2664 <div class="rchart">
2665 <div class="rchart-row">
2666 <div class="rchart-yaxis">{yticks}</div>
2667 <div class="rchart-plotwrap">
2668 <div class="rchart-plot">{region_divs}{ytick_marks}{xtick_marks}{ref_line_divs}</div>
2669 {chip_divs}{bracket_div}
2670 </div>
2671 </div>
2672 <div class="rchart-xaxis">{xticks}</div>
2673 {legend_row}
2674 </div>
2675 }.into_any()
2676}