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, FieldKind, 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(), kind: FieldKind::Text, error: None }
595}
596#[must_use]
600pub fn field(id: impl Into<String>, placeholder: impl Into<String>, value: impl Into<String>, kind: FieldKind, error: Option<String>) -> Widget {
601 Widget::TextField { id: id.into(), placeholder: placeholder.into(), value: value.into(), kind, error }
602}
603#[must_use]
605pub fn secure_field(id: impl Into<String>, placeholder: impl Into<String>, value: impl Into<String>) -> Widget {
606 field(id, placeholder, value, FieldKind::Secure, None)
607}
608#[must_use]
610pub fn email_field(id: impl Into<String>, placeholder: impl Into<String>, value: impl Into<String>) -> Widget {
611 field(id, placeholder, value, FieldKind::Email, None)
612}
613#[must_use]
615pub fn number_field(id: impl Into<String>, placeholder: impl Into<String>, value: impl Into<String>) -> Widget {
616 field(id, placeholder, value, FieldKind::Number, None)
617}
618#[must_use]
620pub fn decimal_field(id: impl Into<String>, placeholder: impl Into<String>, value: impl Into<String>) -> Widget {
621 field(id, placeholder, value, FieldKind::Decimal, None)
622}
623#[must_use]
625pub fn phone_field(id: impl Into<String>, placeholder: impl Into<String>, value: impl Into<String>) -> Widget {
626 field(id, placeholder, value, FieldKind::Phone, None)
627}
628#[must_use]
630pub fn url_field(id: impl Into<String>, placeholder: impl Into<String>, value: impl Into<String>) -> Widget {
631 field(id, placeholder, value, FieldKind::Url, None)
632}
633#[must_use]
635pub fn multiline_field(id: impl Into<String>, placeholder: impl Into<String>, value: impl Into<String>) -> Widget {
636 field(id, placeholder, value, FieldKind::Multiline, None)
637}
638#[must_use]
641pub fn with_error(widget: Widget, message: impl Into<String>) -> Widget {
642 match widget {
643 Widget::TextField { id, placeholder, value, kind, .. } =>
644 Widget::TextField { id, placeholder, value, kind, error: Some(message.into()) },
645 other => other,
646 }
647}
648#[must_use]
650pub fn search_field(id: impl Into<String>, placeholder: impl Into<String>, value: impl Into<String>) -> Widget {
651 Widget::SearchField { id: id.into(), placeholder: placeholder.into(), value: value.into() }
652}
653#[must_use]
655pub fn segment<E: Serialize>(label: impl Into<String>, selected: bool, on_select: E) -> Segment {
656 Segment { label: label.into(), selected, on_select: tok(on_select) }
657}
658#[must_use]
660pub fn segmented(segments: Vec<Segment>) -> Widget {
661 Widget::Segmented { segments }
662}
663#[must_use]
664pub fn toggle(id: impl Into<String>, label: impl Into<String>, value: bool) -> Widget {
665 Widget::Toggle { id: id.into(), label: label.into(), value }
666}
667#[must_use]
668pub fn checkbox(id: impl Into<String>, label: impl Into<String>, value: bool) -> Widget {
669 Widget::Checkbox { id: id.into(), label: label.into(), value }
670}
671#[must_use]
672pub fn slider(id: impl Into<String>, value: i32, max: i32) -> Widget {
673 Widget::Slider { id: id.into(), value, max }
674}
675#[must_use]
676pub fn stepper<E: Serialize>(value: i32, on_decrement: E, on_increment: E) -> Widget {
677 Widget::Stepper { value, on_decrement: tok(on_decrement), on_increment: tok(on_increment) }
678}
679
680#[must_use]
682pub fn tab<E: Serialize>(label: impl Into<String>, selected: bool, on_select: E) -> Tab {
683 Tab { label: label.into(), selected, on_select: tok(on_select), icon: None }
684}
685
686#[must_use]
688pub fn tab_icon<E: Serialize>(label: impl Into<String>, icon: Icon, selected: bool, on_select: E) -> Tab {
689 Tab { label: label.into(), selected, on_select: tok(on_select), icon: Some(icon) }
690}
691
692#[must_use]
695pub fn scaffold(title: impl Into<String>, dark_mode: bool, tabs: Vec<Tab>, body: Widget) -> Widget {
696 let title = title.into();
697 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 }
699}
700
701#[must_use]
705pub fn scaffold_back<E: Serialize>(title: impl Into<String>, dark_mode: bool, tabs: Vec<Tab>, body: Widget, back: E) -> Widget {
706 let title = title.into();
707 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 }
708}
709
710#[must_use]
715pub fn nav_scaffold<R, E>(
716 title: impl Into<String>,
717 dark_mode: bool,
718 tabs: Vec<Tab>,
719 body: Widget,
720 nav: &Nav<R>,
721 on_back: E,
722) -> Widget
723where
724 R: Clone + Serialize,
725 E: Serialize,
726{
727 Widget::Scaffold {
728 title: title.into(),
729 body: Box::new(body),
730 tabs,
731 back: if nav.can_go_back() { Some(tok(on_back)) } else { None },
732 dark_mode,
733 theme: None,
734 fab: None,
735 sheet: None,
736 on_refresh: None,
737 refreshing: false,
738 route: nav.route_key(),
739 depth: nav.depth(),
740 }
741}
742
743pub fn with_theme(widget: Widget, theme: Theme) -> Widget {
747 match widget {
748 Widget::Scaffold { title, body, tabs, back, dark_mode, fab, sheet, on_refresh, refreshing, route, depth, .. } => Widget::Scaffold {
749 title,
750 body,
751 tabs,
752 back,
753 dark_mode,
754 theme: Some(theme),
755 fab,
756 sheet,
757 on_refresh,
758 refreshing,
759 route,
760 depth,
761 },
762 other => other,
763 }
764}
765
766pub fn with_fab<E: Serialize>(widget: Widget, icon: Icon, on_press: E) -> Widget {
769 match widget {
770 Widget::Scaffold { title, body, tabs, back, dark_mode, theme, sheet, on_refresh, refreshing, route, depth, .. } => Widget::Scaffold {
771 title,
772 body,
773 tabs,
774 back,
775 dark_mode,
776 theme,
777 fab: Some(Fab { icon, on_press: tok(on_press) }),
778 sheet,
779 on_refresh,
780 refreshing,
781 route,
782 depth,
783 },
784 other => other,
785 }
786}
787
788pub fn with_sheet<E: Serialize>(widget: Widget, title: impl Into<String>, child: Widget, on_dismiss: E) -> Widget {
791 match widget {
792 Widget::Scaffold { title: t, body, tabs, back, dark_mode, theme, fab, on_refresh, refreshing, route, depth, .. } => Widget::Scaffold {
793 title: t,
794 body,
795 tabs,
796 back,
797 dark_mode,
798 theme,
799 fab,
800 sheet: Some(Sheet { title: title.into(), child: Box::new(child), on_dismiss: tok(on_dismiss) }),
801 on_refresh,
802 refreshing,
803 route,
804 depth,
805 },
806 other => other,
807 }
808}
809
810pub fn with_refresh<E: Serialize>(widget: Widget, refreshing: bool, on_refresh: E) -> Widget {
814 match widget {
815 Widget::Scaffold { title, body, tabs, back, dark_mode, theme, fab, sheet, route, depth, .. } => Widget::Scaffold {
816 title,
817 body,
818 tabs,
819 back,
820 dark_mode,
821 theme,
822 fab,
823 sheet,
824 on_refresh: Some(tok(on_refresh)),
825 refreshing,
826 route,
827 depth,
828 },
829 other => other,
830 }
831}
832
833#[cfg(test)]
834mod tests {
835 use super::*;
836 use serde::Serialize;
837
838 #[derive(Clone, Copy, Serialize, PartialEq, Debug)]
839 enum Route {
840 Home,
841 Detail(u32),
842 }
843
844 #[derive(Serialize)]
845 enum Ev {
846 Tap,
847 Open(u32),
848 }
849
850 #[test]
853 fn nav_push_pop_depth() {
854 let mut nav = Nav::new(Route::Home);
855 assert_eq!(nav.depth(), 1);
856 assert!(!nav.can_go_back());
857
858 nav.push(Route::Detail(7));
859 assert_eq!(nav.depth(), 2);
860 assert!(nav.can_go_back());
861 assert!(matches!(nav.current(), Route::Detail(7)));
862
863 nav.pop();
864 assert_eq!(nav.depth(), 1);
865 assert!(matches!(nav.current(), Route::Home));
866
867 nav.pop(); assert_eq!(nav.depth(), 1);
869 }
870
871 #[test]
872 fn nav_reset_replaces_stack() {
873 let mut nav = Nav::new(Route::Home);
874 nav.push(Route::Detail(1));
875 nav.push(Route::Detail(2));
876 nav.reset(Route::Detail(9));
877 assert_eq!(nav.depth(), 1);
878 assert!(matches!(nav.current(), Route::Detail(9)));
879 }
880
881 #[test]
882 fn nav_route_key_is_serialization() {
883 let nav = Nav::new(Route::Detail(3));
884 assert_eq!(nav.route_key(), serde_json::to_string(&Route::Detail(3)).unwrap());
885 }
886
887 #[test]
890 fn scaffold_sets_route_depth_and_no_back() {
891 match scaffold("Home", false, vec![], text("x")) {
892 Widget::Scaffold { route, depth, back, dark_mode, .. } => {
893 assert_eq!(route, "Home");
894 assert_eq!(depth, 1);
895 assert!(back.is_none());
896 assert!(!dark_mode);
897 }
898 other => panic!("expected Scaffold, got {other:?}"),
899 }
900 }
901
902 #[test]
903 fn scaffold_back_is_depth_2_with_back() {
904 match scaffold_back("Detail", true, vec![], text("x"), Ev::Tap) {
905 Widget::Scaffold { depth, back, dark_mode, .. } => {
906 assert_eq!(depth, 2);
907 assert_eq!(back, Some(serde_json::to_string(&Ev::Tap).unwrap()));
908 assert!(dark_mode);
909 }
910 other => panic!("expected Scaffold, got {other:?}"),
911 }
912 }
913
914 #[test]
915 fn nav_scaffold_shows_back_only_when_poppable() {
916 let mut nav = Nav::new(Route::Home);
917 match nav_scaffold("T", false, vec![], text("x"), &nav, Ev::Tap) {
919 Widget::Scaffold { back, depth, route, .. } => {
920 assert!(back.is_none());
921 assert_eq!(depth, 1);
922 assert_eq!(route, serde_json::to_string(&Route::Home).unwrap());
923 }
924 other => panic!("expected Scaffold, got {other:?}"),
925 }
926 nav.push(Route::Detail(2));
928 match nav_scaffold("T", false, vec![], text("x"), &nav, Ev::Tap) {
929 Widget::Scaffold { back, depth, .. } => {
930 assert_eq!(back, Some(serde_json::to_string(&Ev::Tap).unwrap()));
931 assert_eq!(depth, 2);
932 }
933 other => panic!("expected Scaffold, got {other:?}"),
934 }
935 }
936
937 #[test]
938 fn buttons_carry_serialized_event_tokens() {
939 match button("Go", ButtonStyle::Filled, Ev::Open(5)) {
940 Widget::Button { label, on_press, .. } => {
941 assert_eq!(label, "Go");
942 assert_eq!(on_press, serde_json::to_string(&Ev::Open(5)).unwrap());
943 }
944 other => panic!("expected Button, got {other:?}"),
945 }
946 match card_button(text("c"), CardStyle::Elevated, Ev::Tap) {
947 Widget::Card { on_press, .. } => {
948 assert_eq!(on_press, Some(serde_json::to_string(&Ev::Tap).unwrap()));
949 }
950 other => panic!("expected Card, got {other:?}"),
951 }
952 match card(text("c"), CardStyle::Elevated) {
954 Widget::Card { on_press, .. } => assert!(on_press.is_none()),
955 other => panic!("expected Card, got {other:?}"),
956 }
957 }
958
959 #[test]
962 fn cx_notify_and_save_enqueue_notifications() {
963 let mut cx = Cx::<Ev>::default();
964 cx.notify("toast", "show", "hi");
965 cx.save("blob");
966 assert_eq!(cx.notifications.len(), 2);
967 assert_eq!(cx.notifications[0], PluginNotify { plugin: "toast".into(), op: "show".into(), input: "hi".into() });
968 assert_eq!(cx.notifications[1], PluginNotify { plugin: "storage".into(), op: "save".into(), input: "blob".into() });
969 assert!(cx.requests.is_empty());
970 }
971
972 #[test]
973 fn cx_http_helpers_build_requests() {
974 let mut cx = Cx::<Ev>::default();
975 cx.get("http://h/x", |_| Ev::Tap);
976 cx.post("http://h/y", "hello", |_| Ev::Tap);
977 cx.patch("http://h/z", "patch", |_| Ev::Tap);
978 cx.delete("http://h/d", |_| Ev::Tap);
979
980 let methods: Vec<&str> = cx.requests.iter().map(|(c, _)| c.op.as_str()).collect();
981 assert_eq!(methods, ["GET", "POST", "PATCH", "DELETE"]);
982 assert!(cx.requests.iter().all(|(c, _)| c.plugin == "http"));
983
984 let get_input: serde_json::Value = serde_json::from_str(&cx.requests[0].0.input).unwrap();
985 assert_eq!(get_input["url"], "http://h/x");
986 assert!(get_input["body"].is_null());
987
988 let post_input: serde_json::Value = serde_json::from_str(&cx.requests[1].0.input).unwrap();
989 assert_eq!(post_input["url"], "http://h/y");
990 assert_eq!(post_input["body"], "hello");
991 }
992
993 #[test]
994 fn cx_pick_and_capture_photo_request_the_right_plugin() {
995 let mut cx = Cx::<Ev>::default();
996 cx.pick_photo(|_| Ev::Tap);
997 cx.capture_photo(|_| Ev::Tap);
998 assert_eq!(cx.requests.len(), 2);
999 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", ""));
1002 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", ""));
1003 }
1004
1005 #[test]
1006 fn cx_capture_photo_routes_success_and_cancel() {
1007 let mut cx = Cx::<Ev>::default();
1009 cx.capture_photo(|r| if r.ok { Ev::Open(7) } else { Ev::Tap });
1010 let (_, then) = cx.requests.pop().unwrap();
1011 assert!(matches!(then(PluginResponse { ok: true, output: "file:///tmp/shot.jpg".into() }), Ev::Open(7)));
1012
1013 let mut cx = Cx::<Ev>::default();
1015 cx.capture_photo(|r| if r.ok { Ev::Open(7) } else { Ev::Tap });
1016 let (_, then) = cx.requests.pop().unwrap();
1017 assert!(matches!(then(PluginResponse { ok: false, output: String::new() }), Ev::Tap));
1018 }
1019
1020 #[test]
1021 fn cx_notify_capabilities_map_to_the_right_plugin_and_op() {
1022 let mut cx = Cx::<Ev>::default();
1023 cx.copy("c");
1024 cx.share("s");
1025 cx.open_url("u");
1026 cx.toast("t");
1027 cx.haptic("heavy");
1028 let got: Vec<(&str, &str, &str)> = cx
1029 .notifications
1030 .iter()
1031 .map(|n| (n.plugin.as_str(), n.op.as_str(), n.input.as_str()))
1032 .collect();
1033 assert_eq!(
1034 got,
1035 vec![
1036 ("clipboard", "copy", "c"),
1037 ("share", "text", "s"),
1038 ("browser", "open", "u"),
1039 ("toast", "show", "t"),
1040 ("haptics", "heavy", ""), ]
1042 );
1043 assert!(cx.requests.is_empty());
1044 }
1045
1046 #[test]
1047 fn cx_device_model_is_a_request_not_a_notification() {
1048 let mut cx = Cx::<Ev>::default();
1049 cx.device_model(|_| Ev::Tap);
1050 assert!(cx.notifications.is_empty());
1051 assert_eq!(cx.requests.len(), 1);
1052 let (call, _) = &cx.requests[0];
1053 assert_eq!((call.plugin.as_str(), call.op.as_str(), call.input.as_str()), ("device", "model", ""));
1054 }
1055
1056 #[test]
1057 fn cx_confirm_serializes_title_message_and_routes_ok() {
1058 let mut cx = Cx::<Ev>::default();
1059 cx.confirm("Delete?", "This cannot be undone.", |r| if r.ok { Ev::Tap } else { Ev::Open(0) });
1060 let (call, then) = cx.requests.pop().unwrap();
1061 assert_eq!((call.plugin.as_str(), call.op.as_str()), ("dialog", "confirm"));
1062 let v: serde_json::Value = serde_json::from_str(&call.input).unwrap();
1063 assert_eq!(v["title"], "Delete?");
1064 assert_eq!(v["message"], "This cannot be undone.");
1065 assert!(matches!(then(PluginResponse { ok: true, output: "ok".into() }), Ev::Tap));
1067 }
1068
1069 #[test]
1072 fn text_builders_carry_their_style() {
1073 assert!(matches!(text("b"), Widget::Text { style: TextStyle::Body, .. }));
1074 assert!(matches!(title("t"), Widget::Text { style: TextStyle::Title, .. }));
1075 assert!(matches!(subtitle("s"), Widget::Text { style: TextStyle::Subtitle, .. }));
1076 assert!(matches!(caption("c"), Widget::Text { style: TextStyle::Caption, .. }));
1077 assert!(matches!(emphasis("e"), Widget::Text { style: TextStyle::Emphasis, .. }));
1078 }
1079
1080 #[test]
1081 fn layout_and_content_builders_produce_their_variants() {
1082 assert!(matches!(row(vec![text("a")]), Widget::Row { children } if children.len() == 1));
1083 assert!(matches!(column(vec![]), Widget::Column { children } if children.is_empty()));
1084 assert!(matches!(grid(vec![text("a"), text("b")]), Widget::Grid { children } if children.len() == 2));
1085 assert!(matches!(divider(), Widget::Divider));
1086 assert!(matches!(bar_chart(vec![1.0, 2.0], vec![]), Widget::Chart { style: ChartStyle::Bar, series, .. } if series[0].values.len() == 2));
1087 assert!(matches!(line_chart(vec![1.0], vec![]), Widget::Chart { style: ChartStyle::Line, .. }));
1088 assert!(matches!(donut_chart(vec![ChartSeries::new("a", vec![1.0])]), Widget::Chart { style: ChartStyle::Donut, legend: true, .. }));
1089 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)));
1090 let rc = with_bracket(
1091 region_chart(
1092 vec![ChartRegion::new(0.0, 3.0, 0.0, 80.0, "80%").vertical()],
1093 vec![ChartTick::new(3.0, "3 Mt.")],
1094 65.0, 80.0,
1095 vec![ChartRefLine::target(80.0, "CHF 80'000"), ChartRefLine::max(90.0, "CHF 90'000")],
1096 vec![ChartLegendItem::new("Gap", Rgb::new(0x5A, 0x7D, 0x9A))],
1097 ),
1098 ChartBracket::new(60.0, 80.0, "Ceiling").with_info(),
1099 );
1100 assert!(matches!(rc, Widget::RegionChart { bracket: Some(b), regions, ref_lines, .. } if regions[0].vertical && ref_lines[1].dashed && b.info));
1101 assert!(matches!(
1103 calendar(2026, 6, Some(3), |d| Ev::Open(u32::from(d))),
1104 Widget::Calendar { first_weekday: 1, selected: Some(3), on_day, .. } if on_day.len() == 30
1105 ));
1106 assert!(matches!(
1107 swipe_action(text("row"), vec![("Delete", Tone::Danger, Ev::Tap)]),
1108 Widget::SwipeAction { actions, .. } if actions.len() == 1
1109 ));
1110 assert!(matches!(spacer(Spacing::Lg), Widget::Spacer { .. }));
1111 assert!(matches!(image("u", ImageShape::Circle, ImageRatio::Square), Widget::Image { .. }));
1112 assert!(matches!(badge("new", Tone::Success), Widget::Badge { .. }));
1113 assert!(matches!(color_dot(ProjectColor::Teal), Widget::ColorDot { .. }));
1114 assert!(matches!(card(text("x"), CardStyle::Filled), Widget::Card { on_press: None, .. }));
1115 assert!(matches!(stack(BoxAlign::Center, true, vec![]), Widget::Box { scrim: true, .. }));
1117 }
1118
1119 #[test]
1120 fn input_builders_carry_ids_values_and_event_tokens() {
1121 assert!(matches!(text_field("id", "ph", "v"), Widget::TextField { kind: FieldKind::Text, error: None, .. }));
1122 assert!(matches!(secure_field("pw", "Password", ""), Widget::TextField { kind: FieldKind::Secure, .. }));
1123 assert!(matches!(email_field("e", "", ""), Widget::TextField { kind: FieldKind::Email, .. }));
1124 assert!(matches!(multiline_field("note", "", ""), Widget::TextField { kind: FieldKind::Multiline, .. }));
1125 assert!(matches!(with_error(email_field("e", "", "x"), "Invalid"), Widget::TextField { error: Some(m), kind: FieldKind::Email, .. } if m == "Invalid"));
1126 assert!(matches!(with_error(divider(), "ignored"), Widget::Divider));
1127 assert!(matches!(toggle("t", "l", true), Widget::Toggle { value: true, .. }));
1128 assert!(matches!(checkbox("c", "l", false), Widget::Checkbox { value: false, .. }));
1129 assert!(matches!(slider("s", 3, 10), Widget::Slider { value: 3, max: 10, .. }));
1130
1131 match chip("Latte", true, Ev::Open(2)) {
1132 Widget::Chip { selected, on_press, .. } => {
1133 assert!(selected);
1134 assert_eq!(on_press, serde_json::to_string(&Ev::Open(2)).unwrap());
1135 }
1136 other => panic!("expected Chip, got {other:?}"),
1137 }
1138 match stepper(5, Ev::Tap, Ev::Open(1)) {
1139 Widget::Stepper { value, on_decrement, on_increment } => {
1140 assert_eq!(value, 5);
1141 assert_eq!(on_decrement, serde_json::to_string(&Ev::Tap).unwrap());
1142 assert_eq!(on_increment, serde_json::to_string(&Ev::Open(1)).unwrap());
1143 }
1144 other => panic!("expected Stepper, got {other:?}"),
1145 }
1146 let t = tab("Home", true, Ev::Tap);
1147 assert_eq!(t.label, "Home");
1148 assert!(t.selected);
1149 assert_eq!(t.on_select, serde_json::to_string(&Ev::Tap).unwrap());
1150 }
1151
1152 #[test]
1155 fn widget_tree_round_trips_through_serde() {
1156 let tree = scaffold(
1157 "Home",
1158 true,
1159 vec![tab("A", true, Ev::Tap)],
1160 column(vec![
1161 title("Hi"),
1162 row(vec![button("Go", ButtonStyle::Filled, Ev::Open(3)), chip("x", false, Ev::Tap)]),
1163 image("u", ImageShape::Rounded, ImageRatio::Wide),
1164 slider("s", 2, 5),
1165 ]),
1166 );
1167 let s = serde_json::to_string(&tree).unwrap();
1168 let back: Widget = serde_json::from_str(&s).unwrap();
1169 assert_eq!(s, serde_json::to_string(&back).unwrap());
1170 }
1171
1172 #[test]
1173 fn actions_and_input_values_round_trip() {
1174 let actions = vec![
1175 Action::Fired { token: serde_json::to_string(&Ev::Open(1)).unwrap() },
1176 Action::Input { id: "n".into(), value: InputValue::Int(7) },
1177 Action::Input { id: "n".into(), value: InputValue::Text("hi".into()) },
1178 Action::Input { id: "n".into(), value: InputValue::Bool(true) },
1179 Action::Restore { data: "blob".into() },
1180 Action::Start,
1181 ];
1182 for a in actions {
1183 let s = serde_json::to_string(&a).unwrap();
1184 let back: Action = serde_json::from_str(&s).unwrap();
1185 assert_eq!(s, serde_json::to_string(&back).unwrap());
1186 }
1187 }
1188
1189 #[derive(Default)]
1192 struct CounterModel {
1193 count: i32,
1194 restored: String,
1195 started: bool,
1196 last_input: String,
1197 }
1198
1199 #[derive(serde::Serialize, serde::Deserialize)]
1200 enum CounterEv {
1201 Inc,
1202 Add(i32),
1203 }
1204
1205 #[derive(Default)]
1206 struct CounterApp;
1207
1208 impl MobilerApp for CounterApp {
1209 type Event = CounterEv;
1210 type Model = CounterModel;
1211 fn update(&self, ev: CounterEv, model: &mut CounterModel, _cx: &mut Cx<CounterEv>) {
1212 match ev {
1213 CounterEv::Inc => model.count += 1,
1214 CounterEv::Add(n) => model.count += n,
1215 }
1216 }
1217 fn input(&self, id: &str, value: InputValue, model: &mut CounterModel, _cx: &mut Cx<CounterEv>) {
1218 if let InputValue::Text(t) = value {
1219 model.last_input = format!("{id}={t}");
1220 }
1221 }
1222 fn restore(&self, data: &str, model: &mut CounterModel) {
1223 model.restored = data.to_string();
1224 }
1225 fn init(&self, model: &mut CounterModel, _cx: &mut Cx<CounterEv>) {
1226 model.started = true;
1227 }
1228 fn view(&self, model: &CounterModel) -> Widget {
1229 text(format!("{}", model.count))
1230 }
1231 }
1232
1233 #[test]
1234 fn shell_dispatches_fired_input_restore_and_start() {
1235 use crux_core::App as _;
1236 let shell = MobilerShell::<CounterApp>::default();
1237 let mut m = CounterModel::default();
1238
1239 let _ = shell.update(Action::Fired { token: serde_json::to_string(&CounterEv::Add(5)).unwrap() }, &mut m);
1241 assert_eq!(m.count, 5);
1242 let _ = shell.update(Action::Input { id: "name".into(), value: InputValue::Text("bob".into()) }, &mut m);
1244 assert_eq!(m.last_input, "name=bob");
1245 let _ = shell.update(Action::Restore { data: "saved".into() }, &mut m);
1247 assert_eq!(m.restored, "saved");
1248 let _ = shell.update(Action::Start, &mut m);
1250 assert!(m.started);
1251 assert!(matches!(shell.view(&m), Widget::Text { .. }));
1253 }
1254
1255 #[test]
1256 fn shell_ignores_a_malformed_fired_token() {
1257 use crux_core::App as _;
1258 let shell = MobilerShell::<CounterApp>::default();
1259 let mut m = CounterModel::default();
1260 let _ = shell.update(Action::Fired { token: "not a valid token".into() }, &mut m);
1263 assert_eq!(m.count, 0);
1264 }
1265}