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 Action, BoxAlign, ButtonStyle, CardStyle, ChartBracket, ChartLegendItem, ChartRefLine, ChartRegion,
26 ChartSeries, ChartStyle, ChartTick, Corner, Density, Effect, FieldKind, FontFamily, Icon,
27 ImageRatio, ImageShape, InputValue, PluginCall, PluginNotify, PluginResponse, PluginStreamCall, ProjectColor,
28 Rgb, Spacing, TextStyle, Theme, Tone, Widget,
29};
30use wasm_bindgen_futures::spawn_local;
31
32const STYLE: &str = include_str!("mobiler.css");
38
39type Dispatch = Arc<dyn Fn(Action) + Send + Sync>;
42
43pub trait WebApp:
47 App<Event = Action, ViewModel = Widget, Effect = Effect> + Default + Send + Sync + 'static
48where
49 Self::Model: Default + Send + Sync,
50{
51}
52impl<T> WebApp for T
53where
54 T: App<Event = Action, ViewModel = Widget, Effect = Effect> + Default + Send + Sync + 'static,
55 T::Model: Default + Send + Sync,
56{
57}
58
59pub fn run<A: WebApp>()
61where
62 A::Model: Default + Send + Sync,
63{
64 console_error_panic_hook::set_once();
65 inject_default_style();
66 leptos::mount::mount_to_body(shell::<A>);
67}
68
69fn inject_default_style() {
73 let document = leptos::prelude::document();
74 let Some(head) = document.head() else { return };
75 let Ok(style) = document.create_element("style") else { return };
76 let _ = style.set_attribute("data-mobiler", "shell");
77 style.set_text_content(Some(STYLE));
78 let _ = head.insert_before(&style, head.first_child().as_ref());
79}
80
81fn shell<A: WebApp>() -> impl IntoView
82where
83 A::Model: Default + Send + Sync,
84{
85 let core = Arc::new(Core::<A>::new());
86 let (view, set_view) = signal(core.view());
87
88 let send: Dispatch = {
89 let core = core.clone();
90 Arc::new(move |action: Action| {
91 let effects = core.process_event(action);
92 drive(&core, set_view, effects);
93 })
94 };
95
96 let saved = local_storage().and_then(|s| s.get_item(STORAGE_KEY).ok().flatten()).unwrap_or_default();
99 if !saved.is_empty() {
100 send(Action::Restore { data: saved });
101 }
102 send(Action::Start);
103
104 let send_for_view = send.clone();
105 view! {
106 <div class="app">
107 {move || render(&view.get(), &send_for_view)}
108 </div>
109 }
110}
111
112fn drive<A: WebApp>(core: &Arc<Core<A>>, set_view: WriteSignal<Widget>, effects: Vec<Effect>)
114where
115 A::Model: Default + Send + Sync,
116{
117 for effect in effects {
118 match effect {
119 Effect::Render(_) => set_view.set(core.view()),
120 Effect::PluginNotify(notify) => perform_notify(¬ify.operation),
121 Effect::Plugin(mut request) => {
122 let core = core.clone();
123 spawn_local(async move {
124 let response = perform(&request.operation).await;
125 if let Ok(next) = core.resolve(&mut request, response) {
126 drive(&core, set_view, next);
127 }
128 });
129 }
130 Effect::PluginStream(request) => start_stream(core, set_view, request),
133 }
134 }
135}
136
137fn start_stream<A: WebApp>(
146 core: &Arc<Core<A>>,
147 set_view: WriteSignal<Widget>,
148 request: Request<PluginStreamCall>,
149) where
150 A::Model: Default + Send + Sync,
151{
152 use wasm_bindgen::{closure::Closure, JsCast};
153
154 let call = request.operation.clone();
155
156 let request = Rc::new(RefCell::new(request));
159 let core = core.clone();
160 let emit = move |resp: PluginResponse| {
161 if let Ok(next) = core.resolve(&mut *request.borrow_mut(), resp) {
162 drive(&core, set_view, next);
163 }
164 };
165
166 let handle = match (call.plugin.as_str(), call.op.as_str()) {
167 ("ticker", "start") => {
170 let ms: u32 = call.input.parse().unwrap_or(1000);
171 let count = std::cell::Cell::new(0u32);
172 let interval = gloo_timers::callback::Interval::new(ms, move || {
173 count.set(count.get() + 1);
174 emit(PluginResponse { ok: true, output: count.get().to_string() });
175 });
176 StreamHandle::Ticker { _interval: interval }
177 }
178 ("websocket", "stream") => {
179 let Ok(ws) = web_sys::WebSocket::new(&call.input) else { return };
180 let onmessage = {
181 let emit = emit.clone();
182 Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |e: web_sys::MessageEvent| {
183 emit(PluginResponse { ok: true, output: e.data().as_string().unwrap_or_default() });
184 })
185 };
186 let onclose = Closure::<dyn FnMut(web_sys::CloseEvent)>::new(move |_e| {
187 emit(PluginResponse { ok: false, output: "closed".into() });
188 });
189 ws.set_onmessage(Some(onmessage.as_ref().unchecked_ref()));
190 ws.set_onclose(Some(onclose.as_ref().unchecked_ref()));
191 StreamHandle::Ws(WsStream { ws, _onmessage: onmessage, _onclose: onclose })
192 }
193 _ => return, };
195
196 STREAMS.with(|m| {
197 m.borrow_mut().insert(call.key.clone(), handle);
198 });
199}
200
201enum StreamHandle {
205 Ticker { _interval: gloo_timers::callback::Interval },
207 Ws(WsStream),
208}
209
210struct WsStream {
212 ws: web_sys::WebSocket,
213 _onmessage: wasm_bindgen::closure::Closure<dyn FnMut(web_sys::MessageEvent)>,
214 _onclose: wasm_bindgen::closure::Closure<dyn FnMut(web_sys::CloseEvent)>,
215}
216
217async fn perform(call: &PluginCall) -> PluginResponse {
220 if call.plugin == "device" {
221 let nav = web_sys::window().map(|w| w.navigator());
222 let output = if call.op == "locale" {
223 nav.and_then(|n| n.language()).unwrap_or_else(|| "en-US".into())
225 } else {
226 nav.and_then(|n| n.user_agent().ok()).unwrap_or_default()
227 };
228 return PluginResponse { ok: true, output };
229 }
230 if call.plugin == "photo" && call.op == "pick" {
231 return take_image(false).await;
232 }
233 if call.plugin == "camera" && call.op == "capture" {
234 return take_image(true).await;
235 }
236 if call.plugin == "datetime" {
237 return match call.op.as_str() {
238 "date" => take_datetime("date").await,
239 "time" => take_datetime("time").await,
240 other => PluginResponse { ok: false, output: format!("unknown datetime op '{other}'") },
241 };
242 }
243 if call.plugin == "dialog" && call.op == "confirm" {
244 let v: serde_json::Value = serde_json::from_str(&call.input).unwrap_or(serde_json::Value::Null);
245 let title = v.get("title").and_then(serde_json::Value::as_str).unwrap_or("");
246 let message = v.get("message").and_then(serde_json::Value::as_str).unwrap_or("");
247 let prompt = if title.is_empty() { message.to_string() } else { format!("{title}\n\n{message}") };
248 let ok = web_sys::window()
249 .and_then(|w| w.confirm_with_message(&prompt).ok())
250 .unwrap_or(false);
251 return PluginResponse { ok, output: if ok { "ok".into() } else { "cancel".into() } };
252 }
253 if call.plugin != "http" {
254 return PluginResponse { ok: false, output: format!("plugin '{}' not available", call.plugin) };
255 }
256 let v: serde_json::Value = serde_json::from_str(&call.input).unwrap_or(serde_json::Value::Null);
257 let url = v.get("url").and_then(serde_json::Value::as_str).unwrap_or("");
258 let body = v.get("body").and_then(serde_json::Value::as_str);
259
260 use gloo_net::http::Request;
261 let builder = match call.op.as_str() {
262 "POST" => Request::post(url),
263 "PATCH" => Request::patch(url),
264 "DELETE" => Request::delete(url),
265 _ => Request::get(url),
266 };
267 let request = match body {
268 Some(b) => builder.header("Content-Type", "application/json").body(b),
269 None => builder.build(),
270 };
271 let request = match request {
272 Ok(r) => r,
273 Err(e) => return PluginResponse { ok: false, output: e.to_string() },
274 };
275 match request.send().await {
276 Ok(resp) => PluginResponse { ok: resp.ok(), output: resp.text().await.unwrap_or_default() },
277 Err(e) => PluginResponse { ok: false, output: e.to_string() },
278 }
279}
280
281async fn take_image(capture: bool) -> PluginResponse {
288 use wasm_bindgen::{closure::Closure, JsCast};
289 let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
290 return PluginResponse { ok: false, output: "no document".into() };
291 };
292 let Some(input) = doc.create_element("input").ok().and_then(|e| e.dyn_into::<web_sys::HtmlInputElement>().ok()) else {
293 return PluginResponse { ok: false, output: "no input element".into() };
294 };
295 input.set_type("file");
296 input.set_accept("image/*");
297 if capture {
298 let _ = input.set_attribute("capture", "environment");
300 }
301
302 let (tx, rx) = futures_channel::oneshot::channel::<Option<String>>();
303 let tx = std::cell::RefCell::new(Some(tx));
304 let input_for_cb = input.clone();
305 let on_change = Closure::wrap(Box::new(move || {
306 let url = input_for_cb
307 .files()
308 .and_then(|files| files.get(0))
309 .and_then(|file| web_sys::Url::create_object_url_with_blob(&file).ok());
310 if let Some(tx) = tx.borrow_mut().take() {
311 let _ = tx.send(url);
312 }
313 }) as Box<dyn FnMut()>);
314 input.set_onchange(Some(on_change.as_ref().unchecked_ref()));
315 input.click();
316 on_change.forget(); match rx.await {
319 Ok(Some(url)) => PluginResponse { ok: true, output: url },
320 _ => PluginResponse { ok: false, output: "cancelled".into() },
321 }
322}
323
324async fn take_datetime(kind: &str) -> PluginResponse {
329 use wasm_bindgen::{closure::Closure, JsCast};
330 let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
331 return PluginResponse { ok: false, output: "no document".into() };
332 };
333 let Some(input) = doc.create_element("input").ok().and_then(|e| e.dyn_into::<web_sys::HtmlInputElement>().ok()) else {
334 return PluginResponse { ok: false, output: "no input element".into() };
335 };
336 input.set_type(kind); let _ = input.set_attribute("style", "position:fixed;left:-9999px;opacity:0");
339 if let Some(body) = doc.body() {
340 let _ = body.append_child(&input);
341 }
342
343 let (tx, rx) = futures_channel::oneshot::channel::<Option<String>>();
344 let tx = std::rc::Rc::new(std::cell::RefCell::new(Some(tx)));
345 let input_for_change = input.clone();
346 let tx_change = tx.clone();
347 let on_change = Closure::wrap(Box::new(move || {
348 let v = input_for_change.value();
349 if let Some(tx) = tx_change.borrow_mut().take() {
350 let _ = tx.send(if v.is_empty() { None } else { Some(v) });
351 }
352 }) as Box<dyn FnMut()>);
353 let tx_cancel = tx.clone();
354 let on_cancel = Closure::wrap(Box::new(move || {
355 if let Some(tx) = tx_cancel.borrow_mut().take() {
356 let _ = tx.send(None);
357 }
358 }) as Box<dyn FnMut()>);
359 let _ = input.add_event_listener_with_callback("change", on_change.as_ref().unchecked_ref());
360 let _ = input.add_event_listener_with_callback("cancel", on_cancel.as_ref().unchecked_ref());
361 if input.show_picker().is_err() {
362 input.click(); }
364 on_change.forget(); on_cancel.forget();
366
367 let result = rx.await;
368 input.remove();
369 match result {
370 Ok(Some(v)) => PluginResponse { ok: true, output: v },
371 _ => PluginResponse { ok: false, output: "cancelled".into() },
372 }
373}
374
375const STORAGE_KEY: &str = "mobiler.state";
376
377fn local_storage() -> Option<web_sys::Storage> {
379 web_sys::window()?.local_storage().ok().flatten()
380}
381
382fn perform_notify(notify: &PluginNotify) {
386 let win = match web_sys::window() {
387 Some(w) => w,
388 None => return,
389 };
390 match (notify.plugin.as_str(), notify.op.as_str()) {
391 ("storage", "save") => {
393 if let Some(s) = local_storage() {
394 let _ = s.set_item(STORAGE_KEY, ¬ify.input);
395 }
396 }
397 ("clipboard", "copy") => {
399 let _ = win.navigator().clipboard().write_text(¬ify.input);
400 }
401 ("browser", "open") => {
403 let _ = win.open_with_url_and_target(¬ify.input, "_blank");
404 }
405 ("share", _) => {
408 let _ = win.navigator().clipboard().write_text(¬ify.input);
409 }
410 ("stream", "unsubscribe") => {
414 if let Some(StreamHandle::Ws(ws)) = STREAMS.with(|m| m.borrow_mut().remove(¬ify.input)) {
417 let _ = ws.ws.close();
418 }
419 }
420 ("toast", _) => show_toast(¬ify.input),
422 ("haptics", style) => {
424 let ms = match style {
425 "light" => 15,
426 "heavy" => 50,
427 _ => 30, };
429 let _ = win.navigator().vibrate_with_duration(ms);
430 }
431 _ => {} }
433}
434
435fn show_toast(text: &str) {
438 let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return };
439 let (Ok(el), Some(body)) = (doc.create_element("div"), doc.body()) else { return };
440 el.set_class_name("toast");
441 el.set_text_content(Some(text));
442 let _ = body.append_child(&el);
443 gloo_timers::callback::Timeout::new(2600, move || el.remove()).forget();
444}
445
446fn render(widget: &Widget, send: &Dispatch) -> AnyView {
453 match widget {
454 Widget::Text { content, style } => {
456 let (class, content) = (text_class(*style), content.clone());
457 view! { <p class=class>{content}</p> }.into_any()
458 }
459 Widget::Image { source, shape, ratio } => {
460 let (class, source) = (image_class(*shape, *ratio), source.clone());
461 view! { <img class=class src=source /> }.into_any()
462 }
463 Widget::Badge { label, tone } => {
464 let (class, label) = (format!("badge {}", tone_class(*tone)), label.clone());
465 view! { <span class=class>{label}</span> }.into_any()
466 }
467 Widget::ColorDot { color } => {
468 view! { <span class=format!("dot {}", dot_class(*color))></span> }.into_any()
469 }
470 Widget::Avatar { source, status } => {
471 let dot = status.map(|t| view! { <span class=format!("avatar-status {}", tone_class(t))></span> });
472 view! {
473 <span class="avatar">
474 <img class="avatar-img" src=source.clone() />
475 {dot}
476 </span>
477 }
478 .into_any()
479 }
480 Widget::PdfView { url } => {
481 view! { <iframe class="pdfview" src=url.clone() title="PDF"></iframe> }.into_any()
483 }
484 Widget::WebView { url } => {
485 view! {
488 <iframe
489 class="webview"
490 src=url.clone()
491 title="Web"
492 allow="autoplay; fullscreen; picture-in-picture; encrypted-media"
493 allowfullscreen=true
494 ></iframe>
495 }.into_any()
496 }
497 Widget::Video { url, playing, controls, looping, muted, on_ended, .. } => {
498 let (send, ended) = (send.clone(), on_ended.clone());
504 let autoplay = *playing && *muted;
505 view! {
506 <video
507 class="video"
508 src=url.clone()
509 controls=*controls
510 autoplay=autoplay
511 prop:loop=*looping
512 muted=*muted
513 playsinline=true
514 on:ended=move |_| { if let Some(t) = ended.clone() { send(Action::Fired { token: t }); } }
515 ></video>
516 }.into_any()
517 }
518 Widget::Rating { value, max, on_rate } => {
519 let value = *value;
520 let stars: Vec<AnyView> = (1..=*max)
521 .map(|i| {
522 let threshold = u32::from(i) * 10;
523 let glyph = if value >= threshold { "★" } else if value + 5 >= threshold { "⯨" } else { "☆" };
525 match on_rate {
526 Some(tokens) => {
527 let (send, token) = (send.clone(), tokens.get(usize::from(i - 1)).cloned().unwrap_or_default());
528 view! {
529 <button class="star star-tappable" on:click=move |_| send(Action::Fired { token: token.clone() })>
530 {glyph}
531 </button>
532 }
533 .into_any()
534 }
535 None => view! { <span class="star">{glyph}</span> }.into_any(),
536 }
537 })
538 .collect();
539 view! { <span class="rating">{stars}</span> }.into_any()
540 }
541 Widget::Divider => view! { <hr class="divider" /> }.into_any(),
542 Widget::Progress { value } => match value {
543 Some(v) => {
544 let pct = (v.clamp(0.0, 1.0) * 100.0) as u32;
545 view! { <div class="progress"><div class="progress-bar" style=format!("width:{pct}%")></div></div> }.into_any()
546 }
547 None => view! { <div class="progress progress-indeterminate"><div class="progress-bar"></div></div> }.into_any(),
548 },
549 Widget::Skeleton => view! { <div class="skeleton"></div> }.into_any(),
550 Widget::Chart { series, labels, style, axis, legend } => {
551 chart_view(series, labels, *style, *axis, *legend)
552 }
553 Widget::RegionChart { regions, ticks, x_max, y_max, ref_lines, bracket, legend } => {
554 region_chart_view(regions, ticks, *x_max, *y_max, ref_lines, bracket, legend)
555 }
556 Widget::Calendar { year, month, first_weekday, selected, on_day } => {
557 const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June",
558 "July", "August", "September", "October", "November", "December"];
559 let head_label = format!("{} {year}", MONTHS.get((*month as usize).saturating_sub(1)).copied().unwrap_or(""));
560 let weekdays = ["S", "M", "T", "W", "T", "F", "S"];
561 let heads: Vec<_> = weekdays.iter().map(|w| view! { <div class="cal-head">{*w}</div> }).collect();
562 let blanks: Vec<_> = (0..*first_weekday).map(|_| view! { <div class="cal-blank"></div> }).collect();
563 let selected = *selected;
564 let days: Vec<_> = on_day.iter().enumerate().map(|(i, token)| {
565 let day = (i + 1) as u8;
566 let token = token.clone();
567 let send = send.clone();
568 let cls = if selected == Some(day) { "cal-day cal-sel" } else { "cal-day" };
569 view! { <button class=cls on:click=move |_| send(Action::Fired { token: token.clone() })>{day.to_string()}</button> }
570 }).collect();
571 view! {
572 <div class="calendar">
573 <div class="cal-title">{head_label}</div>
574 <div class="cal-grid">{heads}{blanks}{days}</div>
575 </div>
576 }.into_any()
577 }
578 Widget::SwipeAction { child, actions } => {
579 let acts: Vec<_> = actions.iter().map(|a| {
581 let token = a.on_tap.clone();
582 let send = send.clone();
583 let cls = format!("swipe-act {}", tone_class(a.tone));
584 let label = a.label.clone();
585 view! { <button class=cls on:click=move |_| send(Action::Fired { token: token.clone() })>{label}</button> }
586 }).collect();
587 view! {
588 <div class="swipe-row">
589 <div class="swipe-content">{render(child, send)}</div>
590 <div class="swipe-actions">{acts}</div>
591 </div>
592 }.into_any()
593 }
594 Widget::Spacer { size } => {
595 view! { <div class=format!("spacer {}", spacer_class(*size))></div> }.into_any()
596 }
597
598 Widget::Row { children } => {
600 let kids = render_all(children, send);
601 view! { <div class="row">{kids}</div> }.into_any()
602 }
603 Widget::Column { children } => {
604 let kids = render_all(children, send);
605 view! { <div class="col">{kids}</div> }.into_any()
606 }
607 Widget::Card { child, style, on_press } => {
608 let class = format!("card {}", card_class(*style));
609 let body = render(child, send);
610 match on_press {
611 Some(token) => {
612 let (send, token) = (send.clone(), token.clone());
613 view! {
614 <button
615 class=format!("{class} card-tappable")
616 on:click=move |_| send(Action::Fired { token: token.clone() })
617 >
618 {body}
619 </button>
620 }
621 .into_any()
622 }
623 None => view! { <div class=class>{body}</div> }.into_any(),
624 }
625 }
626 Widget::Box { children, align, scrim } => {
630 let acls = align_class(*align);
631 if *scrim && children.len() > 1 {
632 let bg = render(&children[0], send);
633 let content = render_all(&children[1..], send);
634 view! {
635 <div class=format!("box box-scrim {acls}")>
636 {bg}
637 <div class="scrim"></div>
638 <div class="box-content">{content}</div>
639 </div>
640 }
641 .into_any()
642 } else {
643 let kids = render_all(children, send);
644 view! { <div class=format!("box {acls}")>{kids}</div> }.into_any()
645 }
646 }
647 Widget::Grid { children } => {
648 let kids = render_all(children, send);
649 view! { <div class="grid">{kids}</div> }.into_any()
650 }
651 Widget::Scroller { children } => {
652 let kids = render_all(children, send);
653 view! { <div class="scroller">{kids}</div> }.into_any()
654 }
655 Widget::LazyList { children, on_load_more, loading, has_more, on_refresh, refreshing } => {
660 let kids = render_all(children, send);
661 let refresh_btn = on_refresh.clone().map(|token| {
662 let send = send.clone();
663 view! { <button class="refresh-btn" on:click=move |_| send(Action::Fired { token: token.clone() })>"↻ Refresh"</button> }
664 });
665 let refresh_bar = refreshing.then(|| view! { <div class="progress progress-indeterminate"><div class="progress-bar"></div></div> });
666 let loading_bar = loading.then(|| view! { <div class="progress progress-indeterminate"><div class="progress-bar"></div></div> });
667 let load_more_btn = (!*loading && *has_more)
668 .then(|| on_load_more.clone())
669 .flatten()
670 .map(|token| {
671 let send = send.clone();
672 view! { <button class="btn btn-outlined lazylist-more" on:click=move |_| send(Action::Fired { token: token.clone() })>"Load more"</button> }
673 });
674 let end_cap = (!*has_more && on_load_more.is_some()).then(|| view! { <div class="lazylist-end">"End of list"</div> });
675 view! {
676 <div class="lazylist">
677 {refresh_btn}
678 {refresh_bar}
679 {kids}
680 {loading_bar}
681 {load_more_btn}
682 {end_cap}
683 </div>
684 }.into_any()
685 }
686
687 Widget::Button { label, style, on_press } => {
689 let (send, token, label) = (send.clone(), on_press.clone(), label.clone());
690 let class = format!("btn {}", button_class(*style));
691 view! {
692 <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
693 {label}
694 </button>
695 }
696 .into_any()
697 }
698 Widget::IconButton { icon, on_press } => {
699 let (send, token) = (send.clone(), on_press.clone());
700 let glyph = icon_glyph(*icon);
701 view! {
702 <button class="iconbtn" on:click=move |_| send(Action::Fired { token: token.clone() })>
703 {glyph}
704 </button>
705 }
706 .into_any()
707 }
708 Widget::Chip { label, selected, on_press } => {
709 let (send, token, label) = (send.clone(), on_press.clone(), label.clone());
710 let class = if *selected { "chip selected" } else { "chip" };
711 view! {
712 <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
713 {label}
714 </button>
715 }
716 .into_any()
717 }
718 Widget::TextField { id, placeholder, value, kind, error } => {
719 let (send, id) = (send.clone(), id.clone());
720 let (placeholder, value) = (placeholder.clone(), value.clone());
721 let invalid = error.is_some();
722 let err_view = error.clone().map(|m| view! { <div class="field-error">{m}</div> });
723 let (itype, imode): (&str, &str) = match kind {
725 FieldKind::Secure => ("password", ""),
726 FieldKind::Email => ("email", "email"),
727 FieldKind::Number => ("text", "numeric"),
728 FieldKind::Decimal => ("text", "decimal"),
729 FieldKind::Phone => ("tel", "tel"),
730 FieldKind::Url => ("url", "url"),
731 FieldKind::Text | FieldKind::Multiline => ("text", ""),
732 };
733 let field_class = if invalid { "field field-invalid" } else { "field" };
734 let control = if matches!(kind, FieldKind::Multiline) {
735 view! {
736 <textarea
737 class=field_class
738 rows="3"
739 placeholder=placeholder
740 prop:value=value
741 on:input=move |ev| send(Action::Input {
742 id: id.clone(),
743 value: InputValue::Text(event_target_value(&ev)),
744 })
745 ></textarea>
746 }
747 .into_any()
748 } else {
749 view! {
750 <input
751 class=field_class
752 r#type=itype
753 inputmode=imode
754 placeholder=placeholder
755 prop:value=value
756 on:input=move |ev| send(Action::Input {
757 id: id.clone(),
758 value: InputValue::Text(event_target_value(&ev)),
759 })
760 />
761 }
762 .into_any()
763 };
764 view! { <div class="field-wrap">{control}{err_view}</div> }.into_any()
765 }
766 Widget::SearchField { id, placeholder, value } => {
767 let (send, id) = (send.clone(), id.clone());
768 let (placeholder, value) = (placeholder.clone(), value.clone());
769 view! {
770 <div class="searchfield">
771 <span class="search-icon">{icon_glyph(Icon::Search)}</span>
772 <input
773 class="search-input"
774 placeholder=placeholder
775 prop:value=value
776 on:input=move |ev| send(Action::Input {
777 id: id.clone(),
778 value: InputValue::Text(event_target_value(&ev)),
779 })
780 />
781 </div>
782 }
783 .into_any()
784 }
785 Widget::Segmented { segments } => {
786 let segs: Vec<AnyView> = segments
787 .iter()
788 .map(|s| {
789 let (send, token) = (send.clone(), s.on_select.clone());
790 let class = if s.selected { "segment selected" } else { "segment" };
791 let label = s.label.clone();
792 view! {
793 <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
794 {label}
795 </button>
796 }
797 .into_any()
798 })
799 .collect();
800 view! { <div class="segmented">{segs}</div> }.into_any()
801 }
802 Widget::Toggle { id, label, value } => {
803 let (send, id, label, checked) = (send.clone(), id.clone(), label.clone(), *value);
804 view! {
805 <label class="toggle">
806 {label}
807 <input
808 type="checkbox"
809 role="switch"
810 prop:checked=checked
811 on:change=move |ev| send(Action::Input {
812 id: id.clone(),
813 value: InputValue::Bool(event_target_checked(&ev)),
814 })
815 />
816 </label>
817 }
818 .into_any()
819 }
820 Widget::Checkbox { id, label, value } => {
821 let (send, id, label, checked) = (send.clone(), id.clone(), label.clone(), *value);
822 view! {
823 <label class="check">
824 <input
825 type="checkbox"
826 prop:checked=checked
827 on:change=move |ev| send(Action::Input {
828 id: id.clone(),
829 value: InputValue::Bool(event_target_checked(&ev)),
830 })
831 />
832 {label}
833 </label>
834 }
835 .into_any()
836 }
837 Widget::Slider { id, value, max } => {
838 let (send, id, value, max) = (send.clone(), id.clone(), *value, *max);
839 view! {
840 <input
841 class="slider"
842 type="range"
843 min="0"
844 max=max
845 prop:value=value
846 on:input=move |ev| send(Action::Input {
847 id: id.clone(),
848 value: InputValue::Int(event_target_value(&ev).parse().unwrap_or(0)),
849 })
850 />
851 }
852 .into_any()
853 }
854 Widget::Stepper { value, on_decrement, on_increment } => {
855 let send_dec = send.clone();
856 let send_inc = send.clone();
857 let (dec, inc) = (on_decrement.clone(), on_increment.clone());
858 view! {
859 <div class="stepper">
860 <button on:click=move |_| send_dec(Action::Fired { token: dec.clone() })>"−"</button>
861 <span class="stepper-value">{*value}</span>
862 <button on:click=move |_| send_inc(Action::Fired { token: inc.clone() })>"+"</button>
863 </div>
864 }
865 .into_any()
866 }
867
868 Widget::Scaffold { title, body, tabs, back, dark_mode, theme, fab, sheet, on_refresh, refreshing, route, depth } => {
870 let back_btn = back.clone().map(|token| {
871 let send = send.clone();
872 view! {
873 <button class="back" on:click=move |_| send(Action::Fired { token: token.clone() })>
874 "‹"
875 </button>
876 }
877 });
878 let tabbar = (!tabs.is_empty()).then(|| {
879 let tabs: Vec<AnyView> = tabs
880 .iter()
881 .map(|tab| {
882 let (send, token) = (send.clone(), tab.on_select.clone());
883 let class = if tab.selected { "tab selected" } else { "tab" };
884 let label = tab.label.clone();
885 let icon = tab.icon.map(|i| view! { <span class="tab-icon">{icon_glyph(i)}</span> });
887 view! {
888 <button class=class on:click=move |_| send(Action::Fired { token: token.clone() })>
889 {icon}
890 <span class="tab-label">{label}</span>
891 </button>
892 }
893 .into_any()
894 })
895 .collect();
896 view! { <div class="tabbar">{tabs}</div> }
897 });
898 let fab_btn = fab.clone().map(|f| {
900 let (send, token) = (send.clone(), f.on_press.clone());
901 view! {
902 <button class="fab" on:click=move |_| send(Action::Fired { token: token.clone() })>
903 {icon_glyph(f.icon)}
904 </button>
905 }
906 });
907 let sheet_overlay = sheet.as_ref().map(|s| {
909 let (send_scrim, dismiss) = (send.clone(), s.on_dismiss.clone());
910 let (title, child) = (s.title.clone(), render(&s.child, send));
911 view! {
912 <div class="sheet-scrim" on:click=move |_| send_scrim(Action::Fired { token: dismiss.clone() })></div>
913 <div class="sheet">
914 <div class="sheet-handle"></div>
915 <div class="sheet-title">{title}</div>
916 {child}
917 </div>
918 }
919 });
920 let class = if *dark_mode { "scaffold theme-dark" } else { "scaffold" };
923 let refresh_btn = on_refresh.clone().map(|token| {
926 let send = send.clone();
927 view! {
928 <button class="refresh-btn" on:click=move |_| send(Action::Fired { token: token.clone() })>"↻"</button>
929 }
930 });
931 let refresh_bar = refreshing.then(|| {
932 view! { <div class="progress progress-indeterminate"><div class="progress-bar"></div></div> }
933 });
934 let body_class = format!("scaffold-body {}", nav_class(route, *depth));
935 let theme_style = theme.as_ref().map(theme_css).unwrap_or_default();
938 let (title, body) = (title.clone(), render(body, send));
939 view! {
940 <div class=class style=theme_style>
941 <div class="topbar">
942 {back_btn}
943 <span class="title">{title}</span>
944 {refresh_btn}
945 </div>
946 <div class=body_class data-route=route.clone()>{refresh_bar}{body}</div>
947 {fab_btn}
948 {tabbar}
949 {sheet_overlay}
950 </div>
951 }
952 .into_any()
953 }
954 }
955}
956
957fn render_all(children: &[Widget], send: &Dispatch) -> Vec<AnyView> {
959 children.iter().map(|c| render(c, send)).collect()
960}
961
962thread_local! {
963 static NAV: RefCell<(String, u32, bool)> = const { RefCell::new((String::new(), 0, false)) };
968
969 static STREAMS: RefCell<HashMap<String, StreamHandle>> = RefCell::new(HashMap::new());
973}
974
975fn theme_css(t: &Theme) -> String {
980 let (r, g, b) = (t.seed.r, t.seed.g, t.seed.b);
981 let radius = match t.corner {
982 Corner::None => "0px",
983 Corner::Small => "8px",
984 Corner::Medium => "14px",
985 Corner::Large => "22px",
986 };
987 let (gap, pad) = match t.density {
988 Density::Compact => ("8px", "10px"),
989 Density::Comfortable => ("12px", "14px"),
990 };
991 let font = match t.font {
992 FontFamily::System => "system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif",
993 FontFamily::Rounded => "ui-rounded, \"SF Pro Rounded\", \"Segoe UI\", system-ui, sans-serif",
994 FontFamily::Serif => "ui-serif, Georgia, \"Times New Roman\", serif",
995 FontFamily::Monospace => "ui-monospace, \"SF Mono\", \"Cascadia Code\", Menlo, monospace",
996 };
997 let (ar, ag, ab) = t.accent.map_or((r, g, b), |a| (a.r, a.g, a.b));
999 format!(
1000 "--primary:rgb({r},{g},{b});--accent:rgb({r},{g},{b});\
1001 --accent2:rgb({ar},{ag},{ab});\
1002 --accent-soft:rgba({r},{g},{b},0.16);--radius:{radius};\
1003 --gap:{gap};--pad:{pad};--font:{font};"
1004 )
1005}
1006
1007fn nav_class(route: &str, depth: u32) -> &'static str {
1014 NAV.with_borrow_mut(|(prev_route, prev_depth, toggle)| {
1015 if route == prev_route {
1016 return "";
1017 }
1018 let dir = if depth > *prev_depth {
1019 ["nav-push-a", "nav-push-b"]
1020 } else if depth < *prev_depth {
1021 ["nav-pop-a", "nav-pop-b"]
1022 } else {
1023 ["nav-fade-a", "nav-fade-b"]
1024 };
1025 *toggle = !*toggle;
1026 *prev_route = route.to_string();
1027 *prev_depth = depth;
1028 dir[usize::from(*toggle)]
1029 })
1030}
1031
1032fn text_class(s: TextStyle) -> &'static str {
1035 match s {
1036 TextStyle::Title => "t-title",
1037 TextStyle::Subtitle => "t-subtitle",
1038 TextStyle::Caption => "t-caption",
1039 TextStyle::Emphasis => "t-emphasis",
1040 TextStyle::Body => "t-body",
1041 }
1042}
1043
1044fn button_class(s: ButtonStyle) -> &'static str {
1045 match s {
1046 ButtonStyle::Filled => "btn-filled",
1047 ButtonStyle::Outlined => "btn-outlined",
1048 ButtonStyle::Text => "btn-text",
1049 }
1050}
1051
1052fn card_class(s: CardStyle) -> &'static str {
1053 match s {
1054 CardStyle::Elevated => "card-elevated",
1055 CardStyle::Outlined => "card-outlined",
1056 CardStyle::Filled => "card-filled",
1057 CardStyle::Brand => "card-brand",
1058 }
1059}
1060
1061fn tone_class(t: Tone) -> &'static str {
1062 match t {
1063 Tone::Neutral => "tone-neutral",
1064 Tone::Success => "tone-success",
1065 Tone::Warning => "tone-warning",
1066 Tone::Danger => "tone-danger",
1067 Tone::Info => "tone-info",
1068 }
1069}
1070
1071fn spacer_class(s: Spacing) -> &'static str {
1072 match s {
1073 Spacing::Xs => "sp-xs",
1074 Spacing::Sm => "sp-sm",
1075 Spacing::Md => "sp-md",
1076 Spacing::Lg => "sp-lg",
1077 Spacing::Xl => "sp-xl",
1078 }
1079}
1080
1081fn icon_glyph(i: Icon) -> &'static str {
1082 match i {
1083 Icon::Delete => "🗑",
1084 Icon::Add => "+",
1085 Icon::Edit => "✏️",
1086 Icon::Close => "✕",
1087 Icon::Settings => "⚙",
1088 Icon::Check => "✓",
1089 Icon::Star => "★",
1090 Icon::Info => "ℹ",
1091 Icon::Home => "⌂",
1092 Icon::Search => "🔍",
1093 Icon::Menu => "☰",
1094 Icon::Filter => "⚟",
1095 Icon::Back => "‹",
1096 Icon::Forward => "›",
1097 Icon::Down => "⌄",
1098 Icon::Bell => "🔔",
1099 Icon::Cart => "🛒",
1100 Icon::Share => "↗",
1101 Icon::Heart => "♡",
1102 Icon::HeartFilled => "♥",
1103 Icon::Person => "👤",
1104 Icon::People => "👥",
1105 Icon::Phone => "📞",
1106 Icon::Mail => "✉",
1107 Icon::Calendar => "📅",
1108 Icon::Clock => "🕑",
1109 Icon::MapPin => "📍",
1110 Icon::Camera => "📷",
1111 Icon::Photo => "🖼",
1112 Icon::Play => "▶",
1113 Icon::Scissors => "✂",
1114 }
1115}
1116
1117fn image_class(shape: ImageShape, ratio: ImageRatio) -> String {
1118 let shape = match shape {
1119 ImageShape::Square => "img-square",
1120 ImageShape::Rounded => "img-rounded",
1121 ImageShape::Circle => "img-circle",
1122 };
1123 let ratio = match ratio {
1124 ImageRatio::Wide => "ratio-wide",
1125 ImageRatio::Square => "ratio-square",
1126 ImageRatio::Tall => "ratio-tall",
1127 };
1128 format!("img {shape} {ratio}")
1129}
1130
1131fn dot_class(c: ProjectColor) -> &'static str {
1132 match c {
1133 ProjectColor::Indigo => "dot-indigo",
1134 ProjectColor::Teal => "dot-teal",
1135 ProjectColor::Coral => "dot-coral",
1136 ProjectColor::Amber => "dot-amber",
1137 ProjectColor::Lime => "dot-lime",
1138 ProjectColor::Pink => "dot-pink",
1139 }
1140}
1141
1142fn align_class(a: BoxAlign) -> &'static str {
1143 match a {
1144 BoxAlign::TopStart => "align-top-start",
1145 BoxAlign::TopEnd => "align-top-end",
1146 BoxAlign::Center => "align-center",
1147 BoxAlign::BottomStart => "align-bottom-start",
1148 BoxAlign::BottomCenter => "align-bottom-center",
1149 BoxAlign::BottomEnd => "align-bottom-end",
1150 }
1151}
1152
1153const CHART_PALETTE: [&str; 6] = ["#E0772C", "#2EA06A", "#C0466B", "#8A5CC0", "#C9A227", "#3FA7D6"];
1157
1158fn hex(c: Rgb) -> String {
1159 format!("#{:02x}{:02x}{:02x}", c.r, c.g, c.b)
1160}
1161
1162fn chart_color(i: usize, s: &ChartSeries) -> String {
1164 match s.color {
1165 Some(c) => hex(c),
1166 None if i == 0 => "var(--accent, #5C6BC0)".to_string(),
1167 None => CHART_PALETTE[(i - 1) % CHART_PALETTE.len()].to_string(),
1168 }
1169}
1170
1171fn chart_mag(s: &ChartSeries) -> f32 {
1173 s.values.iter().copied().sum()
1174}
1175
1176fn polar(cx: f32, cy: f32, r: f32, ang: f32) -> (f32, f32) {
1178 (cx + r * ang.sin(), cy - r * ang.cos())
1179}
1180
1181fn arc_path(cx: f32, cy: f32, r: f32, a0: f32, a1: f32) -> String {
1183 let (x0, y0) = polar(cx, cy, r, a0);
1184 let (x1, y1) = polar(cx, cy, r, a1);
1185 let large = if (a1 - a0).abs() > std::f32::consts::PI { 1 } else { 0 };
1186 format!("M {x0:.2} {y0:.2} A {r:.2} {r:.2} 0 {large} 1 {x1:.2} {y1:.2}")
1187}
1188
1189fn wedge_path(cx: f32, cy: f32, r: f32, a0: f32, a1: f32) -> String {
1191 let (x0, y0) = polar(cx, cy, r, a0);
1192 let (x1, y1) = polar(cx, cy, r, a1);
1193 let large = if (a1 - a0).abs() > std::f32::consts::PI { 1 } else { 0 };
1194 format!("M {cx:.2} {cy:.2} L {x0:.2} {y0:.2} A {r:.2} {r:.2} 0 {large} 1 {x1:.2} {y1:.2} Z")
1195}
1196
1197fn fmt_tick(v: f32) -> String {
1198 if (v - v.round()).abs() < 0.05 { format!("{}", v.round() as i64) } else { format!("{v:.1}") }
1199}
1200
1201fn is_cartesian(style: ChartStyle) -> bool {
1202 matches!(style, ChartStyle::Bar | ChartStyle::Line | ChartStyle::StackedBar | ChartStyle::StackedBar100)
1203}
1204
1205fn cartesian_max(series: &[ChartSeries], style: ChartStyle, nslots: usize) -> f32 {
1207 match style {
1208 ChartStyle::StackedBar => (0..nslots)
1209 .map(|j| series.iter().map(|s| *s.values.get(j).unwrap_or(&0.0)).sum::<f32>())
1210 .fold(0.0, f32::max)
1211 .max(1e-6),
1212 ChartStyle::StackedBar100 => 1.0,
1213 _ => series.iter().flat_map(|s| s.values.iter().copied()).fold(0.0, f32::max).max(1e-6),
1214 }
1215}
1216
1217fn cartesian_svg(series: &[ChartSeries], style: ChartStyle, axis: bool, max: f32, nslots: usize) -> AnyView {
1218 let mut nodes: Vec<AnyView> = Vec::new();
1220 if axis {
1221 for k in 0..=4 {
1222 let y = 2.0 + k as f32 * (46.0 / 4.0);
1223 nodes.push(view! { <line x1="0" y1=format!("{y:.2}") x2="100" y2=format!("{y:.2}") class="chart-gridline"></line> }.into_any());
1224 }
1225 }
1226 match style {
1227 ChartStyle::Line => {
1228 for (i, s) in series.iter().enumerate() {
1229 let n = s.values.len().max(1);
1230 let pts = s.values.iter().enumerate().map(|(j, v)| {
1231 let x = if n == 1 { 50.0 } else { j as f32 * (100.0 / (n as f32 - 1.0)) };
1232 let y = 2.0 + (1.0 - (v / max).clamp(0.0, 1.0)) * 46.0;
1233 format!("{x:.2},{y:.2}")
1234 }).collect::<Vec<_>>().join(" ");
1235 let st = format!("fill:none;stroke:{};stroke-width:1.5;vector-effect:non-scaling-stroke", chart_color(i, s));
1236 nodes.push(view! { <polyline points=pts style=st></polyline> }.into_any());
1237 }
1238 }
1239 ChartStyle::Bar => {
1240 let sw = 100.0 / nslots as f32;
1241 let ns = series.len().max(1);
1242 for (i, s) in series.iter().enumerate() {
1243 let st = format!("fill:{}", chart_color(i, s));
1244 for (j, v) in s.values.iter().enumerate() {
1245 let h = (v / max).clamp(0.0, 1.0) * 46.0;
1246 let bw = sw * 0.8 / ns as f32;
1247 let x = j as f32 * sw + sw * 0.1 + i as f32 * bw;
1248 let y = 48.0 - h;
1249 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());
1250 }
1251 }
1252 }
1253 ChartStyle::StackedBar | ChartStyle::StackedBar100 => {
1254 let sw = 100.0 / nslots as f32;
1255 for j in 0..nslots {
1256 let slot_total = series.iter().map(|s| *s.values.get(j).unwrap_or(&0.0)).sum::<f32>().max(1e-6);
1257 let denom = if matches!(style, ChartStyle::StackedBar100) { slot_total } else { max };
1258 let mut acc = 0.0_f32;
1259 for (i, s) in series.iter().enumerate() {
1260 let v = *s.values.get(j).unwrap_or(&0.0);
1261 let h = (v / denom).clamp(0.0, 1.0) * 46.0;
1262 let x = j as f32 * sw + sw * 0.15;
1263 let bw = sw * 0.7;
1264 let y = 48.0 - acc - h;
1265 let st = format!("fill:{}", chart_color(i, s));
1266 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());
1267 acc += h;
1268 }
1269 }
1270 }
1271 _ => {}
1272 }
1273 view! { <svg viewBox="0 0 100 50" preserveAspectRatio="none" class="chart-svg">{nodes}</svg> }.into_any()
1274}
1275
1276fn circular_svg(series: &[ChartSeries], style: ChartStyle) -> AnyView {
1277 use std::f32::consts::PI;
1278 let mut nodes: Vec<AnyView> = Vec::new();
1279 match style {
1280 ChartStyle::Pie | ChartStyle::Donut => {
1281 let total = series.iter().map(chart_mag).sum::<f32>().max(1e-6);
1282 let mut a = 0.0_f32;
1283 for (i, s) in series.iter().enumerate() {
1284 let frac = chart_mag(s) / total;
1285 let st = format!("fill:{}", chart_color(i, s));
1286 if frac >= 0.999 {
1287 nodes.push(view! { <circle cx="50" cy="50" r="45" style=st></circle> }.into_any());
1288 } else if frac > 0.0 {
1289 let d = wedge_path(50.0, 50.0, 45.0, a, a + frac * 2.0 * PI);
1290 nodes.push(view! { <path d=d style=st></path> }.into_any());
1291 }
1292 a += frac * 2.0 * PI;
1293 }
1294 if matches!(style, ChartStyle::Donut) {
1295 nodes.push(view! { <circle cx="50" cy="50" r="24" style="fill:var(--surface, #ffffff)"></circle> }.into_any());
1296 }
1297 }
1298 ChartStyle::Rings => {
1299 let n = series.len().max(1);
1300 for (i, s) in series.iter().enumerate() {
1301 let r = 45.0 - i as f32 * (34.0 / n as f32);
1302 let goal = s.goal.unwrap_or_else(|| chart_mag(s)).max(1e-6);
1303 let prog = (chart_mag(s) / goal).clamp(0.0, 1.0);
1304 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());
1305 let st = format!("fill:none;stroke:{};stroke-width:6;stroke-linecap:round", chart_color(i, s));
1306 if prog >= 0.999 {
1307 nodes.push(view! { <circle cx="50" cy="50" r=format!("{r:.2}") style=st></circle> }.into_any());
1308 } else if prog > 0.0 {
1309 let d = arc_path(50.0, 50.0, r, 0.0, prog * 2.0 * PI);
1310 nodes.push(view! { <path d=d style=st></path> }.into_any());
1311 }
1312 }
1313 }
1314 ChartStyle::Gauge => {
1315 let s = match series.first() { Some(s) => s, None => return view! { <svg viewBox="0 0 100 100" class="chart-svg"></svg> }.into_any() };
1316 let goal = s.goal.unwrap_or_else(|| chart_mag(s)).max(1e-6);
1317 let prog = (chart_mag(s) / goal).clamp(0.0, 1.0);
1318 let a0 = -0.75 * PI; let a1 = 0.75 * PI;
1320 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());
1321 if prog > 0.0 {
1322 let st = format!("fill:none;stroke:{};stroke-width:8;stroke-linecap:round", chart_color(0, s));
1323 nodes.push(view! { <path d=arc_path(50.0, 50.0, 42.0, a0, a0 + prog * 1.5 * PI) style=st></path> }.into_any());
1324 }
1325 let pct = format!("{}%", (prog * 100.0).round() as i64);
1326 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());
1327 }
1328 _ => {}
1329 }
1330 view! { <svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet" class="chart-svg">{nodes}</svg> }.into_any()
1331}
1332
1333fn chart_view(series: &[ChartSeries], labels: &[String], style: ChartStyle, axis: bool, legend: bool) -> AnyView {
1334 let cartesian = is_cartesian(style);
1335 let nslots = series.iter().map(|s| s.values.len()).max().unwrap_or(0).max(1);
1336 let max = cartesian_max(series, style, nslots);
1337
1338 let plot = if cartesian {
1339 let svg = cartesian_svg(series, style, axis, max, nslots);
1340 let yaxis = if axis {
1341 let ticks: Vec<_> = [max, max / 2.0, 0.0].iter()
1342 .map(|t| view! { <span class="chart-tick">{fmt_tick(*t)}</span> })
1343 .collect();
1344 Some(view! { <div class="chart-yaxis">{ticks}</div> })
1345 } else {
1346 None
1347 };
1348 view! { <div class="chart-plot">{yaxis}{svg}</div> }.into_any()
1349 } else {
1350 circular_svg(series, style).into_any()
1351 };
1352
1353 let label_row = if cartesian && !labels.is_empty() {
1354 let items: Vec<_> = labels.iter().map(|l| view! { <span class="chart-label">{l.clone()}</span> }).collect();
1355 Some(view! { <div class="chart-labels">{items}</div> })
1356 } else {
1357 None
1358 };
1359
1360 let legend_row = if legend {
1361 let items: Vec<_> = series.iter().enumerate().map(|(i, s)| {
1362 let sw = format!("background:{}", chart_color(i, s));
1363 let name = s.name.clone();
1364 view! { <span class="chart-legend-item"><span class="chart-swatch" style=sw></span>{name}</span> }
1365 }).collect();
1366 Some(view! { <div class="chart-legend">{items}</div> })
1367 } else {
1368 None
1369 };
1370
1371 view! { <div class="chart">{plot}{label_row}{legend_row}</div> }.into_any()
1372}
1373
1374const CHART_PALETTE_RGB: [(u8, u8, u8); 6] =
1378 [(0xE0, 0x77, 0x2C), (0x2E, 0xA0, 0x6A), (0xC0, 0x46, 0x6B), (0x8A, 0x5C, 0xC0), (0xC9, 0xA2, 0x27), (0x3F, 0xA7, 0xD6)];
1379
1380fn region_rgb(i: usize, r: &ChartRegion) -> (u8, u8, u8) {
1382 match r.color {
1383 Some(c) => (c.r, c.g, c.b),
1384 None => CHART_PALETTE_RGB[i % CHART_PALETTE_RGB.len()],
1385 }
1386}
1387
1388fn contrast_text((r, g, b): (u8, u8, u8)) -> &'static str {
1390 let lum = 0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32;
1391 if lum > 140.0 { "#1a1a1a" } else { "#f5f5f5" }
1392}
1393
1394fn region_color(i: usize, r: &ChartRegion) -> String {
1395 let (r8, g8, b8) = region_rgb(i, r);
1396 format!("#{r8:02x}{g8:02x}{b8:02x}")
1397}
1398
1399fn region_chart_view(
1403 regions: &[ChartRegion],
1404 ticks: &[ChartTick],
1405 x_max: f32,
1406 y_max: f32,
1407 ref_lines: &[ChartRefLine],
1408 bracket: &Option<ChartBracket>,
1409 legend: &[ChartLegendItem],
1410) -> AnyView {
1411 let xm = x_max.max(1e-6);
1412 let ym = y_max.max(1e-6);
1413
1414 let region_divs: Vec<_> = regions.iter().enumerate().map(|(i, r)| {
1415 let left = (r.x0 / xm * 100.0).clamp(0.0, 100.0);
1416 let width = ((r.x1 - r.x0) / xm * 100.0).clamp(0.0, 100.0);
1417 let bottom = (r.y0 / ym * 100.0).clamp(0.0, 100.0);
1418 let height = ((r.y1 - r.y0) / ym * 100.0).clamp(0.0, 100.0);
1419 let style = format!("left:{left:.3}%;width:{width:.3}%;bottom:{bottom:.3}%;height:{height:.3}%;background:{}", region_color(i, r));
1420 let label_class = if r.vertical { "rchart-label rchart-label-v" } else { "rchart-label" };
1421 let label_style = format!("color:{}", contrast_text(region_rgb(i, r)));
1422 let label = r.label.clone();
1423 view! { <div class="rchart-region" style=style><span class=label_class style=label_style>{label}</span></div> }
1424 }).collect();
1425
1426 let ref_line_divs: Vec<_> = ref_lines.iter().map(|rl| {
1429 let style = format!("bottom:{:.3}%", (rl.value / ym * 100.0).clamp(0.0, 100.0));
1430 let cls = if rl.dashed { "rchart-refline rchart-refline-dashed" } else { "rchart-refline" };
1431 view! { <div class=cls style=style></div> }
1432 }).collect();
1433 let chip_divs: Vec<_> = ref_lines.iter().map(|rl| {
1434 let style = format!("bottom:{:.3}%", (rl.value / ym * 100.0).clamp(0.0, 100.0));
1435 let label = rl.label.clone();
1436 view! { <div class="rchart-chip" style=style>{label}</div> }
1437 }).collect();
1438
1439 let bracket_div = bracket.as_ref().map(|b| {
1440 let bottom = (b.y0 / ym * 100.0).clamp(0.0, 100.0);
1441 let height = ((b.y1 - b.y0) / ym * 100.0).clamp(0.0, 100.0);
1442 let style = format!("bottom:{bottom:.3}%;height:{height:.3}%");
1443 let label = if b.info { format!("ⓘ\n{}", b.label) } else { b.label.clone() };
1444 view! { <div class="rchart-bracket" style=style><span>{label}</span></div> }
1445 });
1446
1447 let yticks: Vec<_> = (0..=4).rev().map(|k| {
1448 let v = ym * k as f32 / 4.0;
1449 view! { <span class="chart-tick">{fmt_tick(v)}</span> }
1450 }).collect();
1451
1452 let xticks: Vec<_> = ticks.iter().map(|t| {
1453 let style = format!("left:{:.3}%", (t.at / xm * 100.0).clamp(0.0, 100.0));
1454 let label = t.label.clone();
1455 view! { <span class="rchart-xtick" style=style>{label}</span> }
1456 }).collect();
1457
1458 let ytick_marks: Vec<_> = (0..=4).map(|k| {
1461 let style = format!("bottom:{:.3}%", k as f32 * 25.0);
1462 view! { <div class="rchart-ytick" style=style></div> }
1463 }).collect();
1464 let xtick_marks: Vec<_> = ticks.iter().map(|t| {
1465 let style = format!("left:{:.3}%", (t.at / xm * 100.0).clamp(0.0, 100.0));
1466 view! { <div class="rchart-xtickmark" style=style></div> }
1467 }).collect();
1468
1469 let legend_row = if legend.is_empty() {
1470 None
1471 } else {
1472 let items: Vec<_> = legend.iter().map(|l| {
1473 let sw = format!("background:{}", hex(l.color));
1474 let name = l.label.clone();
1475 view! { <span class="chart-legend-item"><span class="chart-swatch" style=sw></span>{name}</span> }
1476 }).collect();
1477 Some(view! { <div class="chart-legend">{items}</div> })
1478 };
1479
1480 view! {
1481 <div class="rchart">
1482 <div class="rchart-row">
1483 <div class="rchart-yaxis">{yticks}</div>
1484 <div class="rchart-plotwrap">
1485 <div class="rchart-plot">{region_divs}{ytick_marks}{xtick_marks}{ref_line_divs}</div>
1486 {chip_divs}{bracket_div}
1487 </div>
1488 </div>
1489 <div class="rchart-xaxis">{xticks}</div>
1490 {legend_row}
1491 </div>
1492 }.into_any()
1493}