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, 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 || 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::text(true, 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::text(true, e.data().as_string().unwrap_or_default()));
289 })
290 };
291 let onclose = Closure::<dyn FnMut(web_sys::CloseEvent)>::new(move |_e| {
292 emit(PluginResponse::text(false, "closed"));
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::text(true, system_deeplink(&href)));
307 }
308 emit(PluginResponse::text(true, 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::text(true, 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::text(true, 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 ("transfer", op @ ("upload" | "download")) => {
332 let v: serde_json::Value = serde_json::from_str(&call.input).unwrap_or(serde_json::Value::Null);
333 let url = v.get("url").and_then(|x| x.as_str()).unwrap_or("").to_string();
334 let headers: Vec<(String, String)> = v
335 .get("headers")
336 .and_then(|x| x.as_array())
337 .map(|hs| {
338 hs.iter()
339 .filter_map(|h| Some((h.get("name")?.as_str()?.to_string(), h.get("value")?.as_str()?.to_string())))
340 .collect()
341 })
342 .unwrap_or_default();
343
344 if op == "upload" {
345 let method = v.get("method").and_then(|x| x.as_str()).unwrap_or("PUT").to_string();
346 let source = v.get("source").and_then(|x| x.as_str()).unwrap_or("").to_string();
347 let multipart = v.get("multipart").map(|m| WebMultipart {
348 field: m.get("field").and_then(|x| x.as_str()).unwrap_or("").to_string(),
349 filename: m.get("filename").and_then(|x| x.as_str()).map(|s| s.to_string()),
350 fields: m
351 .get("fields")
352 .and_then(|x| x.as_array())
353 .map(|fs| {
354 fs.iter()
355 .filter_map(|f| Some((f.get("name")?.as_str()?.to_string(), f.get("value")?.as_str()?.to_string())))
356 .collect()
357 })
358 .unwrap_or_default(),
359 });
360 start_web_upload(url, method, headers, source, multipart, emit.clone())
361 } else {
362 start_web_download(url, headers, emit.clone())
363 }
364 }
365 _ => return, };
367
368 STREAMS.with(|m| {
369 m.borrow_mut().insert(call.key.clone(), handle);
370 });
371}
372
373fn js_now() -> f64 {
375 web_sys::window().and_then(|w| w.performance()).map(|p| p.now()).unwrap_or(0.0)
376}
377
378struct WebMultipart {
383 field: String,
385 filename: Option<String>,
387 fields: Vec<(String, String)>,
389}
390
391fn infer_filename(source: &str) -> String {
393 let s = source.split(['?', '#']).next().unwrap_or(source);
394 let name = s.rsplit('/').next().unwrap_or("");
395 if name.is_empty() {
396 "file".to_string()
397 } else {
398 name.to_string()
399 }
400}
401
402fn parse_header_block(raw: &str) -> Vec<HttpHeader> {
406 raw.split("\r\n")
407 .filter_map(|line| {
408 let (name, value) = line.split_once(':')?;
409 let name = name.trim();
410 if name.is_empty() {
411 return None;
412 }
413 Some(HttpHeader { name: name.to_string(), value: value.trim().to_string() })
414 })
415 .collect()
416}
417
418fn start_web_upload(
426 url: String,
427 method: String,
428 headers: Vec<(String, String)>,
429 source: String,
430 multipart: Option<WebMultipart>,
431 emit: impl Fn(PluginResponse) + Clone + 'static,
432) -> StreamHandle {
433 use wasm_bindgen::{closure::Closure, JsCast};
434 let xhr = web_sys::XmlHttpRequest::new().expect("xhr");
435 let _ = xhr.open_with_async(&method, &url, true);
436 for (n, val) in &headers {
437 if multipart.is_some() && n.eq_ignore_ascii_case("content-type") {
442 continue;
443 }
444 let _ = xhr.set_request_header(n, val);
445 }
446
447 let last = std::rc::Rc::new(std::cell::Cell::new(0.0f64));
453 let on_prog = {
454 let (emit, last) = (emit.clone(), last.clone());
455 Closure::<dyn FnMut(web_sys::ProgressEvent)>::new(move |e: web_sys::ProgressEvent| {
456 let now = js_now();
457 if now - last.get() < 100.0 {
458 return;
459 }
460 last.set(now);
461 let total = if e.length_computable() { Some(e.total() as u64) } else { None };
462 emit(transfer_response(&TransferEvent::Progress { transferred: e.loaded() as u64, total }));
463 })
464 };
465 if let Ok(upload) = xhr.upload() {
466 upload.set_onprogress(Some(on_prog.as_ref().unchecked_ref()));
467 }
468
469 let on_done = {
472 let (emit, xhr_c) = (emit.clone(), xhr.clone());
473 Closure::<dyn FnMut()>::new(move || {
474 let status = xhr_c.status().unwrap_or(0);
475 let outcome = if status == 0 {
476 HttpOutcome::TransportError { message: "upload failed".into() }
477 } else {
478 let headers = xhr_c
484 .get_all_response_headers()
485 .ok()
486 .map(|raw| parse_header_block(&raw))
487 .unwrap_or_default();
488 HttpOutcome::Response { status, headers, body: vec![] }
489 };
490 emit(transfer_response(&TransferEvent::Done { outcome, handle: None }));
491 })
492 };
493 xhr.set_onload(Some(on_done.as_ref().unchecked_ref()));
494 let on_err = {
495 let emit = emit.clone();
496 Closure::<dyn FnMut()>::new(move || {
497 emit(transfer_response(&TransferEvent::Done {
498 outcome: HttpOutcome::TransportError { message: "upload error".into() },
499 handle: None,
500 }));
501 })
502 };
503 xhr.set_onerror(Some(on_err.as_ref().unchecked_ref()));
504 let on_abort = {
505 let emit = emit.clone();
506 Closure::<dyn FnMut()>::new(move || {
507 emit(transfer_response(&TransferEvent::Done {
508 outcome: HttpOutcome::TransportError { message: "upload aborted".into() },
509 handle: None,
510 }));
511 })
512 };
513 xhr.set_onabort(Some(on_abort.as_ref().unchecked_ref()));
514
515 let xhr_send = xhr.clone();
527 let cancelled = std::rc::Rc::new(std::cell::Cell::new(false));
528 let cancelled_send = cancelled.clone();
529 wasm_bindgen_futures::spawn_local(async move {
530 let blob = fetch_blob(&source).await;
531 if cancelled_send.get() {
532 return;
533 }
534 match (blob, &multipart) {
535 (Some(blob), Some(mp)) => {
536 let form = web_sys::FormData::new().expect("FormData");
538 for (name, value) in &mp.fields {
539 let _ = form.append_with_str(name, value);
540 }
541 let filename = mp.filename.clone().unwrap_or_else(|| infer_filename(&source));
542 let _ = form.append_with_blob_and_filename(&mp.field, &blob, &filename);
543 let _ = xhr_send.send_with_opt_form_data(Some(&form));
544 }
545 (Some(blob), None) => {
546 let _ = xhr_send.send_with_opt_blob(Some(&blob));
547 }
548 (None, _) => {
549 let _ = xhr_send.send();
550 }
551 }
552 });
553
554 StreamHandle::Transfer(TransferHandle {
555 xhr: Some(xhr),
556 abort: None,
557 cancelled: Some(cancelled),
558 _on_prog: Some(on_prog),
559 _on_done: Some(on_done),
560 _on_err: Some(on_err),
561 _on_abort: Some(on_abort),
562 })
563}
564
565async fn fetch_blob(url: &str) -> Option<web_sys::Blob> {
569 use wasm_bindgen::JsCast;
570 let win = web_sys::window()?;
571 let resp_value = wasm_bindgen_futures::JsFuture::from(win.fetch_with_str(url)).await.ok()?;
572 let resp: web_sys::Response = resp_value.dyn_into().ok()?;
573 let blob_promise = resp.blob().ok()?;
574 let blob_value = wasm_bindgen_futures::JsFuture::from(blob_promise).await.ok()?;
575 blob_value.dyn_into().ok()
576}
577
578fn start_web_download(
587 url: String,
588 headers: Vec<(String, String)>,
589 emit: impl Fn(PluginResponse) + Clone + 'static,
590) -> StreamHandle {
591 let ctrl = web_sys::AbortController::new().expect("abortcontroller");
592 let signal = ctrl.signal();
593 let emit2 = emit.clone();
594 wasm_bindgen_futures::spawn_local(async move {
595 match fetch_stream(&url, &headers, &signal).await {
596 Ok((status, resp_headers, total, mut reader)) => {
597 let mut got: u64 = 0;
598 let mut chunks: Vec<u8> = Vec::new();
599 let mut last = js_now();
600 loop {
601 match reader.next().await {
602 Ok(Some(chunk)) => {
603 got += chunk.len() as u64;
604 chunks.extend_from_slice(&chunk);
605 let now = js_now();
606 if now - last >= 100.0 {
608 last = now;
609 emit2(transfer_response(&TransferEvent::Progress { transferred: got, total }));
610 }
611 }
612 Ok(None) => break, Err(msg) => {
614 emit2(transfer_response(&TransferEvent::Done {
615 outcome: HttpOutcome::TransportError { message: msg },
616 handle: None,
617 }));
618 return;
619 }
620 }
621 }
622 let handle = make_blob_url(&chunks);
623 let outcome = HttpOutcome::Response { status, headers: resp_headers, body: vec![] };
624 emit2(transfer_response(&TransferEvent::Done { outcome, handle: Some(handle) }));
625 }
626 Err(msg) => emit2(transfer_response(&TransferEvent::Done {
627 outcome: HttpOutcome::TransportError { message: msg },
628 handle: None,
629 })),
630 }
631 });
632 StreamHandle::Transfer(TransferHandle {
633 xhr: None,
634 abort: Some(ctrl),
635 cancelled: None,
636 _on_prog: None,
637 _on_done: None,
638 _on_err: None,
639 _on_abort: None,
640 })
641}
642
643async fn fetch_stream(
647 url: &str,
648 headers: &[(String, String)],
649 signal: &web_sys::AbortSignal,
650) -> Result<(u16, Vec<HttpHeader>, Option<u64>, Reader), String> {
651 use wasm_bindgen::JsCast;
652 let win = web_sys::window().ok_or_else(|| "no window".to_string())?;
653 let js_headers = web_sys::Headers::new().map_err(|e| js_err(&e))?;
654 for (n, v) in headers {
655 js_headers.append(n, v).map_err(|e| js_err(&e))?;
656 }
657 let init = web_sys::RequestInit::new();
658 init.set_method("GET");
659 init.set_headers_headers(&js_headers);
660 init.set_signal(Some(signal));
661 let request = web_sys::Request::new_with_str_and_init(url, &init).map_err(|e| js_err(&e))?;
662
663 let resp_value = wasm_bindgen_futures::JsFuture::from(win.fetch_with_request(&request))
664 .await
665 .map_err(|e| js_err(&e))?;
666 let resp: web_sys::Response = resp_value.dyn_into().map_err(|_| "fetch: not a Response".to_string())?;
667 let status = resp.status();
668 let resp_headers = response_headers(&resp.headers());
669 let total = resp_headers
670 .iter()
671 .find(|h| h.name.eq_ignore_ascii_case("content-length"))
672 .and_then(|h| h.value.parse().ok());
673
674 let Some(stream) = resp.body() else {
675 return Ok((status, resp_headers, total, Reader::empty()));
678 };
679 let reader = web_sys::ReadableStreamDefaultReader::new(&stream).map_err(|e| js_err(&e))?;
680 Ok((status, resp_headers, total, Reader::new(reader)))
681}
682
683fn response_headers(headers: &web_sys::Headers) -> Vec<HttpHeader> {
686 use wasm_bindgen::JsCast;
687 let mut out = Vec::new();
688 if let Ok(Some(iter)) = js_sys::try_iter(headers) {
689 for entry in iter.flatten() {
690 let arr: js_sys::Array = entry.unchecked_into();
691 let name = arr.get(0).as_string().unwrap_or_default();
692 let value = arr.get(1).as_string().unwrap_or_default();
693 out.push(HttpHeader { name, value });
694 }
695 }
696 out
697}
698
699fn js_err(e: &wasm_bindgen::JsValue) -> String {
702 use wasm_bindgen::JsCast;
703 e.as_string()
704 .or_else(|| e.dyn_ref::<js_sys::Error>().map(|err| String::from(err.message())))
705 .unwrap_or_else(|| "transfer error".to_string())
706}
707
708struct Reader(Option<web_sys::ReadableStreamDefaultReader>);
712impl Reader {
713 fn new(reader: web_sys::ReadableStreamDefaultReader) -> Self {
714 Self(Some(reader))
715 }
716 fn empty() -> Self {
718 Self(None)
719 }
720 async fn next(&mut self) -> Result<Option<Vec<u8>>, String> {
721 use wasm_bindgen::JsCast;
722 let Some(reader) = &self.0 else { return Ok(None) };
723 let result = wasm_bindgen_futures::JsFuture::from(reader.read()).await.map_err(|e| js_err(&e))?;
724 let result: web_sys::ReadableStreamReadResult = result.unchecked_into();
725 if result.get_done().unwrap_or(true) {
726 return Ok(None);
727 }
728 let value = result.get_value();
729 let bytes = js_sys::Uint8Array::new(&value).to_vec();
730 Ok(Some(bytes))
731 }
732}
733
734fn make_blob_url(bytes: &[u8]) -> String {
738 let array = js_sys::Uint8Array::from(bytes);
739 let parts = js_sys::Array::new();
740 parts.push(&array);
741 web_sys::Blob::new_with_u8_array_sequence(&parts)
742 .ok()
743 .and_then(|blob| web_sys::Url::create_object_url_with_blob(&blob).ok())
744 .unwrap_or_default()
745}
746
747fn system_deeplink(url: &str) -> String {
749 format!("{{\"type\":\"deeplink\",\"url\":{}}}", serde_json::to_string(url).unwrap_or_else(|_| "\"\"".into()))
750}
751fn system_lifecycle(doc: &web_sys::Document) -> String {
753 let state = if doc.visibility_state() == web_sys::VisibilityState::Visible { "active" } else { "background" };
754 format!("{{\"type\":\"lifecycle\",\"state\":\"{state}\"}}")
755}
756
757enum StreamHandle {
761 Ticker { _interval: gloo_timers::callback::Interval },
763 Ws(WsStream),
764 #[allow(dead_code)]
767 System(SystemStream),
768 #[allow(dead_code)]
771 Transfer(TransferHandle),
772}
773
774struct TransferHandle {
778 xhr: Option<web_sys::XmlHttpRequest>,
779 abort: Option<web_sys::AbortController>,
780 cancelled: Option<std::rc::Rc<std::cell::Cell<bool>>>,
785 _on_prog: Option<wasm_bindgen::closure::Closure<dyn FnMut(web_sys::ProgressEvent)>>,
790 _on_done: Option<wasm_bindgen::closure::Closure<dyn FnMut()>>,
791 _on_err: Option<wasm_bindgen::closure::Closure<dyn FnMut()>>,
792 _on_abort: Option<wasm_bindgen::closure::Closure<dyn FnMut()>>,
793}
794impl Drop for TransferHandle {
795 fn drop(&mut self) {
796 if let Some(c) = &self.cancelled {
797 c.set(true);
798 }
799 if let Some(x) = &self.xhr {
800 let _ = x.abort();
801 }
802 if let Some(a) = &self.abort {
803 a.abort();
804 }
805 }
806}
807
808fn transfer_response(ev: &TransferEvent) -> PluginResponse {
810 PluginResponse {
811 ok: matches!(ev, TransferEvent::Done { outcome, .. } if outcome.is_success()),
812 output: ev.encode(),
813 }
814}
815
816struct SystemStream {
818 win: web_sys::Window,
819 doc: web_sys::Document,
820 _onpop: wasm_bindgen::closure::Closure<dyn FnMut(web_sys::Event)>,
821 _onvis: wasm_bindgen::closure::Closure<dyn FnMut(web_sys::Event)>,
822}
823impl Drop for SystemStream {
824 fn drop(&mut self) {
825 use wasm_bindgen::JsCast;
826 let _ = self.win.remove_event_listener_with_callback("popstate", self._onpop.as_ref().unchecked_ref());
827 let _ = self.doc.remove_event_listener_with_callback("visibilitychange", self._onvis.as_ref().unchecked_ref());
828 }
829}
830
831struct WsStream {
833 ws: web_sys::WebSocket,
834 _onmessage: wasm_bindgen::closure::Closure<dyn FnMut(web_sys::MessageEvent)>,
835 _onclose: wasm_bindgen::closure::Closure<dyn FnMut(web_sys::CloseEvent)>,
836}
837
838async fn perform(call: &PluginCall) -> PluginResponse {
841 if call.plugin == "device" {
842 let nav = web_sys::window().map(|w| w.navigator());
843 let output = if call.op == "locale" {
844 nav.and_then(|n| n.language()).unwrap_or_else(|| "en-US".into())
846 } else {
847 nav.and_then(|n| n.user_agent().ok()).unwrap_or_default()
848 };
849 return PluginResponse::text(true, output);
850 }
851 if call.plugin == "photo" && call.op == "pick" {
852 return take_image(false).await;
853 }
854 if call.plugin == "camera" && call.op == "capture" {
855 return take_image(true).await;
856 }
857 if call.plugin == "datetime" {
858 return match call.op.as_str() {
859 "date" => take_datetime("date").await,
860 "time" => take_datetime("time").await,
861 other => PluginResponse::text(false, format!("unknown datetime op '{other}'")),
862 };
863 }
864 if call.plugin == "dialog" && call.op == "confirm" {
865 let v: serde_json::Value = serde_json::from_str(&call.input).unwrap_or(serde_json::Value::Null);
866 let title = v.get("title").and_then(serde_json::Value::as_str).unwrap_or("");
867 let message = v.get("message").and_then(serde_json::Value::as_str).unwrap_or("");
868 let prompt = if title.is_empty() { message.to_string() } else { format!("{title}\n\n{message}") };
869 let ok = web_sys::window()
870 .and_then(|w| w.confirm_with_message(&prompt).ok())
871 .unwrap_or(false);
872 return PluginResponse::text(ok, if ok { "ok" } else { "cancel" });
873 }
874 if call.plugin != "http" {
875 return PluginResponse::text(false, format!("plugin '{}' not available", call.plugin));
876 }
877 let v: serde_json::Value = serde_json::from_str(&call.input).unwrap_or(serde_json::Value::Null);
878 let url = v.get("url").and_then(serde_json::Value::as_str).unwrap_or("");
879 let body = v.get("body").and_then(serde_json::Value::as_str);
880 let req_headers: Vec<(String, String)> = v
881 .get("headers")
882 .and_then(serde_json::Value::as_array)
883 .map(|hs| {
884 hs.iter()
885 .filter_map(|h| {
886 Some((
887 h.get("name")?.as_str()?.to_string(),
888 h.get("value")?.as_str()?.to_string(),
889 ))
890 })
891 .collect()
892 })
893 .unwrap_or_default();
894
895 use gloo_net::http::{Method, Request};
896
897 let builder = match call.op.as_str() {
900 "GET" => Request::get(url),
901 "POST" => Request::post(url),
902 "PUT" => Request::put(url),
903 "PATCH" => Request::patch(url),
904 "DELETE" => Request::delete(url),
905 "HEAD" => Request::get(url).method(Method::HEAD),
906 "OPTIONS" => Request::get(url).method(Method::OPTIONS),
907 other => return http_transport_error(format!("unsupported HTTP method '{other}'")),
908 };
909
910 let caller_set_content_type =
912 req_headers.iter().any(|(n, _)| n.eq_ignore_ascii_case("content-type"));
913
914 let gloo_headers = gloo_net::http::Headers::new();
920 for (name, value) in &req_headers {
921 gloo_headers.append(name, value);
922 }
923 if body.is_some() && !caller_set_content_type {
924 gloo_headers.append("Content-Type", "application/json");
925 }
926 let builder = builder.headers(gloo_headers);
927
928 let request = match body {
929 Some(b) => builder.body(b),
930 None => builder.build(),
931 };
932 let request = match request {
933 Ok(r) => r,
934 Err(e) => return http_transport_error(e.to_string()),
935 };
936
937 match request.send().await {
938 Ok(resp) => {
939 let status = resp.status();
940 let headers = resp
941 .headers()
942 .entries()
943 .map(|(name, value)| HttpHeader { name, value })
944 .collect();
945 match resp.binary().await {
946 Ok(bytes) => {
947 let outcome = HttpOutcome::Response { status, headers, body: bytes };
948 PluginResponse { ok: (200..300).contains(&status), output: outcome.encode() }
949 }
950 Err(e) => http_transport_error(e.to_string()),
953 }
954 }
955 Err(e) => http_transport_error(e.to_string()),
956 }
957}
958
959fn http_transport_error(message: String) -> PluginResponse {
961 PluginResponse { ok: false, output: HttpOutcome::TransportError { message }.encode() }
962}
963
964async fn take_image(capture: bool) -> PluginResponse {
971 use wasm_bindgen::{closure::Closure, JsCast};
972 let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
973 return PluginResponse::text(false, "no document");
974 };
975 let Some(input) = doc.create_element("input").ok().and_then(|e| e.dyn_into::<web_sys::HtmlInputElement>().ok()) else {
976 return PluginResponse::text(false, "no input element");
977 };
978 input.set_type("file");
979 input.set_accept("image/*");
980 if capture {
981 let _ = input.set_attribute("capture", "environment");
983 }
984
985 let (tx, rx) = futures_channel::oneshot::channel::<Option<String>>();
986 let tx = std::cell::RefCell::new(Some(tx));
987 let input_for_cb = input.clone();
988 let on_change = Closure::wrap(Box::new(move || {
989 let url = input_for_cb
990 .files()
991 .and_then(|files| files.get(0))
992 .and_then(|file| web_sys::Url::create_object_url_with_blob(&file).ok());
993 if let Some(tx) = tx.borrow_mut().take() {
994 let _ = tx.send(url);
995 }
996 }) as Box<dyn FnMut()>);
997 input.set_onchange(Some(on_change.as_ref().unchecked_ref()));
998 input.click();
999 on_change.forget(); match rx.await {
1002 Ok(Some(url)) => PluginResponse::text(true, url),
1003 _ => PluginResponse::text(false, "cancelled"),
1004 }
1005}
1006
1007async fn take_datetime(kind: &str) -> PluginResponse {
1012 use wasm_bindgen::{closure::Closure, JsCast};
1013 let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
1014 return PluginResponse::text(false, "no document");
1015 };
1016 let Some(input) = doc.create_element("input").ok().and_then(|e| e.dyn_into::<web_sys::HtmlInputElement>().ok()) else {
1017 return PluginResponse::text(false, "no input element");
1018 };
1019 input.set_type(kind); let _ = input.set_attribute("style", "position:fixed;left:-9999px;opacity:0");
1022 if let Some(body) = doc.body() {
1023 let _ = body.append_child(&input);
1024 }
1025
1026 let (tx, rx) = futures_channel::oneshot::channel::<Option<String>>();
1027 let tx = std::rc::Rc::new(std::cell::RefCell::new(Some(tx)));
1028 let input_for_change = input.clone();
1029 let tx_change = tx.clone();
1030 let on_change = Closure::wrap(Box::new(move || {
1031 let v = input_for_change.value();
1032 if let Some(tx) = tx_change.borrow_mut().take() {
1033 let _ = tx.send(if v.is_empty() { None } else { Some(v) });
1034 }
1035 }) as Box<dyn FnMut()>);
1036 let tx_cancel = tx.clone();
1037 let on_cancel = Closure::wrap(Box::new(move || {
1038 if let Some(tx) = tx_cancel.borrow_mut().take() {
1039 let _ = tx.send(None);
1040 }
1041 }) as Box<dyn FnMut()>);
1042 let _ = input.add_event_listener_with_callback("change", on_change.as_ref().unchecked_ref());
1043 let _ = input.add_event_listener_with_callback("cancel", on_cancel.as_ref().unchecked_ref());
1044 if input.show_picker().is_err() {
1045 input.click(); }
1047 on_change.forget(); on_cancel.forget();
1049
1050 let result = rx.await;
1051 input.remove();
1052 match result {
1053 Ok(Some(v)) => PluginResponse::text(true, v),
1054 _ => PluginResponse::text(false, "cancelled"),
1055 }
1056}
1057
1058const STORAGE_KEY: &str = "mobiler.state";
1059
1060fn local_storage() -> Option<web_sys::Storage> {
1062 web_sys::window()?.local_storage().ok().flatten()
1063}
1064
1065fn perform_notify(notify: &PluginNotify) {
1069 let win = match web_sys::window() {
1070 Some(w) => w,
1071 None => return,
1072 };
1073 match (notify.plugin.as_str(), notify.op.as_str()) {
1074 ("storage", "save") => {
1076 if let Some(s) = local_storage() {
1077 let _ = s.set_item(STORAGE_KEY, ¬ify.input);
1078 }
1079 }
1080 ("clipboard", "copy") => {
1082 let _ = win.navigator().clipboard().write_text(¬ify.input);
1083 }
1084 ("browser", "open") => {
1086 let _ = win.open_with_url_and_target(¬ify.input, "_blank");
1087 }
1088 ("share", _) => {
1091 let _ = win.navigator().clipboard().write_text(¬ify.input);
1092 }
1093 ("stream", "unsubscribe") => {
1097 if let Some(StreamHandle::Ws(ws)) = STREAMS.with(|m| m.borrow_mut().remove(¬ify.input)) {
1100 let _ = ws.ws.close();
1101 }
1102 }
1103 ("toast", _) => show_toast(¬ify.input),
1105 ("haptics", style) => {
1107 let ms = match style {
1108 "light" => 15,
1109 "heavy" => 50,
1110 _ => 30, };
1112 let _ = win.navigator().vibrate_with_duration(ms);
1113 }
1114 _ => {} }
1116}
1117
1118fn show_toast(text: &str) {
1121 let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return };
1122 let (Ok(el), Some(body)) = (doc.create_element("div"), doc.body()) else { return };
1123 el.set_class_name("toast");
1124 el.set_text_content(Some(text));
1125 let _ = body.append_child(&el);
1126 gloo_timers::callback::Timeout::new(2600, move || el.remove()).forget();
1127}
1128
1129fn render(widget: &Widget, send: &Dispatch) -> AnyView {
1136 match widget {
1137 Widget::Text { content, style } => {
1139 let (class, content) = (text_class(*style), content.clone());
1140 view! { <p class=class>{content}</p> }.into_any()
1141 }
1142 Widget::Image { source, shape, ratio } => {
1143 let (class, source) = (image_class(*shape, *ratio), source.clone());
1144 view! { <img class=class src=source /> }.into_any()
1145 }
1146 Widget::Badge { label, tone } => {
1147 let (class, label) = (format!("badge {}", tone_class(*tone)), label.clone());
1148 view! { <span class=class>{label}</span> }.into_any()
1149 }
1150 Widget::ColorDot { color } => {
1151 view! { <span class=format!("dot {}", dot_class(*color))></span> }.into_any()
1152 }
1153 Widget::Avatar { source, status } => {
1154 let dot = status.map(|t| view! { <span class=format!("avatar-status {}", tone_class(t))></span> });
1155 view! {
1156 <span class="avatar">
1157 <img class="avatar-img" src=source.clone() />
1158 {dot}
1159 </span>
1160 }
1161 .into_any()
1162 }
1163 Widget::PdfView { url } => {
1164 view! { <iframe class="pdfview" src=url.clone() title="PDF"></iframe> }.into_any()
1166 }
1167 Widget::WebView { url } => {
1168 view! {
1171 <iframe
1172 class="webview"
1173 src=url.clone()
1174 title="Web"
1175 allow="autoplay; fullscreen; picture-in-picture; encrypted-media"
1176 allowfullscreen=true
1177 ></iframe>
1178 }.into_any()
1179 }
1180 Widget::Map { id, center_lat, center_lng, zoom, markers, style_url, interactive } => {
1184 let send = send.clone();
1185 let id = id.clone();
1186 let center = format!("{center_lat},{center_lng}");
1187 let markers_json = serde_json::to_string(markers).unwrap_or_else(|_| "[]".to_string());
1188 let style = style_url.clone().unwrap_or_default();
1189 view! {
1190 <div class="mobiler-map-wrap">
1191 <div
1192 class="mobiler-map"
1193 data-map="1"
1194 data-center=center
1195 data-zoom=zoom.to_string()
1196 data-style=style
1197 data-markers=markers_json
1198 data-interactive=interactive.to_string()
1199 ></div>
1200 <input
1201 class="mobiler-map-sink"
1202 type="text"
1203 tabindex="-1"
1204 aria-hidden="true"
1205 on:input=move |ev| {
1206 let raw = event_target_value(&ev);
1207 if let Some((suffix, value)) = raw.split_once('|') {
1208 send(Action::Input {
1209 id: format!("{id}.{suffix}"),
1210 value: InputValue::Text(value.to_string()),
1211 });
1212 }
1213 }
1214 />
1215 </div>
1216 }.into_any()
1217 }
1218 Widget::Video { url, playing, controls, looping, muted, on_ended, poster, start_at_ms, captions, rate, volume, urls, start_index, .. } => {
1219 use wasm_bindgen::JsCast;
1227 let (send, ended) = (send.clone(), on_ended.clone());
1228 let autoplay = *playing && *muted;
1229 let playlist = urls.clone();
1230 let start_index = (*start_index).max(0) as usize;
1231 let effective = if playlist.is_empty() { url.clone() }
1232 else { playlist.get(start_index).cloned().unwrap_or_else(|| url.clone()) };
1233 let is_hls = effective.to_ascii_lowercase().ends_with(".m3u8");
1234 let src = (!is_hls).then(|| effective.clone());
1235 let hls_src = is_hls.then(|| effective.clone());
1236 let poster_attr = poster.clone();
1237 let start_at = *start_at_ms;
1238 let rate = *rate as f64;
1239 let volume = (*volume as f64).clamp(0.0, 1.0);
1240 let tracks: Vec<_> = captions.iter().map(|c| view! {
1241 <track kind="subtitles" src=c.url.clone() srclang=c.language.clone() label=c.label.clone() default=c.default_on />
1242 }).collect();
1243 let next_idx = std::rc::Rc::new(std::cell::Cell::new(start_index));
1244 view! {
1245 <video
1246 class="video"
1247 src=src
1248 data-hls-src=hls_src
1249 poster=poster_attr
1250 controls=*controls
1251 autoplay=autoplay
1252 prop:loop=*looping
1253 prop:playbackRate=rate
1254 prop:volume=volume
1255 muted=*muted
1256 playsinline=true
1257 on:loadedmetadata=move |ev| {
1258 if start_at >= 0 {
1259 if let Some(v) = ev.target().and_then(|t| t.dyn_into::<web_sys::HtmlVideoElement>().ok()) {
1260 v.set_current_time(start_at as f64 / 1000.0);
1261 }
1262 }
1263 }
1264 on:ended=move |ev| {
1265 let nxt = next_idx.get() + 1;
1266 if !playlist.is_empty() && nxt < playlist.len() {
1267 next_idx.set(nxt);
1268 if let Some(v) = ev.target().and_then(|t| t.dyn_into::<web_sys::HtmlVideoElement>().ok()) {
1269 v.set_src(&playlist[nxt]);
1270 let _ = v.play();
1271 }
1272 } else if let Some(t) = ended.clone() {
1273 send(Action::Fired { token: t });
1274 }
1275 }
1276 >{tracks}</video>
1277 }.into_any()
1278 }
1279 Widget::Rating { value, max, on_rate } => {
1280 let value = *value;
1281 let stars: Vec<AnyView> = (1..=*max)
1282 .map(|i| {
1283 let threshold = u32::from(i) * 10;
1284 let glyph = if value >= threshold { "★" } else if value + 5 >= threshold { "⯨" } else { "☆" };
1286 match on_rate {
1287 Some(tokens) => {
1288 let (send, token) = (send.clone(), tokens.get(usize::from(i - 1)).cloned().unwrap_or_default());
1289 view! {
1290 <button class="star star-tappable" on:click=move |_| send(Action::Fired { token: token.clone() })>
1291 {glyph}
1292 </button>
1293 }
1294 .into_any()
1295 }
1296 None => view! { <span class="star">{glyph}</span> }.into_any(),
1297 }
1298 })
1299 .collect();
1300 view! { <span class="rating">{stars}</span> }.into_any()
1301 }
1302 Widget::Divider => view! { <hr class="divider" /> }.into_any(),
1303 Widget::Progress { value } => match value {
1304 Some(v) => {
1305 let pct = (v.clamp(0.0, 1.0) * 100.0) as u32;
1306 view! { <div class="progress"><div class="progress-bar" style=format!("width:{pct}%")></div></div> }.into_any()
1307 }
1308 None => view! { <div class="progress progress-indeterminate"><div class="progress-bar"></div></div> }.into_any(),
1309 },
1310 Widget::Skeleton => view! { <div class="skeleton"></div> }.into_any(),
1311 Widget::Chart { series, labels, style, axis, legend } => {
1312 chart_view(series, labels, *style, *axis, *legend)
1313 }
1314 Widget::RegionChart { regions, ticks, x_max, y_max, ref_lines, bracket, legend } => {
1315 region_chart_view(regions, ticks, *x_max, *y_max, ref_lines, bracket, legend)
1316 }
1317 Widget::Calendar { title, weekday_labels, leading_blanks, selected, on_day, markers, .. } => {
1318 let heads: Vec<_> = weekday_labels.iter().map(|w| view! { <div class="cal-head">{w.clone()}</div> }).collect();
1319 let blanks: Vec<_> = (0..*leading_blanks).map(|_| view! { <div class="cal-blank"></div> }).collect();
1320 let selected = *selected;
1321 let days: Vec<_> = on_day.iter().enumerate().map(|(i, token)| {
1322 let day = (i + 1) as u8;
1323 let token = token.clone();
1324 let send = send.clone();
1325 let cls = if selected == Some(day) { "cal-day cal-sel" } else { "cal-day" };
1326 let level = markers.get(i).copied().unwrap_or(0).min(3);
1328 let dots = (level > 0).then(|| {
1329 let d: Vec<_> = (0..level).map(|_| view! { <span class="cal-dot"></span> }).collect();
1330 view! { <span class="cal-dots">{d}</span> }
1331 });
1332 view! { <button class=cls on:click=move |_| send(Action::Fired { token: token.clone() })>{day.to_string()}{dots}</button> }
1333 }).collect();
1334 view! {
1335 <div class="calendar">
1336 <div class="cal-title">{title.clone()}</div>
1337 <div class="cal-grid">{heads}{blanks}{days}</div>
1338 </div>
1339 }.into_any()
1340 }
1341 Widget::SwipeAction { child, actions } => {
1342 let acts: Vec<_> = actions.iter().map(|a| {
1344 let token = a.on_tap.clone();
1345 let send = send.clone();
1346 let cls = format!("swipe-act {}", tone_class(a.tone));
1347 let label = a.label.clone();
1348 view! { <button class=cls on:click=move |_| send(Action::Fired { token: token.clone() })>{label}</button> }
1349 }).collect();
1350 view! {
1351 <div class="swipe-row">
1352 <div class="swipe-content">{render(child, send)}</div>
1353 <div class="swipe-actions">{acts}</div>
1354 </div>
1355 }.into_any()
1356 }
1357 Widget::Spacer { size } => {
1358 view! { <div class=format!("spacer {}", spacer_class(*size))></div> }.into_any()
1359 }
1360
1361 Widget::Row { children } => {
1363 let kids = render_all(children, send);
1364 view! { <div class="row">{kids}</div> }.into_any()
1365 }
1366 Widget::Column { children } => {
1367 let kids = render_all(children, send);
1368 view! { <div class="col">{kids}</div> }.into_any()
1369 }
1370 Widget::Card { child, style, on_press, on_long_press } => {
1371 let class = format!("card {}", card_class(*style));
1372 let body = render(child, send);
1373 match (on_press, on_long_press) {
1374 (None, None) => view! { <div class=class>{body}</div> }.into_any(),
1376 (tap, long) => {
1378 let send = send.clone();
1379 let timer: Rc<RefCell<Option<gloo_timers::callback::Timeout>>> =
1383 Rc::new(RefCell::new(None));
1384 let long_fired = Rc::new(RefCell::new(false));
1385
1386 let on_pointerdown = {
1387 let (send, long, timer, long_fired) =
1388 (send.clone(), long.clone(), timer.clone(), long_fired.clone());
1389 move |_: web_sys::PointerEvent| {
1390 let Some(token) = long.clone() else { return };
1391 *long_fired.borrow_mut() = false;
1392 let (send, long_fired) = (send.clone(), long_fired.clone());
1393 *timer.borrow_mut() = Some(gloo_timers::callback::Timeout::new(
1394 500,
1395 move || {
1396 *long_fired.borrow_mut() = true;
1397 send(Action::Fired { token: token.clone() });
1398 },
1399 ));
1400 }
1401 };
1402 let cancel = {
1403 let timer = timer.clone();
1404 move |_: web_sys::PointerEvent| { timer.borrow_mut().take(); }
1406 };
1407 let on_click = {
1408 let (send, tap, long_fired) = (send.clone(), tap.clone(), long_fired.clone());
1409 move |_| {
1410 if std::mem::take(&mut *long_fired.borrow_mut()) {
1412 return;
1413 }
1414 if let Some(token) = tap.clone() {
1415 send(Action::Fired { token });
1416 }
1417 }
1418 };
1419 view! {
1420 <button
1421 class=format!("{class} card-tappable")
1422 on:pointerdown=on_pointerdown
1423 on:pointerup=cancel.clone()
1424 on:pointerleave=cancel.clone()
1425 on:pointercancel=cancel
1426 on:click=on_click
1427 >
1428 {body}
1429 </button>
1430 }
1431 .into_any()
1432 }
1433 }
1434 }
1435 Widget::Box { children, align, scrim } => {
1439 let acls = align_class(*align);
1440 if *scrim && children.len() > 1 {
1441 let bg = render(&children[0], send);
1442 let content = render_all(&children[1..], send);
1443 view! {
1444 <div class=format!("box box-scrim {acls}")>
1445 {bg}
1446 <div class="scrim"></div>
1447 <div class="box-content">{content}</div>
1448 </div>
1449 }
1450 .into_any()
1451 } else {
1452 let kids = render_all(children, send);
1453 view! { <div class=format!("box {acls}")>{kids}</div> }.into_any()
1454 }
1455 }
1456 Widget::Grid { children } => {
1457 let kids = render_all(children, send);
1458 view! { <div class="grid">{kids}</div> }.into_any()
1459 }
1460 Widget::Scroller { children, edge_fade } => {
1461 let kids = render_all(children, send);
1462 if *edge_fade {
1463 view! { <div class="scroller scroller-fade">{kids}<div class="scroller-end"></div></div> }.into_any()
1464 } else {
1465 view! { <div class="scroller">{kids}</div> }.into_any()
1466 }
1467 }
1468 Widget::Split { primary, detail, show_detail, on_back } => {
1472 let p = render(primary, send);
1473 let d = render(detail, send);
1474 let back_btn = on_back.clone().map(|t| {
1475 let send = send.clone();
1476 view! { <button class="split-back" on:click=move |_| send(Action::Fired { token: t.clone() })>"‹ Back"</button> }
1477 });
1478 view! {
1479 <div class="split" data-detail=show_detail.then_some("1")>
1480 <div class="split-primary">{p}</div>
1481 <div class="split-detail">{back_btn}{d}</div>
1482 </div>
1483 }.into_any()
1484 }
1485 Widget::A11y { child, label, hint, role } => {
1488 let body = render(child, send);
1489 let role_attr = role.map(a11y_role_aria).unwrap_or("group");
1490 view! {
1491 <div class="a11y" role=role_attr aria-label=label.clone() title=hint.clone()>
1492 {body}
1493 </div>
1494 }.into_any()
1495 }
1496 Widget::LazyList { children, on_load_more, loading, has_more, on_refresh, refreshing } => {
1501 let kids = render_all(children, send);
1502 let refresh_btn = on_refresh.clone().map(|token| {
1503 let send = send.clone();
1504 view! { <button class="refresh-btn" on:click=move |_| send(Action::Fired { token: token.clone() })>"↻ Refresh"</button> }
1505 });
1506 let refresh_bar = refreshing.then(|| view! { <div class="progress progress-indeterminate"><div class="progress-bar"></div></div> });
1507 let loading_bar = loading.then(|| view! { <div class="progress progress-indeterminate"><div class="progress-bar"></div></div> });
1508 let load_more_btn = (!*loading && *has_more)
1509 .then(|| on_load_more.clone())
1510 .flatten()
1511 .map(|token| {
1512 let send = send.clone();
1513 view! { <button class="btn btn-outlined lazylist-more" on:click=move |_| send(Action::Fired { token: token.clone() })>"Load more"</button> }
1514 });
1515 let end_cap = (!*has_more && on_load_more.is_some()).then(|| view! { <div class="lazylist-end">"End of list"</div> });
1516 view! {
1517 <div class="lazylist">
1518 {refresh_btn}
1519 {refresh_bar}
1520 {kids}
1521 {loading_bar}
1522 {load_more_btn}
1523 {end_cap}
1524 </div>
1525 }.into_any()
1526 }
1527
1528 Widget::Button { label, style, on_press, tone, icon, wide } => {
1530 let (send, token, label) = (send.clone(), on_press.clone(), label.clone());
1531 let mut class = format!("btn {}", button_class(*style));
1533 if *tone != Tone::Neutral {
1534 class.push(' ');
1535 class.push_str(button_tone_class(*tone));
1536 }
1537 if *wide {
1538 class.push_str(" btn-wide");
1539 }
1540 let glyph = icon.map(|i| view! { <span class="btn-icon">{icon_glyph(i)}</span> });
1541 view! {
1542 <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
1543 {glyph}
1544 {label}
1545 </button>
1546 }
1547 .into_any()
1548 }
1549 Widget::IconButton { icon, on_press } => {
1550 let (send, token) = (send.clone(), on_press.clone());
1551 let glyph = icon_glyph(*icon);
1552 view! {
1553 <button class="iconbtn" on:click=move |_| send(Action::Fired { token: token.clone() })>
1554 {glyph}
1555 </button>
1556 }
1557 .into_any()
1558 }
1559 Widget::Chip { label, selected, on_press } => {
1560 let (send, token, label) = (send.clone(), on_press.clone(), label.clone());
1561 let class = if *selected { "chip selected" } else { "chip" };
1562 view! {
1563 <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
1564 {label}
1565 </button>
1566 }
1567 .into_any()
1568 }
1569 Widget::TextField { id, placeholder, value, kind, error } => {
1570 let (send, id) = (send.clone(), id.clone());
1571 let (placeholder, value) = (placeholder.clone(), value.clone());
1572 let invalid = error.is_some();
1573 let err_view = error.clone().map(|m| view! { <div class="field-error">{m}</div> });
1574 let (itype, imode): (&str, &str) = match kind {
1576 FieldKind::Secure => ("password", ""),
1577 FieldKind::Email => ("email", "email"),
1578 FieldKind::Number => ("text", "numeric"),
1579 FieldKind::Decimal => ("text", "decimal"),
1580 FieldKind::Phone => ("tel", "tel"),
1581 FieldKind::Url => ("url", "url"),
1582 FieldKind::Text | FieldKind::Multiline => ("text", ""),
1583 };
1584 let field_class = if invalid { "field field-invalid" } else { "field" };
1585 let control = if matches!(kind, FieldKind::Multiline) {
1586 view! {
1587 <textarea
1588 class=field_class
1589 rows="3"
1590 placeholder=placeholder
1591 prop:value=value
1592 on:input=move |ev| send(Action::Input {
1593 id: id.clone(),
1594 value: InputValue::Text(event_target_value(&ev)),
1595 })
1596 ></textarea>
1597 }
1598 .into_any()
1599 } else {
1600 view! {
1601 <input
1602 class=field_class
1603 r#type=itype
1604 inputmode=imode
1605 placeholder=placeholder
1606 prop:value=value
1607 on:input=move |ev| send(Action::Input {
1608 id: id.clone(),
1609 value: InputValue::Text(event_target_value(&ev)),
1610 })
1611 />
1612 }
1613 .into_any()
1614 };
1615 view! { <div class="field-wrap">{control}{err_view}</div> }.into_any()
1616 }
1617 Widget::SearchField { id, placeholder, value } => {
1618 let (send, id) = (send.clone(), id.clone());
1619 let (placeholder, value) = (placeholder.clone(), value.clone());
1620 view! {
1621 <div class="searchfield">
1622 <span class="search-icon">{icon_glyph(Icon::Search)}</span>
1623 <input
1624 class="search-input"
1625 placeholder=placeholder
1626 prop:value=value
1627 on:input=move |ev| send(Action::Input {
1628 id: id.clone(),
1629 value: InputValue::Text(event_target_value(&ev)),
1630 })
1631 />
1632 </div>
1633 }
1634 .into_any()
1635 }
1636 Widget::Segmented { segments } => {
1637 let segs: Vec<AnyView> = segments
1638 .iter()
1639 .map(|s| {
1640 let (send, token) = (send.clone(), s.on_select.clone());
1641 let class = if s.selected { "segment selected" } else { "segment" };
1642 let label = s.label.clone();
1643 view! {
1644 <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
1645 {label}
1646 </button>
1647 }
1648 .into_any()
1649 })
1650 .collect();
1651 view! { <div class="segmented">{segs}</div> }.into_any()
1652 }
1653 Widget::Toggle { id, label, value } => {
1654 let (send, id, label, checked) = (send.clone(), id.clone(), label.clone(), *value);
1655 view! {
1656 <label class="toggle">
1657 {label}
1658 <input
1659 type="checkbox"
1660 role="switch"
1661 prop:checked=checked
1662 on:change=move |ev| send(Action::Input {
1663 id: id.clone(),
1664 value: InputValue::Bool(event_target_checked(&ev)),
1665 })
1666 />
1667 </label>
1668 }
1669 .into_any()
1670 }
1671 Widget::Checkbox { id, label, value } => {
1672 let (send, id, label, checked) = (send.clone(), id.clone(), label.clone(), *value);
1673 view! {
1674 <label class="check">
1675 <input
1676 type="checkbox"
1677 prop:checked=checked
1678 on:change=move |ev| send(Action::Input {
1679 id: id.clone(),
1680 value: InputValue::Bool(event_target_checked(&ev)),
1681 })
1682 />
1683 {label}
1684 </label>
1685 }
1686 .into_any()
1687 }
1688 Widget::Slider { id, value, max } => {
1689 let (send, id, value, max) = (send.clone(), id.clone(), *value, *max);
1690 view! {
1691 <input
1692 class="slider"
1693 type="range"
1694 min="0"
1695 max=max
1696 prop:value=value
1697 on:input=move |ev| send(Action::Input {
1698 id: id.clone(),
1699 value: InputValue::Int(event_target_value(&ev).parse().unwrap_or(0)),
1700 })
1701 />
1702 }
1703 .into_any()
1704 }
1705 Widget::Stepper { value, on_decrement, on_increment } => {
1706 let send_dec = send.clone();
1707 let send_inc = send.clone();
1708 let (dec, inc) = (on_decrement.clone(), on_increment.clone());
1709 view! {
1710 <div class="stepper">
1711 <button on:click=move |_| send_dec(Action::Fired { token: dec.clone() })>"−"</button>
1712 <span class="stepper-value">{*value}</span>
1713 <button on:click=move |_| send_inc(Action::Fired { token: inc.clone() })>"+"</button>
1714 </div>
1715 }
1716 .into_any()
1717 }
1718
1719 Widget::Scaffold { title, body, tabs, back, dark_mode, theme, fab, sheet, on_refresh, refreshing, route, depth } => {
1721 let back_btn = back.clone().map(|token| {
1722 let send = send.clone();
1723 view! {
1724 <button class="back" on:click=move |_| send(Action::Fired { token: token.clone() })>
1725 "‹"
1726 </button>
1727 }
1728 });
1729 let tabbar = (!tabs.is_empty()).then(|| {
1730 let tabs: Vec<AnyView> = tabs
1731 .iter()
1732 .map(|tab| {
1733 let (send, token) = (send.clone(), tab.on_select.clone());
1734 let class = if tab.selected { "tab selected" } else { "tab" };
1735 let label = tab.label.clone();
1736 let icon = tab.icon.map(|i| view! { <span class="tab-icon">{icon_glyph(i)}</span> });
1738 view! {
1739 <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
1740 {icon}
1741 <span class="tab-label">{label}</span>
1742 </button>
1743 }
1744 .into_any()
1745 })
1746 .collect();
1747 view! { <div class="tabbar">{tabs}</div> }
1748 });
1749 let fab_btn = fab.clone().map(|f| {
1751 let (send, token) = (send.clone(), f.on_press.clone());
1752 view! {
1753 <button class="fab" on:click=move |_| send(Action::Fired { token: token.clone() })>
1754 {icon_glyph(f.icon)}
1755 </button>
1756 }
1757 });
1758 let sheet_overlay = sheet.as_ref().map(|s| {
1760 let (send_scrim, dismiss) = (send.clone(), s.on_dismiss.clone());
1761 let (title, child) = (s.title.clone(), render(&s.child, send));
1762 view! {
1763 <div class="sheet-scrim" on:click=move |_| send_scrim(Action::Fired { token: dismiss.clone() })></div>
1764 <div class="sheet">
1765 <div class="sheet-handle"></div>
1766 <div class="sheet-title">{title}</div>
1767 {child}
1768 </div>
1769 }
1770 });
1771 let large = theme.as_ref().is_some_and(|t| t.density == Density::Large);
1775 let class = format!("scaffold{}{}", if *dark_mode { " theme-dark" } else { "" }, if large { " density-large" } else { "" });
1776 let refresh_btn = on_refresh.clone().map(|token| {
1779 let send = send.clone();
1780 view! {
1781 <button class="refresh-btn" on:click=move |_| send(Action::Fired { token: token.clone() })>"↻"</button>
1782 }
1783 });
1784 let refresh_bar = refreshing.then(|| {
1785 view! { <div class="progress progress-indeterminate"><div class="progress-bar"></div></div> }
1786 });
1787 let body_class = format!("scaffold-body {}", nav_class(route, *depth));
1788 let theme_style = theme.as_ref().map(theme_css).unwrap_or_default();
1791 let (title, body) = (title.clone(), render(body, send));
1792 view! {
1793 <div class=class style=theme_style>
1794 <div class="topbar">
1795 {back_btn}
1796 <span class="title">{title}</span>
1797 {refresh_btn}
1798 </div>
1799 <div class=body_class data-route=route.clone()>{refresh_bar}{body}</div>
1800 {fab_btn}
1801 {tabbar}
1802 {sheet_overlay}
1803 </div>
1804 }
1805 .into_any()
1806 }
1807 }
1808}
1809
1810fn render_all(children: &[Widget], send: &Dispatch) -> Vec<AnyView> {
1812 children.iter().map(|c| render(c, send)).collect()
1813}
1814
1815thread_local! {
1816 static NAV: RefCell<(String, u32, bool)> = const { RefCell::new((String::new(), 0, false)) };
1821
1822 static STREAMS: RefCell<HashMap<String, StreamHandle>> = RefCell::new(HashMap::new());
1826}
1827
1828fn theme_css(t: &Theme) -> String {
1833 let (r, g, b) = (t.seed.r, t.seed.g, t.seed.b);
1834 let radius = match t.corner {
1835 Corner::None => "0px",
1836 Corner::Small => "8px",
1837 Corner::Medium => "14px",
1838 Corner::Large => "22px",
1839 };
1840 let (gap, pad) = match t.density {
1841 Density::Compact => ("8px", "10px"),
1842 Density::Comfortable => ("12px", "14px"),
1843 Density::Large => ("16px", "18px"),
1844 };
1845 let font = match t.font {
1846 FontFamily::System => "system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif",
1847 FontFamily::Rounded => "ui-rounded, \"SF Pro Rounded\", \"Segoe UI\", system-ui, sans-serif",
1848 FontFamily::Serif => "ui-serif, Georgia, \"Times New Roman\", serif",
1849 FontFamily::Monospace => "ui-monospace, \"SF Mono\", \"Cascadia Code\", Menlo, monospace",
1850 };
1851 let (ar, ag, ab) = t.accent.map_or((r, g, b), |a| (a.r, a.g, a.b));
1853 format!(
1854 "--primary:rgb({r},{g},{b});--accent:rgb({r},{g},{b});\
1855 --accent2:rgb({ar},{ag},{ab});\
1856 --accent-soft:rgba({r},{g},{b},0.16);--radius:{radius};\
1857 --gap:{gap};--pad:{pad};--font:{font};"
1858 )
1859}
1860
1861fn nav_class(route: &str, depth: u32) -> &'static str {
1868 NAV.with_borrow_mut(|(prev_route, prev_depth, toggle)| {
1869 if route == prev_route {
1870 return "";
1871 }
1872 let dir = if depth > *prev_depth {
1873 ["nav-push-a", "nav-push-b"]
1874 } else if depth < *prev_depth {
1875 ["nav-pop-a", "nav-pop-b"]
1876 } else {
1877 ["nav-fade-a", "nav-fade-b"]
1878 };
1879 *toggle = !*toggle;
1880 *prev_route = route.to_string();
1881 *prev_depth = depth;
1882 dir[usize::from(*toggle)]
1883 })
1884}
1885
1886fn text_class(s: TextStyle) -> &'static str {
1889 match s {
1890 TextStyle::Title => "t-title",
1891 TextStyle::Subtitle => "t-subtitle",
1892 TextStyle::Caption => "t-caption",
1893 TextStyle::Emphasis => "t-emphasis",
1894 TextStyle::Body => "t-body",
1895 }
1896}
1897
1898fn button_class(s: ButtonStyle) -> &'static str {
1899 match s {
1900 ButtonStyle::Filled => "btn-filled",
1901 ButtonStyle::Outlined => "btn-outlined",
1902 ButtonStyle::Text => "btn-text",
1903 ButtonStyle::Tonal => "btn-tonal",
1904 }
1905}
1906
1907fn button_tone_class(t: Tone) -> &'static str {
1908 match t {
1909 Tone::Neutral => "",
1910 Tone::Success => "btn-success",
1911 Tone::Warning => "btn-warning",
1912 Tone::Danger => "btn-danger",
1913 Tone::Info => "btn-info",
1914 }
1915}
1916
1917fn card_class(s: CardStyle) -> &'static str {
1918 match s {
1919 CardStyle::Elevated => "card-elevated",
1920 CardStyle::Outlined => "card-outlined",
1921 CardStyle::Filled => "card-filled",
1922 CardStyle::Brand => "card-brand",
1923 }
1924}
1925
1926fn a11y_role_aria(role: A11yRole) -> &'static str {
1927 match role {
1928 A11yRole::Button => "button",
1929 A11yRole::Link => "link",
1930 A11yRole::Image => "img",
1931 A11yRole::Header => "heading",
1932 A11yRole::Adjustable => "slider",
1933 }
1934}
1935
1936fn tone_class(t: Tone) -> &'static str {
1937 match t {
1938 Tone::Neutral => "tone-neutral",
1939 Tone::Success => "tone-success",
1940 Tone::Warning => "tone-warning",
1941 Tone::Danger => "tone-danger",
1942 Tone::Info => "tone-info",
1943 }
1944}
1945
1946fn spacer_class(s: Spacing) -> &'static str {
1947 match s {
1948 Spacing::Xs => "sp-xs",
1949 Spacing::Sm => "sp-sm",
1950 Spacing::Md => "sp-md",
1951 Spacing::Lg => "sp-lg",
1952 Spacing::Xl => "sp-xl",
1953 }
1954}
1955
1956fn icon_glyph(i: Icon) -> &'static str {
1957 match i {
1958 Icon::Delete => "🗑",
1959 Icon::Add => "+",
1960 Icon::Edit => "✏️",
1961 Icon::Close => "✕",
1962 Icon::Settings => "⚙",
1963 Icon::Check => "✓",
1964 Icon::Star => "★",
1965 Icon::Info => "ℹ",
1966 Icon::Home => "⌂",
1967 Icon::Search => "🔍",
1968 Icon::Menu => "☰",
1969 Icon::Filter => "⚟",
1970 Icon::Back => "‹",
1971 Icon::Forward => "›",
1972 Icon::Down => "⌄",
1973 Icon::Bell => "🔔",
1974 Icon::Cart => "🛒",
1975 Icon::Share => "↗",
1976 Icon::Heart => "♡",
1977 Icon::HeartFilled => "♥",
1978 Icon::Person => "👤",
1979 Icon::People => "👥",
1980 Icon::Phone => "📞",
1981 Icon::Mail => "✉",
1982 Icon::Calendar => "📅",
1983 Icon::Clock => "🕑",
1984 Icon::MapPin => "📍",
1985 Icon::Camera => "📷",
1986 Icon::Photo => "🖼",
1987 Icon::Play => "▶",
1988 Icon::Scissors => "✂",
1989 }
1990}
1991
1992fn image_class(shape: ImageShape, ratio: ImageRatio) -> String {
1993 let shape = match shape {
1994 ImageShape::Square => "img-square",
1995 ImageShape::Rounded => "img-rounded",
1996 ImageShape::Circle => "img-circle",
1997 };
1998 let ratio = match ratio {
1999 ImageRatio::Wide => "ratio-wide",
2000 ImageRatio::Square => "ratio-square",
2001 ImageRatio::Tall => "ratio-tall",
2002 };
2003 format!("img {shape} {ratio}")
2004}
2005
2006fn dot_class(c: ProjectColor) -> &'static str {
2007 match c {
2008 ProjectColor::Indigo => "dot-indigo",
2009 ProjectColor::Teal => "dot-teal",
2010 ProjectColor::Coral => "dot-coral",
2011 ProjectColor::Amber => "dot-amber",
2012 ProjectColor::Lime => "dot-lime",
2013 ProjectColor::Pink => "dot-pink",
2014 }
2015}
2016
2017fn align_class(a: BoxAlign) -> &'static str {
2018 match a {
2019 BoxAlign::TopStart => "align-top-start",
2020 BoxAlign::TopEnd => "align-top-end",
2021 BoxAlign::Center => "align-center",
2022 BoxAlign::BottomStart => "align-bottom-start",
2023 BoxAlign::BottomCenter => "align-bottom-center",
2024 BoxAlign::BottomEnd => "align-bottom-end",
2025 }
2026}
2027
2028const CHART_PALETTE: [&str; 6] = ["#E0772C", "#2EA06A", "#C0466B", "#8A5CC0", "#C9A227", "#3FA7D6"];
2032
2033fn hex(c: Rgb) -> String {
2034 format!("#{:02x}{:02x}{:02x}", c.r, c.g, c.b)
2035}
2036
2037fn chart_color(i: usize, s: &ChartSeries) -> String {
2039 match s.color {
2040 Some(c) => hex(c),
2041 None if i == 0 => "var(--accent, #5C6BC0)".to_string(),
2042 None => CHART_PALETTE[(i - 1) % CHART_PALETTE.len()].to_string(),
2043 }
2044}
2045
2046fn chart_mag(s: &ChartSeries) -> f32 {
2048 s.values.iter().copied().sum()
2049}
2050
2051fn polar(cx: f32, cy: f32, r: f32, ang: f32) -> (f32, f32) {
2053 (cx + r * ang.sin(), cy - r * ang.cos())
2054}
2055
2056fn arc_path(cx: f32, cy: f32, r: f32, a0: f32, a1: f32) -> String {
2058 let (x0, y0) = polar(cx, cy, r, a0);
2059 let (x1, y1) = polar(cx, cy, r, a1);
2060 let large = if (a1 - a0).abs() > std::f32::consts::PI { 1 } else { 0 };
2061 format!("M {x0:.2} {y0:.2} A {r:.2} {r:.2} 0 {large} 1 {x1:.2} {y1:.2}")
2062}
2063
2064fn wedge_path(cx: f32, cy: f32, r: f32, a0: f32, a1: f32) -> String {
2066 let (x0, y0) = polar(cx, cy, r, a0);
2067 let (x1, y1) = polar(cx, cy, r, a1);
2068 let large = if (a1 - a0).abs() > std::f32::consts::PI { 1 } else { 0 };
2069 format!("M {cx:.2} {cy:.2} L {x0:.2} {y0:.2} A {r:.2} {r:.2} 0 {large} 1 {x1:.2} {y1:.2} Z")
2070}
2071
2072fn fmt_tick(v: f32) -> String {
2073 if (v - v.round()).abs() < 0.05 { format!("{}", v.round() as i64) } else { format!("{v:.1}") }
2074}
2075
2076fn is_cartesian(style: ChartStyle) -> bool {
2077 matches!(style, ChartStyle::Bar | ChartStyle::Line | ChartStyle::StackedBar | ChartStyle::StackedBar100)
2078}
2079
2080fn cartesian_max(series: &[ChartSeries], style: ChartStyle, nslots: usize) -> f32 {
2082 match style {
2083 ChartStyle::StackedBar => (0..nslots)
2084 .map(|j| series.iter().map(|s| *s.values.get(j).unwrap_or(&0.0)).sum::<f32>())
2085 .fold(0.0, f32::max)
2086 .max(1e-6),
2087 ChartStyle::StackedBar100 => 1.0,
2088 _ => series.iter().flat_map(|s| s.values.iter().copied()).fold(0.0, f32::max).max(1e-6),
2089 }
2090}
2091
2092fn cartesian_svg(series: &[ChartSeries], style: ChartStyle, axis: bool, max: f32, nslots: usize) -> AnyView {
2093 let mut nodes: Vec<AnyView> = Vec::new();
2095 if axis {
2096 for k in 0..=4 {
2097 let y = 2.0 + k as f32 * (46.0 / 4.0);
2098 nodes.push(view! { <line x1="0" y1=format!("{y:.2}") x2="100" y2=format!("{y:.2}") class="chart-gridline"></line> }.into_any());
2099 }
2100 }
2101 match style {
2102 ChartStyle::Line => {
2103 for (i, s) in series.iter().enumerate() {
2104 let n = s.values.len().max(1);
2105 let pts = s.values.iter().enumerate().map(|(j, v)| {
2106 let x = if n == 1 { 50.0 } else { j as f32 * (100.0 / (n as f32 - 1.0)) };
2107 let y = 2.0 + (1.0 - (v / max).clamp(0.0, 1.0)) * 46.0;
2108 format!("{x:.2},{y:.2}")
2109 }).collect::<Vec<_>>().join(" ");
2110 let st = format!("fill:none;stroke:{};stroke-width:1.5;vector-effect:non-scaling-stroke", chart_color(i, s));
2111 nodes.push(view! { <polyline points=pts style=st></polyline> }.into_any());
2112 }
2113 }
2114 ChartStyle::Bar => {
2115 let sw = 100.0 / nslots as f32;
2116 let ns = series.len().max(1);
2117 for (i, s) in series.iter().enumerate() {
2118 let st = format!("fill:{}", chart_color(i, s));
2119 for (j, v) in s.values.iter().enumerate() {
2120 let h = (v / max).clamp(0.0, 1.0) * 46.0;
2121 let bw = sw * 0.8 / ns as f32;
2122 let x = j as f32 * sw + sw * 0.1 + i as f32 * bw;
2123 let y = 48.0 - h;
2124 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());
2125 }
2126 }
2127 }
2128 ChartStyle::StackedBar | ChartStyle::StackedBar100 => {
2129 let sw = 100.0 / nslots as f32;
2130 for j in 0..nslots {
2131 let slot_total = series.iter().map(|s| *s.values.get(j).unwrap_or(&0.0)).sum::<f32>().max(1e-6);
2132 let denom = if matches!(style, ChartStyle::StackedBar100) { slot_total } else { max };
2133 let mut acc = 0.0_f32;
2134 for (i, s) in series.iter().enumerate() {
2135 let v = *s.values.get(j).unwrap_or(&0.0);
2136 let h = (v / denom).clamp(0.0, 1.0) * 46.0;
2137 let x = j as f32 * sw + sw * 0.15;
2138 let bw = sw * 0.7;
2139 let y = 48.0 - acc - h;
2140 let st = format!("fill:{}", chart_color(i, s));
2141 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());
2142 acc += h;
2143 }
2144 }
2145 }
2146 _ => {}
2147 }
2148 view! { <svg viewBox="0 0 100 50" preserveAspectRatio="none" class="chart-svg">{nodes}</svg> }.into_any()
2149}
2150
2151fn circular_svg(series: &[ChartSeries], style: ChartStyle) -> AnyView {
2152 use std::f32::consts::PI;
2153 let mut nodes: Vec<AnyView> = Vec::new();
2154 match style {
2155 ChartStyle::Pie | ChartStyle::Donut => {
2156 let total = series.iter().map(chart_mag).sum::<f32>().max(1e-6);
2157 let mut a = 0.0_f32;
2158 for (i, s) in series.iter().enumerate() {
2159 let frac = chart_mag(s) / total;
2160 let st = format!("fill:{}", chart_color(i, s));
2161 if frac >= 0.999 {
2162 nodes.push(view! { <circle cx="50" cy="50" r="45" style=st></circle> }.into_any());
2163 } else if frac > 0.0 {
2164 let d = wedge_path(50.0, 50.0, 45.0, a, a + frac * 2.0 * PI);
2165 nodes.push(view! { <path d=d style=st></path> }.into_any());
2166 }
2167 a += frac * 2.0 * PI;
2168 }
2169 if matches!(style, ChartStyle::Donut) {
2170 nodes.push(view! { <circle cx="50" cy="50" r="24" style="fill:var(--surface, #ffffff)"></circle> }.into_any());
2171 }
2172 }
2173 ChartStyle::Rings => {
2174 let n = series.len().max(1);
2175 for (i, s) in series.iter().enumerate() {
2176 let r = 45.0 - i as f32 * (34.0 / n as f32);
2177 let goal = s.goal.unwrap_or_else(|| chart_mag(s)).max(1e-6);
2178 let prog = (chart_mag(s) / goal).clamp(0.0, 1.0);
2179 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());
2180 let st = format!("fill:none;stroke:{};stroke-width:6;stroke-linecap:round", chart_color(i, s));
2181 if prog >= 0.999 {
2182 nodes.push(view! { <circle cx="50" cy="50" r=format!("{r:.2}") style=st></circle> }.into_any());
2183 } else if prog > 0.0 {
2184 let d = arc_path(50.0, 50.0, r, 0.0, prog * 2.0 * PI);
2185 nodes.push(view! { <path d=d style=st></path> }.into_any());
2186 }
2187 }
2188 }
2189 ChartStyle::Gauge => {
2190 let s = match series.first() { Some(s) => s, None => return view! { <svg viewBox="0 0 100 100" class="chart-svg"></svg> }.into_any() };
2191 let goal = s.goal.unwrap_or_else(|| chart_mag(s)).max(1e-6);
2192 let prog = (chart_mag(s) / goal).clamp(0.0, 1.0);
2193 let a0 = -0.75 * PI; let a1 = 0.75 * PI;
2195 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());
2196 if prog > 0.0 {
2197 let st = format!("fill:none;stroke:{};stroke-width:8;stroke-linecap:round", chart_color(0, s));
2198 nodes.push(view! { <path d=arc_path(50.0, 50.0, 42.0, a0, a0 + prog * 1.5 * PI) style=st></path> }.into_any());
2199 }
2200 let pct = format!("{}%", (prog * 100.0).round() as i64);
2201 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());
2202 }
2203 _ => {}
2204 }
2205 view! { <svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet" class="chart-svg">{nodes}</svg> }.into_any()
2206}
2207
2208fn chart_view(series: &[ChartSeries], labels: &[String], style: ChartStyle, axis: bool, legend: bool) -> AnyView {
2209 let cartesian = is_cartesian(style);
2210 let nslots = series.iter().map(|s| s.values.len()).max().unwrap_or(0).max(1);
2211 let max = cartesian_max(series, style, nslots);
2212
2213 let plot = if cartesian {
2214 let svg = cartesian_svg(series, style, axis, max, nslots);
2215 let yaxis = if axis {
2216 let ticks: Vec<_> = [max, max / 2.0, 0.0].iter()
2217 .map(|t| view! { <span class="chart-tick">{fmt_tick(*t)}</span> })
2218 .collect();
2219 Some(view! { <div class="chart-yaxis">{ticks}</div> })
2220 } else {
2221 None
2222 };
2223 view! { <div class="chart-plot">{yaxis}{svg}</div> }.into_any()
2224 } else {
2225 circular_svg(series, style).into_any()
2226 };
2227
2228 let label_row = if cartesian && !labels.is_empty() {
2229 let items: Vec<_> = labels.iter().map(|l| view! { <span class="chart-label">{l.clone()}</span> }).collect();
2230 Some(view! { <div class="chart-labels">{items}</div> })
2231 } else {
2232 None
2233 };
2234
2235 let legend_row = if legend {
2236 let items: Vec<_> = series.iter().enumerate().map(|(i, s)| {
2237 let sw = format!("background:{}", chart_color(i, s));
2238 let name = s.name.clone();
2239 view! { <span class="chart-legend-item"><span class="chart-swatch" style=sw></span>{name}</span> }
2240 }).collect();
2241 Some(view! { <div class="chart-legend">{items}</div> })
2242 } else {
2243 None
2244 };
2245
2246 view! { <div class="chart">{plot}{label_row}{legend_row}</div> }.into_any()
2247}
2248
2249const CHART_PALETTE_RGB: [(u8, u8, u8); 6] =
2253 [(0xE0, 0x77, 0x2C), (0x2E, 0xA0, 0x6A), (0xC0, 0x46, 0x6B), (0x8A, 0x5C, 0xC0), (0xC9, 0xA2, 0x27), (0x3F, 0xA7, 0xD6)];
2254
2255fn region_rgb(i: usize, r: &ChartRegion) -> (u8, u8, u8) {
2257 match r.color {
2258 Some(c) => (c.r, c.g, c.b),
2259 None => CHART_PALETTE_RGB[i % CHART_PALETTE_RGB.len()],
2260 }
2261}
2262
2263fn contrast_text((r, g, b): (u8, u8, u8)) -> &'static str {
2265 let lum = 0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32;
2266 if lum > 140.0 { "#1a1a1a" } else { "#f5f5f5" }
2267}
2268
2269fn region_color(i: usize, r: &ChartRegion) -> String {
2270 let (r8, g8, b8) = region_rgb(i, r);
2271 format!("#{r8:02x}{g8:02x}{b8:02x}")
2272}
2273
2274fn region_chart_view(
2278 regions: &[ChartRegion],
2279 ticks: &[ChartTick],
2280 x_max: f32,
2281 y_max: f32,
2282 ref_lines: &[ChartRefLine],
2283 bracket: &Option<ChartBracket>,
2284 legend: &[ChartLegendItem],
2285) -> AnyView {
2286 let xm = x_max.max(1e-6);
2287 let ym = y_max.max(1e-6);
2288
2289 let region_divs: Vec<_> = regions.iter().enumerate().map(|(i, r)| {
2290 let left = (r.x0 / xm * 100.0).clamp(0.0, 100.0);
2291 let width = ((r.x1 - r.x0) / xm * 100.0).clamp(0.0, 100.0);
2292 let bottom = (r.y0 / ym * 100.0).clamp(0.0, 100.0);
2293 let height = ((r.y1 - r.y0) / ym * 100.0).clamp(0.0, 100.0);
2294 let style = format!("left:{left:.3}%;width:{width:.3}%;bottom:{bottom:.3}%;height:{height:.3}%;background:{}", region_color(i, r));
2295 let label_class = if r.vertical { "rchart-label rchart-label-v" } else { "rchart-label" };
2296 let label_style = format!("color:{}", contrast_text(region_rgb(i, r)));
2297 let label = r.label.clone();
2298 view! { <div class="rchart-region" style=style><span class=label_class style=label_style>{label}</span></div> }
2299 }).collect();
2300
2301 let ref_line_divs: Vec<_> = ref_lines.iter().map(|rl| {
2304 let style = format!("bottom:{:.3}%", (rl.value / ym * 100.0).clamp(0.0, 100.0));
2305 let cls = if rl.dashed { "rchart-refline rchart-refline-dashed" } else { "rchart-refline" };
2306 view! { <div class=cls style=style></div> }
2307 }).collect();
2308 let chip_divs: Vec<_> = ref_lines.iter().map(|rl| {
2309 let style = format!("bottom:{:.3}%", (rl.value / ym * 100.0).clamp(0.0, 100.0));
2310 let label = rl.label.clone();
2311 view! { <div class="rchart-chip" style=style>{label}</div> }
2312 }).collect();
2313
2314 let bracket_div = bracket.as_ref().map(|b| {
2315 let bottom = (b.y0 / ym * 100.0).clamp(0.0, 100.0);
2316 let height = ((b.y1 - b.y0) / ym * 100.0).clamp(0.0, 100.0);
2317 let style = format!("bottom:{bottom:.3}%;height:{height:.3}%");
2318 let label = if b.info { format!("ⓘ\n{}", b.label) } else { b.label.clone() };
2319 view! { <div class="rchart-bracket" style=style><span>{label}</span></div> }
2320 });
2321
2322 let yticks: Vec<_> = (0..=4).rev().map(|k| {
2323 let v = ym * k as f32 / 4.0;
2324 view! { <span class="chart-tick">{fmt_tick(v)}</span> }
2325 }).collect();
2326
2327 let xticks: Vec<_> = ticks.iter().map(|t| {
2328 let style = format!("left:{:.3}%", (t.at / xm * 100.0).clamp(0.0, 100.0));
2329 let label = t.label.clone();
2330 view! { <span class="rchart-xtick" style=style>{label}</span> }
2331 }).collect();
2332
2333 let ytick_marks: Vec<_> = (0..=4).map(|k| {
2336 let style = format!("bottom:{:.3}%", k as f32 * 25.0);
2337 view! { <div class="rchart-ytick" style=style></div> }
2338 }).collect();
2339 let xtick_marks: Vec<_> = ticks.iter().map(|t| {
2340 let style = format!("left:{:.3}%", (t.at / xm * 100.0).clamp(0.0, 100.0));
2341 view! { <div class="rchart-xtickmark" style=style></div> }
2342 }).collect();
2343
2344 let legend_row = if legend.is_empty() {
2345 None
2346 } else {
2347 let items: Vec<_> = legend.iter().map(|l| {
2348 let sw = format!("background:{}", hex(l.color));
2349 let name = l.label.clone();
2350 view! { <span class="chart-legend-item"><span class="chart-swatch" style=sw></span>{name}</span> }
2351 }).collect();
2352 Some(view! { <div class="chart-legend">{items}</div> })
2353 };
2354
2355 view! {
2356 <div class="rchart">
2357 <div class="rchart-row">
2358 <div class="rchart-yaxis">{yticks}</div>
2359 <div class="rchart-plotwrap">
2360 <div class="rchart-plot">{region_divs}{ytick_marks}{xtick_marks}{ref_line_divs}</div>
2361 {chip_divs}{bracket_div}
2362 </div>
2363 </div>
2364 <div class="rchart-xaxis">{xticks}</div>
2365 {legend_row}
2366 </div>
2367 }.into_any()
2368}