1use std::marker::PhantomData;
9
10use crux_core::{
11 App, Command,
12 capability::Operation,
13 macros::effect,
14 render::{RenderOperation, render},
15};
16use facet::Facet;
17use serde::{Deserialize, Serialize, de::DeserializeOwned};
18
19pub use mobiler_ui::{
20 Action, BoxAlign, ButtonStyle, CardStyle, ChartBracket, ChartLegendItem, ChartRefLine, ChartRegion,
21 ChartSeries, ChartStyle, ChartTick, Corner, Density, Fab, FontFamily, Icon,
22 ImageRatio, ImageShape, InputValue, ProjectColor, Rgb, Segment, Sheet, Spacing, SwipeButton, Tab,
23 TextStyle, Theme, Tone, Widget,
24};
25
26#[effect(facet_typegen)]
30#[derive(Debug)]
31pub enum Effect {
32 Render(RenderOperation),
33 PluginNotify(PluginNotify),
35 Plugin(PluginCall),
37}
38
39#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
40pub struct PluginNotify {
41 pub plugin: String,
42 pub op: String,
43 pub input: String,
44}
45impl Operation for PluginNotify {
46 type Output = ();
47}
48
49#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
50pub struct PluginCall {
51 pub plugin: String,
52 pub op: String,
53 pub input: String,
54}
55impl Operation for PluginCall {
56 type Output = PluginResponse;
57}
58
59#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
60pub struct PluginResponse {
61 pub ok: bool,
62 pub output: String,
63}
64
65type Continuation<E> = Box<dyn FnOnce(PluginResponse) -> E + Send>;
66
67pub struct Cx<E> {
70 notifications: Vec<PluginNotify>,
71 requests: Vec<(PluginCall, Continuation<E>)>,
72}
73
74impl<E> Default for Cx<E> {
75 fn default() -> Self {
76 Self { notifications: Vec::new(), requests: Vec::new() }
77 }
78}
79
80impl<E> Cx<E> {
81 pub fn notify(&mut self, plugin: impl Into<String>, op: impl Into<String>, input: impl Into<String>) {
83 self.notifications.push(PluginNotify { plugin: plugin.into(), op: op.into(), input: input.into() });
84 }
85
86 pub fn plugin(
89 &mut self,
90 plugin: impl Into<String>,
91 op: impl Into<String>,
92 input: impl Into<String>,
93 then: impl FnOnce(PluginResponse) -> E + Send + 'static,
94 ) {
95 self.requests
96 .push((PluginCall { plugin: plugin.into(), op: op.into(), input: input.into() }, Box::new(then)));
97 }
98
99 pub fn save(&mut self, data: impl Into<String>) {
101 self.notify("storage", "save", data);
102 }
103
104 pub fn copy(&mut self, text: impl Into<String>) {
106 self.notify("clipboard", "copy", text);
107 }
108
109 pub fn share(&mut self, text: impl Into<String>) {
111 self.notify("share", "text", text);
112 }
113
114 pub fn open_url(&mut self, url: impl Into<String>) {
117 self.notify("browser", "open", url);
118 }
119
120 pub fn toast(&mut self, text: impl Into<String>) {
122 self.notify("toast", "show", text);
123 }
124
125 pub fn haptic(&mut self, style: impl Into<String>) {
128 self.notify("haptics", style, "");
129 }
130
131 pub fn http(
136 &mut self,
137 method: impl Into<String>,
138 url: impl Into<String>,
139 body: Option<String>,
140 then: impl FnOnce(PluginResponse) -> E + Send + 'static,
141 ) {
142 #[derive(Serialize)]
143 struct HttpReq {
144 url: String,
145 body: Option<String>,
146 }
147 let input = serde_json::to_string(&HttpReq { url: url.into(), body })
148 .expect("serialize http request");
149 self.plugin("http", method, input, then);
150 }
151
152 pub fn get(&mut self, url: impl Into<String>, then: impl FnOnce(PluginResponse) -> E + Send + 'static) {
154 self.http("GET", url, None, then);
155 }
156 pub fn post(&mut self, url: impl Into<String>, body: impl Into<String>, then: impl FnOnce(PluginResponse) -> E + Send + 'static) {
158 self.http("POST", url, Some(body.into()), then);
159 }
160 pub fn patch(&mut self, url: impl Into<String>, body: impl Into<String>, then: impl FnOnce(PluginResponse) -> E + Send + 'static) {
162 self.http("PATCH", url, Some(body.into()), then);
163 }
164 pub fn delete(&mut self, url: impl Into<String>, then: impl FnOnce(PluginResponse) -> E + Send + 'static) {
166 self.http("DELETE", url, None, then);
167 }
168
169 pub fn device_model(&mut self, then: impl FnOnce(PluginResponse) -> E + Send + 'static) {
173 self.plugin("device", "model", "", then);
174 }
175
176 pub fn pick_photo(&mut self, then: impl FnOnce(PluginResponse) -> E + Send + 'static) {
181 self.plugin("photo", "pick", "", then);
182 }
183
184 pub fn capture_photo(&mut self, then: impl FnOnce(PluginResponse) -> E + Send + 'static) {
191 self.plugin("camera", "capture", "", then);
192 }
193
194 pub fn confirm(
198 &mut self,
199 title: impl Into<String>,
200 message: impl Into<String>,
201 then: impl FnOnce(PluginResponse) -> E + Send + 'static,
202 ) {
203 #[derive(Serialize)]
204 struct Confirm {
205 title: String,
206 message: String,
207 }
208 let input = serde_json::to_string(&Confirm { title: title.into(), message: message.into() })
209 .expect("serialize confirm");
210 self.plugin("dialog", "confirm", input, then);
211 }
212
213 pub fn pick_date(&mut self, then: impl FnOnce(PluginResponse) -> E + Send + 'static) {
218 self.plugin("datetime", "date", "", then);
219 }
220
221 pub fn pick_time(&mut self, then: impl FnOnce(PluginResponse) -> E + Send + 'static) {
226 self.plugin("datetime", "time", "", then);
227 }
228}
229
230pub trait MobilerApp: Default {
235 type Event: Serialize + DeserializeOwned + Send + 'static;
236 type Model: Default;
237
238 fn update(&self, event: Self::Event, model: &mut Self::Model, cx: &mut Cx<Self::Event>);
239
240 fn input(&self, id: &str, value: InputValue, model: &mut Self::Model, cx: &mut Cx<Self::Event>) {
241 let _ = (id, value, model, cx);
242 }
243
244 fn restore(&self, data: &str, model: &mut Self::Model) {
247 let _ = (data, model);
248 }
249
250 fn init(&self, model: &mut Self::Model, cx: &mut Cx<Self::Event>) {
253 let _ = (model, cx);
254 }
255
256 fn view(&self, model: &Self::Model) -> Widget;
257}
258
259pub struct MobilerShell<A>(PhantomData<fn() -> A>);
261
262impl<A> Default for MobilerShell<A> {
263 fn default() -> Self {
264 Self(PhantomData)
265 }
266}
267
268impl<A: MobilerApp> App for MobilerShell<A> {
269 type Event = Action;
270 type Model = A::Model;
271 type ViewModel = Widget;
272 type Effect = Effect;
273
274 fn update(&self, action: Action, model: &mut Self::Model) -> Command<Effect, Action> {
275 let app = A::default();
276 let mut cx = Cx::<A::Event>::default();
277 match action {
278 Action::Fired { token } => {
279 if let Ok(event) = serde_json::from_str::<A::Event>(&token) {
280 app.update(event, model, &mut cx);
281 }
282 }
283 Action::Input { id, value } => app.input(&id, value, model, &mut cx),
284 Action::Restore { data } => app.restore(&data, model),
285 Action::Start => app.init(model, &mut cx),
286 }
287 let mut commands: Vec<Command<Effect, Action>> = Vec::new();
288 for op in cx.notifications {
289 commands.push(Command::notify_shell(op).build());
290 }
291 for (op, then) in cx.requests {
292 commands.push(Command::request_from_shell(op).then_send(move |response: PluginResponse| {
293 Action::Fired { token: serde_json::to_string(&then(response)).expect("serialize event") }
294 }));
295 }
296 commands.push(render());
297 Command::all(commands)
298 }
299
300 fn view(&self, model: &Self::Model) -> Widget {
301 A::default().view(model)
302 }
303}
304
305#[derive(Clone, Debug)]
324pub struct Nav<R> {
325 stack: Vec<R>,
326}
327
328impl<R: Clone + Serialize> Nav<R> {
329 #[must_use]
331 pub fn new(root: R) -> Self {
332 Self { stack: vec![root] }
333 }
334 pub fn push(&mut self, route: R) {
336 self.stack.push(route);
337 }
338 pub fn pop(&mut self) {
340 if self.stack.len() > 1 {
341 self.stack.pop();
342 }
343 }
344 pub fn reset(&mut self, root: R) {
346 self.stack = vec![root];
347 }
348 #[must_use]
350 pub fn current(&self) -> &R {
351 self.stack.last().expect("nav stack is never empty")
352 }
353 #[must_use]
355 pub fn depth(&self) -> u32 {
356 self.stack.len() as u32
357 }
358 #[must_use]
360 pub fn can_go_back(&self) -> bool {
361 self.stack.len() > 1
362 }
363 fn route_key(&self) -> String {
366 serde_json::to_string(self.current()).expect("serialize route")
367 }
368}
369
370fn tok<E: Serialize>(event: E) -> String {
374 serde_json::to_string(&event).expect("serialize event")
375}
376
377#[must_use]
378pub fn styled(content: impl Into<String>, style: TextStyle) -> Widget {
379 Widget::Text { content: content.into(), style }
380}
381#[must_use]
382pub fn text(content: impl Into<String>) -> Widget { styled(content, TextStyle::Body) }
383#[must_use]
384pub fn title(content: impl Into<String>) -> Widget { styled(content, TextStyle::Title) }
385#[must_use]
386pub fn subtitle(content: impl Into<String>) -> Widget { styled(content, TextStyle::Subtitle) }
387#[must_use]
388pub fn caption(content: impl Into<String>) -> Widget { styled(content, TextStyle::Caption) }
389#[must_use]
390pub fn emphasis(content: impl Into<String>) -> Widget { styled(content, TextStyle::Emphasis) }
391
392#[must_use]
393pub fn image(source: impl Into<String>, shape: ImageShape, ratio: ImageRatio) -> Widget {
394 Widget::Image { source: source.into(), shape, ratio }
395}
396#[must_use]
397pub fn badge(label: impl Into<String>, tone: Tone) -> Widget {
398 Widget::Badge { label: label.into(), tone }
399}
400#[must_use]
402pub fn color_dot(color: ProjectColor) -> Widget {
403 Widget::ColorDot { color }
404}
405#[must_use]
406pub fn divider() -> Widget { Widget::Divider }
407#[must_use]
409pub fn progress(value: Option<f32>) -> Widget { Widget::Progress { value } }
410#[must_use]
412pub fn skeleton() -> Widget { Widget::Skeleton }
413fn one_series(values: Vec<f32>) -> Vec<ChartSeries> {
415 vec![ChartSeries { name: String::new(), values, color: None, goal: None }]
416}
417
418#[must_use]
421pub fn bar_chart(values: Vec<f32>, labels: Vec<String>) -> Widget {
422 Widget::Chart { series: one_series(values), labels, style: ChartStyle::Bar, axis: false, legend: false }
423}
424#[must_use]
427pub fn line_chart(values: Vec<f32>, labels: Vec<String>) -> Widget {
428 Widget::Chart { series: one_series(values), labels, style: ChartStyle::Line, axis: false, legend: false }
429}
430#[must_use]
434pub fn chart(series: Vec<ChartSeries>, labels: Vec<String>, style: ChartStyle, axis: bool, legend: bool) -> Widget {
435 Widget::Chart { series, labels, style, axis, legend }
436}
437#[must_use]
439pub fn stacked_bar_chart(series: Vec<ChartSeries>, labels: Vec<String>) -> Widget {
440 chart(series, labels, ChartStyle::StackedBar, true, true)
441}
442#[must_use]
444pub fn pct_stacked_bar_chart(series: Vec<ChartSeries>, labels: Vec<String>) -> Widget {
445 chart(series, labels, ChartStyle::StackedBar100, false, true)
446}
447#[must_use]
449pub fn pie_chart(series: Vec<ChartSeries>) -> Widget {
450 chart(series, vec![], ChartStyle::Pie, false, true)
451}
452#[must_use]
454pub fn donut_chart(series: Vec<ChartSeries>) -> Widget {
455 chart(series, vec![], ChartStyle::Donut, false, true)
456}
457#[must_use]
460pub fn rings_chart(series: Vec<ChartSeries>) -> Widget {
461 chart(series, vec![], ChartStyle::Rings, false, true)
462}
463#[must_use]
465pub fn gauge_chart(series: ChartSeries) -> Widget {
466 chart(vec![series], vec![], ChartStyle::Gauge, false, false)
467}
468
469#[must_use]
474pub fn region_chart(
475 regions: Vec<ChartRegion>,
476 ticks: Vec<ChartTick>,
477 x_max: f32,
478 y_max: f32,
479 ref_lines: Vec<ChartRefLine>,
480 legend: Vec<ChartLegendItem>,
481) -> Widget {
482 Widget::RegionChart { regions, ticks, x_max, y_max, ref_lines, bracket: None, legend }
483}
484
485#[must_use]
487pub fn with_bracket(widget: Widget, bracket: ChartBracket) -> Widget {
488 match widget {
489 Widget::RegionChart { regions, ticks, x_max, y_max, ref_lines, legend, .. } => {
490 Widget::RegionChart { regions, ticks, x_max, y_max, ref_lines, bracket: Some(bracket), legend }
491 }
492 other => other,
493 }
494}
495
496fn days_in_month(year: u32, month: u8) -> u8 {
498 match month {
499 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
500 4 | 6 | 9 | 11 => 30,
501 2 => if (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 { 29 } else { 28 },
502 _ => 30,
503 }
504}
505
506fn weekday(year: u32, month: u8, day: u8) -> u8 {
508 const T: [u32; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
509 let y = if month < 3 { year - 1 } else { year };
510 let m = month as usize - 1;
511 ((y + y / 4 - y / 100 + y / 400 + T[m] + u32::from(day)) % 7) as u8
512}
513
514#[must_use]
518pub fn calendar<E: Serialize>(year: u32, month: u8, selected: Option<u8>, on_day: impl Fn(u8) -> E) -> Widget {
519 let n = days_in_month(year, month);
520 let on_day = (1..=n).map(|d| tok(on_day(d))).collect();
521 Widget::Calendar { year, month, first_weekday: weekday(year, month, 1), selected, on_day }
522}
523
524#[must_use]
527pub fn swipe_action<S: Into<String>, E: Serialize>(child: Widget, actions: Vec<(S, Tone, E)>) -> Widget {
528 Widget::SwipeAction {
529 child: Box::new(child),
530 actions: actions
531 .into_iter()
532 .map(|(label, tone, ev)| SwipeButton { label: label.into(), tone, on_tap: tok(ev) })
533 .collect(),
534 }
535}
536#[must_use]
537pub fn spacer(size: Spacing) -> Widget { Widget::Spacer { size } }
538
539#[must_use]
540pub fn row(children: Vec<Widget>) -> Widget { Widget::Row { children } }
541#[must_use]
542pub fn column(children: Vec<Widget>) -> Widget { Widget::Column { children } }
543#[must_use]
544pub fn card(child: Widget, style: CardStyle) -> Widget {
545 Widget::Card { child: Box::new(child), style, on_press: None }
546}
547#[must_use]
549pub fn card_button<E: Serialize>(child: Widget, style: CardStyle, on_press: E) -> Widget {
550 Widget::Card { child: Box::new(child), style, on_press: Some(tok(on_press)) }
551}
552#[must_use]
555pub fn stack(align: BoxAlign, scrim: bool, children: Vec<Widget>) -> Widget {
556 Widget::Box { children, align, scrim }
557}
558#[must_use]
559pub fn grid(children: Vec<Widget>) -> Widget { Widget::Grid { children } }
560#[must_use]
562pub fn scroller(children: Vec<Widget>) -> Widget { Widget::Scroller { children } }
563#[must_use]
565pub fn avatar(source: impl Into<String>) -> Widget { Widget::Avatar { source: source.into(), status: None } }
566#[must_use]
568pub fn avatar_status(source: impl Into<String>, status: Tone) -> Widget {
569 Widget::Avatar { source: source.into(), status: Some(status) }
570}
571#[must_use]
573pub fn rating(value: u32, max: u8) -> Widget { Widget::Rating { value, max, on_rate: None } }
574#[must_use]
576pub fn rating_input<E: Serialize>(value: u32, max: u8, on_rate: Vec<E>) -> Widget {
577 Widget::Rating { value, max, on_rate: Some(on_rate.into_iter().map(tok).collect()) }
578}
579
580#[must_use]
581pub fn button<E: Serialize>(label: impl Into<String>, style: ButtonStyle, on_press: E) -> Widget {
582 Widget::Button { label: label.into(), style, on_press: tok(on_press) }
583}
584#[must_use]
585pub fn icon_button<E: Serialize>(icon: Icon, on_press: E) -> Widget {
586 Widget::IconButton { icon, on_press: tok(on_press) }
587}
588#[must_use]
589pub fn chip<E: Serialize>(label: impl Into<String>, selected: bool, on_press: E) -> Widget {
590 Widget::Chip { label: label.into(), selected, on_press: tok(on_press) }
591}
592#[must_use]
593pub fn text_field(id: impl Into<String>, placeholder: impl Into<String>, value: impl Into<String>) -> Widget {
594 Widget::TextField { id: id.into(), placeholder: placeholder.into(), value: value.into() }
595}
596#[must_use]
598pub fn search_field(id: impl Into<String>, placeholder: impl Into<String>, value: impl Into<String>) -> Widget {
599 Widget::SearchField { id: id.into(), placeholder: placeholder.into(), value: value.into() }
600}
601#[must_use]
603pub fn segment<E: Serialize>(label: impl Into<String>, selected: bool, on_select: E) -> Segment {
604 Segment { label: label.into(), selected, on_select: tok(on_select) }
605}
606#[must_use]
608pub fn segmented(segments: Vec<Segment>) -> Widget {
609 Widget::Segmented { segments }
610}
611#[must_use]
612pub fn toggle(id: impl Into<String>, label: impl Into<String>, value: bool) -> Widget {
613 Widget::Toggle { id: id.into(), label: label.into(), value }
614}
615#[must_use]
616pub fn checkbox(id: impl Into<String>, label: impl Into<String>, value: bool) -> Widget {
617 Widget::Checkbox { id: id.into(), label: label.into(), value }
618}
619#[must_use]
620pub fn slider(id: impl Into<String>, value: i32, max: i32) -> Widget {
621 Widget::Slider { id: id.into(), value, max }
622}
623#[must_use]
624pub fn stepper<E: Serialize>(value: i32, on_decrement: E, on_increment: E) -> Widget {
625 Widget::Stepper { value, on_decrement: tok(on_decrement), on_increment: tok(on_increment) }
626}
627
628#[must_use]
630pub fn tab<E: Serialize>(label: impl Into<String>, selected: bool, on_select: E) -> Tab {
631 Tab { label: label.into(), selected, on_select: tok(on_select), icon: None }
632}
633
634#[must_use]
636pub fn tab_icon<E: Serialize>(label: impl Into<String>, icon: Icon, selected: bool, on_select: E) -> Tab {
637 Tab { label: label.into(), selected, on_select: tok(on_select), icon: Some(icon) }
638}
639
640#[must_use]
643pub fn scaffold(title: impl Into<String>, dark_mode: bool, tabs: Vec<Tab>, body: Widget) -> Widget {
644 let title = title.into();
645 Widget::Scaffold { route: title.clone(), title, body: Box::new(body), tabs, back: None, dark_mode, theme: None, fab: None, sheet: None, on_refresh: None, refreshing: false, depth: 1 }
647}
648
649#[must_use]
653pub fn scaffold_back<E: Serialize>(title: impl Into<String>, dark_mode: bool, tabs: Vec<Tab>, body: Widget, back: E) -> Widget {
654 let title = title.into();
655 Widget::Scaffold { route: title.clone(), title, body: Box::new(body), tabs, back: Some(tok(back)), dark_mode, theme: None, fab: None, sheet: None, on_refresh: None, refreshing: false, depth: 2 }
656}
657
658#[must_use]
663pub fn nav_scaffold<R, E>(
664 title: impl Into<String>,
665 dark_mode: bool,
666 tabs: Vec<Tab>,
667 body: Widget,
668 nav: &Nav<R>,
669 on_back: E,
670) -> Widget
671where
672 R: Clone + Serialize,
673 E: Serialize,
674{
675 Widget::Scaffold {
676 title: title.into(),
677 body: Box::new(body),
678 tabs,
679 back: if nav.can_go_back() { Some(tok(on_back)) } else { None },
680 dark_mode,
681 theme: None,
682 fab: None,
683 sheet: None,
684 on_refresh: None,
685 refreshing: false,
686 route: nav.route_key(),
687 depth: nav.depth(),
688 }
689}
690
691pub fn with_theme(widget: Widget, theme: Theme) -> Widget {
695 match widget {
696 Widget::Scaffold { title, body, tabs, back, dark_mode, fab, sheet, on_refresh, refreshing, route, depth, .. } => Widget::Scaffold {
697 title,
698 body,
699 tabs,
700 back,
701 dark_mode,
702 theme: Some(theme),
703 fab,
704 sheet,
705 on_refresh,
706 refreshing,
707 route,
708 depth,
709 },
710 other => other,
711 }
712}
713
714pub fn with_fab<E: Serialize>(widget: Widget, icon: Icon, on_press: E) -> Widget {
717 match widget {
718 Widget::Scaffold { title, body, tabs, back, dark_mode, theme, sheet, on_refresh, refreshing, route, depth, .. } => Widget::Scaffold {
719 title,
720 body,
721 tabs,
722 back,
723 dark_mode,
724 theme,
725 fab: Some(Fab { icon, on_press: tok(on_press) }),
726 sheet,
727 on_refresh,
728 refreshing,
729 route,
730 depth,
731 },
732 other => other,
733 }
734}
735
736pub fn with_sheet<E: Serialize>(widget: Widget, title: impl Into<String>, child: Widget, on_dismiss: E) -> Widget {
739 match widget {
740 Widget::Scaffold { title: t, body, tabs, back, dark_mode, theme, fab, on_refresh, refreshing, route, depth, .. } => Widget::Scaffold {
741 title: t,
742 body,
743 tabs,
744 back,
745 dark_mode,
746 theme,
747 fab,
748 sheet: Some(Sheet { title: title.into(), child: Box::new(child), on_dismiss: tok(on_dismiss) }),
749 on_refresh,
750 refreshing,
751 route,
752 depth,
753 },
754 other => other,
755 }
756}
757
758pub fn with_refresh<E: Serialize>(widget: Widget, refreshing: bool, on_refresh: E) -> Widget {
762 match widget {
763 Widget::Scaffold { title, body, tabs, back, dark_mode, theme, fab, sheet, route, depth, .. } => Widget::Scaffold {
764 title,
765 body,
766 tabs,
767 back,
768 dark_mode,
769 theme,
770 fab,
771 sheet,
772 on_refresh: Some(tok(on_refresh)),
773 refreshing,
774 route,
775 depth,
776 },
777 other => other,
778 }
779}
780
781#[cfg(test)]
782mod tests {
783 use super::*;
784 use serde::Serialize;
785
786 #[derive(Clone, Copy, Serialize, PartialEq, Debug)]
787 enum Route {
788 Home,
789 Detail(u32),
790 }
791
792 #[derive(Serialize)]
793 enum Ev {
794 Tap,
795 Open(u32),
796 }
797
798 #[test]
801 fn nav_push_pop_depth() {
802 let mut nav = Nav::new(Route::Home);
803 assert_eq!(nav.depth(), 1);
804 assert!(!nav.can_go_back());
805
806 nav.push(Route::Detail(7));
807 assert_eq!(nav.depth(), 2);
808 assert!(nav.can_go_back());
809 assert!(matches!(nav.current(), Route::Detail(7)));
810
811 nav.pop();
812 assert_eq!(nav.depth(), 1);
813 assert!(matches!(nav.current(), Route::Home));
814
815 nav.pop(); assert_eq!(nav.depth(), 1);
817 }
818
819 #[test]
820 fn nav_reset_replaces_stack() {
821 let mut nav = Nav::new(Route::Home);
822 nav.push(Route::Detail(1));
823 nav.push(Route::Detail(2));
824 nav.reset(Route::Detail(9));
825 assert_eq!(nav.depth(), 1);
826 assert!(matches!(nav.current(), Route::Detail(9)));
827 }
828
829 #[test]
830 fn nav_route_key_is_serialization() {
831 let nav = Nav::new(Route::Detail(3));
832 assert_eq!(nav.route_key(), serde_json::to_string(&Route::Detail(3)).unwrap());
833 }
834
835 #[test]
838 fn scaffold_sets_route_depth_and_no_back() {
839 match scaffold("Home", false, vec![], text("x")) {
840 Widget::Scaffold { route, depth, back, dark_mode, .. } => {
841 assert_eq!(route, "Home");
842 assert_eq!(depth, 1);
843 assert!(back.is_none());
844 assert!(!dark_mode);
845 }
846 other => panic!("expected Scaffold, got {other:?}"),
847 }
848 }
849
850 #[test]
851 fn scaffold_back_is_depth_2_with_back() {
852 match scaffold_back("Detail", true, vec![], text("x"), Ev::Tap) {
853 Widget::Scaffold { depth, back, dark_mode, .. } => {
854 assert_eq!(depth, 2);
855 assert_eq!(back, Some(serde_json::to_string(&Ev::Tap).unwrap()));
856 assert!(dark_mode);
857 }
858 other => panic!("expected Scaffold, got {other:?}"),
859 }
860 }
861
862 #[test]
863 fn nav_scaffold_shows_back_only_when_poppable() {
864 let mut nav = Nav::new(Route::Home);
865 match nav_scaffold("T", false, vec![], text("x"), &nav, Ev::Tap) {
867 Widget::Scaffold { back, depth, route, .. } => {
868 assert!(back.is_none());
869 assert_eq!(depth, 1);
870 assert_eq!(route, serde_json::to_string(&Route::Home).unwrap());
871 }
872 other => panic!("expected Scaffold, got {other:?}"),
873 }
874 nav.push(Route::Detail(2));
876 match nav_scaffold("T", false, vec![], text("x"), &nav, Ev::Tap) {
877 Widget::Scaffold { back, depth, .. } => {
878 assert_eq!(back, Some(serde_json::to_string(&Ev::Tap).unwrap()));
879 assert_eq!(depth, 2);
880 }
881 other => panic!("expected Scaffold, got {other:?}"),
882 }
883 }
884
885 #[test]
886 fn buttons_carry_serialized_event_tokens() {
887 match button("Go", ButtonStyle::Filled, Ev::Open(5)) {
888 Widget::Button { label, on_press, .. } => {
889 assert_eq!(label, "Go");
890 assert_eq!(on_press, serde_json::to_string(&Ev::Open(5)).unwrap());
891 }
892 other => panic!("expected Button, got {other:?}"),
893 }
894 match card_button(text("c"), CardStyle::Elevated, Ev::Tap) {
895 Widget::Card { on_press, .. } => {
896 assert_eq!(on_press, Some(serde_json::to_string(&Ev::Tap).unwrap()));
897 }
898 other => panic!("expected Card, got {other:?}"),
899 }
900 match card(text("c"), CardStyle::Elevated) {
902 Widget::Card { on_press, .. } => assert!(on_press.is_none()),
903 other => panic!("expected Card, got {other:?}"),
904 }
905 }
906
907 #[test]
910 fn cx_notify_and_save_enqueue_notifications() {
911 let mut cx = Cx::<Ev>::default();
912 cx.notify("toast", "show", "hi");
913 cx.save("blob");
914 assert_eq!(cx.notifications.len(), 2);
915 assert_eq!(cx.notifications[0], PluginNotify { plugin: "toast".into(), op: "show".into(), input: "hi".into() });
916 assert_eq!(cx.notifications[1], PluginNotify { plugin: "storage".into(), op: "save".into(), input: "blob".into() });
917 assert!(cx.requests.is_empty());
918 }
919
920 #[test]
921 fn cx_http_helpers_build_requests() {
922 let mut cx = Cx::<Ev>::default();
923 cx.get("http://h/x", |_| Ev::Tap);
924 cx.post("http://h/y", "hello", |_| Ev::Tap);
925 cx.patch("http://h/z", "patch", |_| Ev::Tap);
926 cx.delete("http://h/d", |_| Ev::Tap);
927
928 let methods: Vec<&str> = cx.requests.iter().map(|(c, _)| c.op.as_str()).collect();
929 assert_eq!(methods, ["GET", "POST", "PATCH", "DELETE"]);
930 assert!(cx.requests.iter().all(|(c, _)| c.plugin == "http"));
931
932 let get_input: serde_json::Value = serde_json::from_str(&cx.requests[0].0.input).unwrap();
933 assert_eq!(get_input["url"], "http://h/x");
934 assert!(get_input["body"].is_null());
935
936 let post_input: serde_json::Value = serde_json::from_str(&cx.requests[1].0.input).unwrap();
937 assert_eq!(post_input["url"], "http://h/y");
938 assert_eq!(post_input["body"], "hello");
939 }
940
941 #[test]
942 fn cx_pick_and_capture_photo_request_the_right_plugin() {
943 let mut cx = Cx::<Ev>::default();
944 cx.pick_photo(|_| Ev::Tap);
945 cx.capture_photo(|_| Ev::Tap);
946 assert_eq!(cx.requests.len(), 2);
947 assert_eq!((cx.requests[0].0.plugin.as_str(), cx.requests[0].0.op.as_str(), cx.requests[0].0.input.as_str()), ("photo", "pick", ""));
950 assert_eq!((cx.requests[1].0.plugin.as_str(), cx.requests[1].0.op.as_str(), cx.requests[1].0.input.as_str()), ("camera", "capture", ""));
951 }
952
953 #[test]
954 fn cx_capture_photo_routes_success_and_cancel() {
955 let mut cx = Cx::<Ev>::default();
957 cx.capture_photo(|r| if r.ok { Ev::Open(7) } else { Ev::Tap });
958 let (_, then) = cx.requests.pop().unwrap();
959 assert!(matches!(then(PluginResponse { ok: true, output: "file:///tmp/shot.jpg".into() }), Ev::Open(7)));
960
961 let mut cx = Cx::<Ev>::default();
963 cx.capture_photo(|r| if r.ok { Ev::Open(7) } else { Ev::Tap });
964 let (_, then) = cx.requests.pop().unwrap();
965 assert!(matches!(then(PluginResponse { ok: false, output: String::new() }), Ev::Tap));
966 }
967
968 #[test]
969 fn cx_notify_capabilities_map_to_the_right_plugin_and_op() {
970 let mut cx = Cx::<Ev>::default();
971 cx.copy("c");
972 cx.share("s");
973 cx.open_url("u");
974 cx.toast("t");
975 cx.haptic("heavy");
976 let got: Vec<(&str, &str, &str)> = cx
977 .notifications
978 .iter()
979 .map(|n| (n.plugin.as_str(), n.op.as_str(), n.input.as_str()))
980 .collect();
981 assert_eq!(
982 got,
983 vec![
984 ("clipboard", "copy", "c"),
985 ("share", "text", "s"),
986 ("browser", "open", "u"),
987 ("toast", "show", "t"),
988 ("haptics", "heavy", ""), ]
990 );
991 assert!(cx.requests.is_empty());
992 }
993
994 #[test]
995 fn cx_device_model_is_a_request_not_a_notification() {
996 let mut cx = Cx::<Ev>::default();
997 cx.device_model(|_| Ev::Tap);
998 assert!(cx.notifications.is_empty());
999 assert_eq!(cx.requests.len(), 1);
1000 let (call, _) = &cx.requests[0];
1001 assert_eq!((call.plugin.as_str(), call.op.as_str(), call.input.as_str()), ("device", "model", ""));
1002 }
1003
1004 #[test]
1005 fn cx_confirm_serializes_title_message_and_routes_ok() {
1006 let mut cx = Cx::<Ev>::default();
1007 cx.confirm("Delete?", "This cannot be undone.", |r| if r.ok { Ev::Tap } else { Ev::Open(0) });
1008 let (call, then) = cx.requests.pop().unwrap();
1009 assert_eq!((call.plugin.as_str(), call.op.as_str()), ("dialog", "confirm"));
1010 let v: serde_json::Value = serde_json::from_str(&call.input).unwrap();
1011 assert_eq!(v["title"], "Delete?");
1012 assert_eq!(v["message"], "This cannot be undone.");
1013 assert!(matches!(then(PluginResponse { ok: true, output: "ok".into() }), Ev::Tap));
1015 }
1016
1017 #[test]
1020 fn text_builders_carry_their_style() {
1021 assert!(matches!(text("b"), Widget::Text { style: TextStyle::Body, .. }));
1022 assert!(matches!(title("t"), Widget::Text { style: TextStyle::Title, .. }));
1023 assert!(matches!(subtitle("s"), Widget::Text { style: TextStyle::Subtitle, .. }));
1024 assert!(matches!(caption("c"), Widget::Text { style: TextStyle::Caption, .. }));
1025 assert!(matches!(emphasis("e"), Widget::Text { style: TextStyle::Emphasis, .. }));
1026 }
1027
1028 #[test]
1029 fn layout_and_content_builders_produce_their_variants() {
1030 assert!(matches!(row(vec![text("a")]), Widget::Row { children } if children.len() == 1));
1031 assert!(matches!(column(vec![]), Widget::Column { children } if children.is_empty()));
1032 assert!(matches!(grid(vec![text("a"), text("b")]), Widget::Grid { children } if children.len() == 2));
1033 assert!(matches!(divider(), Widget::Divider));
1034 assert!(matches!(bar_chart(vec![1.0, 2.0], vec![]), Widget::Chart { style: ChartStyle::Bar, series, .. } if series[0].values.len() == 2));
1035 assert!(matches!(line_chart(vec![1.0], vec![]), Widget::Chart { style: ChartStyle::Line, .. }));
1036 assert!(matches!(donut_chart(vec![ChartSeries::new("a", vec![1.0])]), Widget::Chart { style: ChartStyle::Donut, legend: true, .. }));
1037 assert!(matches!(gauge_chart(ChartSeries::new("g", vec![3.0]).with_goal(5.0)), Widget::Chart { style: ChartStyle::Gauge, series, .. } if series[0].goal == Some(5.0)));
1038 let rc = with_bracket(
1039 region_chart(
1040 vec![ChartRegion::new(0.0, 3.0, 0.0, 80.0, "80%").vertical()],
1041 vec![ChartTick::new(3.0, "3 Mt.")],
1042 65.0, 80.0,
1043 vec![ChartRefLine::target(80.0, "CHF 80'000"), ChartRefLine::max(90.0, "CHF 90'000")],
1044 vec![ChartLegendItem::new("Gap", Rgb::new(0x5A, 0x7D, 0x9A))],
1045 ),
1046 ChartBracket::new(60.0, 80.0, "Ceiling").with_info(),
1047 );
1048 assert!(matches!(rc, Widget::RegionChart { bracket: Some(b), regions, ref_lines, .. } if regions[0].vertical && ref_lines[1].dashed && b.info));
1049 assert!(matches!(
1051 calendar(2026, 6, Some(3), |d| Ev::Open(u32::from(d))),
1052 Widget::Calendar { first_weekday: 1, selected: Some(3), on_day, .. } if on_day.len() == 30
1053 ));
1054 assert!(matches!(
1055 swipe_action(text("row"), vec![("Delete", Tone::Danger, Ev::Tap)]),
1056 Widget::SwipeAction { actions, .. } if actions.len() == 1
1057 ));
1058 assert!(matches!(spacer(Spacing::Lg), Widget::Spacer { .. }));
1059 assert!(matches!(image("u", ImageShape::Circle, ImageRatio::Square), Widget::Image { .. }));
1060 assert!(matches!(badge("new", Tone::Success), Widget::Badge { .. }));
1061 assert!(matches!(color_dot(ProjectColor::Teal), Widget::ColorDot { .. }));
1062 assert!(matches!(card(text("x"), CardStyle::Filled), Widget::Card { on_press: None, .. }));
1063 assert!(matches!(stack(BoxAlign::Center, true, vec![]), Widget::Box { scrim: true, .. }));
1065 }
1066
1067 #[test]
1068 fn input_builders_carry_ids_values_and_event_tokens() {
1069 assert!(matches!(text_field("id", "ph", "v"), Widget::TextField { .. }));
1070 assert!(matches!(toggle("t", "l", true), Widget::Toggle { value: true, .. }));
1071 assert!(matches!(checkbox("c", "l", false), Widget::Checkbox { value: false, .. }));
1072 assert!(matches!(slider("s", 3, 10), Widget::Slider { value: 3, max: 10, .. }));
1073
1074 match chip("Latte", true, Ev::Open(2)) {
1075 Widget::Chip { selected, on_press, .. } => {
1076 assert!(selected);
1077 assert_eq!(on_press, serde_json::to_string(&Ev::Open(2)).unwrap());
1078 }
1079 other => panic!("expected Chip, got {other:?}"),
1080 }
1081 match stepper(5, Ev::Tap, Ev::Open(1)) {
1082 Widget::Stepper { value, on_decrement, on_increment } => {
1083 assert_eq!(value, 5);
1084 assert_eq!(on_decrement, serde_json::to_string(&Ev::Tap).unwrap());
1085 assert_eq!(on_increment, serde_json::to_string(&Ev::Open(1)).unwrap());
1086 }
1087 other => panic!("expected Stepper, got {other:?}"),
1088 }
1089 let t = tab("Home", true, Ev::Tap);
1090 assert_eq!(t.label, "Home");
1091 assert!(t.selected);
1092 assert_eq!(t.on_select, serde_json::to_string(&Ev::Tap).unwrap());
1093 }
1094
1095 #[test]
1098 fn widget_tree_round_trips_through_serde() {
1099 let tree = scaffold(
1100 "Home",
1101 true,
1102 vec![tab("A", true, Ev::Tap)],
1103 column(vec![
1104 title("Hi"),
1105 row(vec![button("Go", ButtonStyle::Filled, Ev::Open(3)), chip("x", false, Ev::Tap)]),
1106 image("u", ImageShape::Rounded, ImageRatio::Wide),
1107 slider("s", 2, 5),
1108 ]),
1109 );
1110 let s = serde_json::to_string(&tree).unwrap();
1111 let back: Widget = serde_json::from_str(&s).unwrap();
1112 assert_eq!(s, serde_json::to_string(&back).unwrap());
1113 }
1114
1115 #[test]
1116 fn actions_and_input_values_round_trip() {
1117 let actions = vec![
1118 Action::Fired { token: serde_json::to_string(&Ev::Open(1)).unwrap() },
1119 Action::Input { id: "n".into(), value: InputValue::Int(7) },
1120 Action::Input { id: "n".into(), value: InputValue::Text("hi".into()) },
1121 Action::Input { id: "n".into(), value: InputValue::Bool(true) },
1122 Action::Restore { data: "blob".into() },
1123 Action::Start,
1124 ];
1125 for a in actions {
1126 let s = serde_json::to_string(&a).unwrap();
1127 let back: Action = serde_json::from_str(&s).unwrap();
1128 assert_eq!(s, serde_json::to_string(&back).unwrap());
1129 }
1130 }
1131
1132 #[derive(Default)]
1135 struct CounterModel {
1136 count: i32,
1137 restored: String,
1138 started: bool,
1139 last_input: String,
1140 }
1141
1142 #[derive(serde::Serialize, serde::Deserialize)]
1143 enum CounterEv {
1144 Inc,
1145 Add(i32),
1146 }
1147
1148 #[derive(Default)]
1149 struct CounterApp;
1150
1151 impl MobilerApp for CounterApp {
1152 type Event = CounterEv;
1153 type Model = CounterModel;
1154 fn update(&self, ev: CounterEv, model: &mut CounterModel, _cx: &mut Cx<CounterEv>) {
1155 match ev {
1156 CounterEv::Inc => model.count += 1,
1157 CounterEv::Add(n) => model.count += n,
1158 }
1159 }
1160 fn input(&self, id: &str, value: InputValue, model: &mut CounterModel, _cx: &mut Cx<CounterEv>) {
1161 if let InputValue::Text(t) = value {
1162 model.last_input = format!("{id}={t}");
1163 }
1164 }
1165 fn restore(&self, data: &str, model: &mut CounterModel) {
1166 model.restored = data.to_string();
1167 }
1168 fn init(&self, model: &mut CounterModel, _cx: &mut Cx<CounterEv>) {
1169 model.started = true;
1170 }
1171 fn view(&self, model: &CounterModel) -> Widget {
1172 text(format!("{}", model.count))
1173 }
1174 }
1175
1176 #[test]
1177 fn shell_dispatches_fired_input_restore_and_start() {
1178 use crux_core::App as _;
1179 let shell = MobilerShell::<CounterApp>::default();
1180 let mut m = CounterModel::default();
1181
1182 let _ = shell.update(Action::Fired { token: serde_json::to_string(&CounterEv::Add(5)).unwrap() }, &mut m);
1184 assert_eq!(m.count, 5);
1185 let _ = shell.update(Action::Input { id: "name".into(), value: InputValue::Text("bob".into()) }, &mut m);
1187 assert_eq!(m.last_input, "name=bob");
1188 let _ = shell.update(Action::Restore { data: "saved".into() }, &mut m);
1190 assert_eq!(m.restored, "saved");
1191 let _ = shell.update(Action::Start, &mut m);
1193 assert!(m.started);
1194 assert!(matches!(shell.view(&m), Widget::Text { .. }));
1196 }
1197
1198 #[test]
1199 fn shell_ignores_a_malformed_fired_token() {
1200 use crux_core::App as _;
1201 let shell = MobilerShell::<CounterApp>::default();
1202 let mut m = CounterModel::default();
1203 let _ = shell.update(Action::Fired { token: "not a valid token".into() }, &mut m);
1206 assert_eq!(m.count, 0);
1207 }
1208}