1use std::marker::PhantomData;
9
10pub mod bunny;
11pub mod format;
12pub mod http;
13pub mod i18n;
14pub use format::{Currency, Locale};
15pub use http::{HttpHeader, HttpOutcome};
16pub use i18n::{Catalog, negotiate};
17
18use crux_core::{
19 App, Command,
20 capability::Operation,
21 macros::effect,
22 render::{RenderOperation, render},
23};
24use facet::Facet;
25use serde::{Deserialize, Serialize, de::DeserializeOwned};
26
27pub use mobiler_ui::{
28 A11yRole, Action, BoxAlign, ButtonStyle, Caption, CardStyle, ChartBracket, ChartLegendItem, ChartRefLine, ChartRegion,
29 ChartSeries, ChartStyle, ChartTick, Corner, Density, Fab, FieldKind, FontFamily, Icon,
30 ImageRatio, ImageShape, InputValue, MapMarker, ProjectColor, Rgb, Segment, Sheet, Spacing, SwipeButton, Tab,
31 TextStyle, Theme, Tone, Widget,
32};
33
34#[effect(facet_typegen)]
38#[derive(Debug)]
39pub enum Effect {
40 Render(RenderOperation),
41 PluginNotify(PluginNotify),
43 Plugin(PluginCall),
45 PluginStream(PluginStreamCall),
50}
51
52#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
53pub struct PluginNotify {
54 pub plugin: String,
55 pub op: String,
56 pub input: String,
57}
58impl Operation for PluginNotify {
59 type Output = ();
60}
61
62#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
63pub struct PluginCall {
64 pub plugin: String,
65 pub op: String,
66 pub input: String,
67}
68impl Operation for PluginCall {
69 type Output = PluginResponse;
70}
71
72#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
76pub struct PluginStreamCall {
77 pub key: String,
78 pub plugin: String,
79 pub op: String,
80 pub input: String,
81}
82impl Operation for PluginStreamCall {
83 type Output = PluginResponse;
84}
85
86#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
95pub struct PluginResponse {
96 pub ok: bool,
97 pub output: Vec<u8>,
98}
99
100impl PluginResponse {
101 pub fn text(ok: bool, s: impl Into<String>) -> Self {
103 Self { ok, output: s.into().into_bytes() }
104 }
105
106 pub fn as_text(&self) -> Option<&str> {
108 std::str::from_utf8(&self.output).ok()
109 }
110}
111
112type Continuation<E> = Box<dyn FnOnce(PluginResponse) -> E + Send>;
113type StreamContinuation<E> = Box<dyn Fn(PluginResponse) -> E + Send>;
115
116pub struct Cx<E> {
119 notifications: Vec<PluginNotify>,
120 requests: Vec<(PluginCall, Continuation<E>)>,
121 streams: Vec<(PluginStreamCall, StreamContinuation<E>)>,
122}
123
124impl<E> Default for Cx<E> {
125 fn default() -> Self {
126 Self { notifications: Vec::new(), requests: Vec::new(), streams: Vec::new() }
127 }
128}
129
130impl<E> Cx<E> {
131 pub fn notify(&mut self, plugin: impl Into<String>, op: impl Into<String>, input: impl Into<String>) {
133 self.notifications.push(PluginNotify { plugin: plugin.into(), op: op.into(), input: input.into() });
134 }
135
136 pub fn plugin(
139 &mut self,
140 plugin: impl Into<String>,
141 op: impl Into<String>,
142 input: impl Into<String>,
143 then: impl FnOnce(PluginResponse) -> E + Send + 'static,
144 ) {
145 self.requests
146 .push((PluginCall { plugin: plugin.into(), op: op.into(), input: input.into() }, Box::new(then)));
147 }
148
149 pub fn subscribe(
157 &mut self,
158 key: impl Into<String>,
159 plugin: impl Into<String>,
160 op: impl Into<String>,
161 input: impl Into<String>,
162 on_event: impl Fn(PluginResponse) -> E + Send + 'static,
163 ) {
164 self.streams.push((
165 PluginStreamCall { key: key.into(), plugin: plugin.into(), op: op.into(), input: input.into() },
166 Box::new(on_event),
167 ));
168 }
169
170 pub fn unsubscribe(&mut self, key: impl Into<String>) {
174 self.notify("stream", "unsubscribe", key);
175 }
176
177 pub fn save(&mut self, data: impl Into<String>) {
179 self.notify("storage", "save", data);
180 }
181
182 pub fn copy(&mut self, text: impl Into<String>) {
184 self.notify("clipboard", "copy", text);
185 }
186
187 pub fn share(&mut self, text: impl Into<String>) {
189 self.notify("share", "text", text);
190 }
191
192 pub fn open_url(&mut self, url: impl Into<String>) {
195 self.notify("browser", "open", url);
196 }
197
198 pub fn toast(&mut self, text: impl Into<String>) {
200 self.notify("toast", "show", text);
201 }
202
203 pub fn haptic(&mut self, style: impl Into<String>) {
206 self.notify("haptics", style, "");
207 }
208
209 pub fn request(
224 &mut self,
225 method: impl Into<String>,
226 url: impl Into<String>,
227 ) -> crate::http::RequestBuilder<'_, E> {
228 crate::http::RequestBuilder::new(self, method.into(), url.into())
229 }
230
231 pub fn get(&mut self, url: impl Into<String>, then: impl FnOnce(HttpOutcome) -> E + Send + 'static) {
233 self.request("GET", url).send(then);
234 }
235 pub fn post(&mut self, url: impl Into<String>, body: impl Into<String>, then: impl FnOnce(HttpOutcome) -> E + Send + 'static) {
237 self.request("POST", url).body(body).send(then);
238 }
239 pub fn put(&mut self, url: impl Into<String>, body: impl Into<String>, then: impl FnOnce(HttpOutcome) -> E + Send + 'static) {
241 self.request("PUT", url).body(body).send(then);
242 }
243 pub fn patch(&mut self, url: impl Into<String>, body: impl Into<String>, then: impl FnOnce(HttpOutcome) -> E + Send + 'static) {
245 self.request("PATCH", url).body(body).send(then);
246 }
247 pub fn delete(&mut self, url: impl Into<String>, then: impl FnOnce(HttpOutcome) -> E + Send + 'static) {
249 self.request("DELETE", url).send(then);
250 }
251
252 pub fn device_model(&mut self, then: impl FnOnce(PluginResponse) -> E + Send + 'static) {
256 self.plugin("device", "model", "", then);
257 }
258
259 pub fn device_locale(&mut self, then: impl FnOnce(PluginResponse) -> E + Send + 'static) {
264 self.plugin("device", "locale", "", then);
265 }
266
267 pub fn pick_photo(&mut self, then: impl FnOnce(PluginResponse) -> E + Send + 'static) {
272 self.plugin("photo", "pick", "", then);
273 }
274
275 pub fn capture_photo(&mut self, then: impl FnOnce(PluginResponse) -> E + Send + 'static) {
282 self.plugin("camera", "capture", "", then);
283 }
284
285 pub fn confirm(
289 &mut self,
290 title: impl Into<String>,
291 message: impl Into<String>,
292 then: impl FnOnce(PluginResponse) -> E + Send + 'static,
293 ) {
294 #[derive(Serialize)]
295 struct Confirm {
296 title: String,
297 message: String,
298 }
299 let input = serde_json::to_string(&Confirm { title: title.into(), message: message.into() })
300 .expect("serialize confirm");
301 self.plugin("dialog", "confirm", input, then);
302 }
303
304 pub fn pick_date(&mut self, then: impl FnOnce(PluginResponse) -> E + Send + 'static) {
309 self.plugin("datetime", "date", "", then);
310 }
311
312 pub fn pick_time(&mut self, then: impl FnOnce(PluginResponse) -> E + Send + 'static) {
317 self.plugin("datetime", "time", "", then);
318 }
319
320 pub fn now(&mut self, then: impl FnOnce(PluginResponse) -> E + Send + 'static) {
327 self.plugin("datetime", "now", "", then);
328 }
329}
330
331pub trait MobilerApp: Default {
336 type Event: Serialize + DeserializeOwned + Send + 'static;
337 type Model: Default;
338
339 fn update(&self, event: Self::Event, model: &mut Self::Model, cx: &mut Cx<Self::Event>);
340
341 fn input(&self, id: &str, value: InputValue, model: &mut Self::Model, cx: &mut Cx<Self::Event>) {
342 let _ = (id, value, model, cx);
343 }
344
345 fn restore(&self, data: &str, model: &mut Self::Model) {
348 let _ = (data, model);
349 }
350
351 fn init(&self, model: &mut Self::Model, cx: &mut Cx<Self::Event>) {
354 let _ = (model, cx);
355 }
356
357 fn view(&self, model: &Self::Model) -> Widget;
358}
359
360pub struct MobilerShell<A>(PhantomData<fn() -> A>);
362
363impl<A> Default for MobilerShell<A> {
364 fn default() -> Self {
365 Self(PhantomData)
366 }
367}
368
369impl<A: MobilerApp> App for MobilerShell<A> {
370 type Event = Action;
371 type Model = A::Model;
372 type ViewModel = Widget;
373 type Effect = Effect;
374
375 fn update(&self, action: Action, model: &mut Self::Model) -> Command<Effect, Action> {
376 let app = A::default();
377 let mut cx = Cx::<A::Event>::default();
378 match action {
379 Action::Fired { token } => {
380 if let Ok(event) = serde_json::from_str::<A::Event>(&token) {
381 app.update(event, model, &mut cx);
382 }
383 }
384 Action::Input { id, value } => app.input(&id, value, model, &mut cx),
385 Action::Restore { data } => app.restore(&data, model),
386 Action::Start => app.init(model, &mut cx),
387 }
388 let mut commands: Vec<Command<Effect, Action>> = Vec::new();
389 for op in cx.notifications {
390 commands.push(Command::notify_shell(op).build());
391 }
392 for (op, then) in cx.requests {
393 commands.push(Command::request_from_shell(op).then_send(move |response: PluginResponse| {
394 Action::Fired { token: serde_json::to_string(&then(response)).expect("serialize event") }
395 }));
396 }
397 for (op, then) in cx.streams {
398 commands.push(Command::stream_from_shell(op).then_send(move |response: PluginResponse| {
401 Action::Fired { token: serde_json::to_string(&then(response)).expect("serialize event") }
402 }));
403 }
404 commands.push(render());
405 Command::all(commands)
406 }
407
408 fn view(&self, model: &Self::Model) -> Widget {
409 A::default().view(model)
410 }
411}
412
413#[derive(Clone, Debug)]
432pub struct Nav<R> {
433 stack: Vec<R>,
434}
435
436impl<R: Clone + Serialize> Nav<R> {
437 #[must_use]
439 pub fn new(root: R) -> Self {
440 Self { stack: vec![root] }
441 }
442 pub fn push(&mut self, route: R) {
444 self.stack.push(route);
445 }
446 pub fn pop(&mut self) {
448 if self.stack.len() > 1 {
449 self.stack.pop();
450 }
451 }
452 pub fn reset(&mut self, root: R) {
454 self.stack = vec![root];
455 }
456 #[must_use]
458 pub fn current(&self) -> &R {
459 self.stack.last().expect("nav stack is never empty")
460 }
461 #[must_use]
463 pub fn depth(&self) -> u32 {
464 self.stack.len() as u32
465 }
466 #[must_use]
468 pub fn can_go_back(&self) -> bool {
469 self.stack.len() > 1
470 }
471 fn route_key(&self) -> String {
474 serde_json::to_string(self.current()).expect("serialize route")
475 }
476}
477
478fn tok<E: Serialize>(event: E) -> String {
482 serde_json::to_string(&event).expect("serialize event")
483}
484
485#[must_use]
486pub fn styled(content: impl Into<String>, style: TextStyle) -> Widget {
487 Widget::Text { content: content.into(), style }
488}
489#[must_use]
490pub fn text(content: impl Into<String>) -> Widget { styled(content, TextStyle::Body) }
491#[must_use]
492pub fn title(content: impl Into<String>) -> Widget { styled(content, TextStyle::Title) }
493#[must_use]
494pub fn subtitle(content: impl Into<String>) -> Widget { styled(content, TextStyle::Subtitle) }
495#[must_use]
496pub fn caption(content: impl Into<String>) -> Widget { styled(content, TextStyle::Caption) }
497#[must_use]
498pub fn emphasis(content: impl Into<String>) -> Widget { styled(content, TextStyle::Emphasis) }
499
500#[must_use]
501pub fn image(source: impl Into<String>, shape: ImageShape, ratio: ImageRatio) -> Widget {
502 Widget::Image { source: source.into(), shape, ratio }
503}
504#[must_use]
505pub fn badge(label: impl Into<String>, tone: Tone) -> Widget {
506 Widget::Badge { label: label.into(), tone }
507}
508#[must_use]
510pub fn color_dot(color: ProjectColor) -> Widget {
511 Widget::ColorDot { color }
512}
513#[must_use]
514pub fn divider() -> Widget { Widget::Divider }
515#[must_use]
517pub fn progress(value: Option<f32>) -> Widget { Widget::Progress { value } }
518#[must_use]
520pub fn skeleton() -> Widget { Widget::Skeleton }
521#[must_use]
525pub fn pdf_view(url: impl Into<String>) -> Widget { Widget::PdfView { url: url.into() } }
526#[must_use]
535pub fn video_player<E: Serialize>(id: impl Into<String>, url: impl Into<String>, playing: bool, seek_to_ms: i64, on_ended: E) -> Widget {
536 Widget::Video {
537 url: url.into(),
538 id: id.into(),
539 playing,
540 seek_to_ms,
541 controls: true,
542 looping: false,
543 muted: false,
544 on_ended: Some(tok(on_ended)),
545 poster: None,
546 start_at_ms: -1,
547 captions: Vec::new(),
548 rate: 1.0,
549 volume: 1.0,
550 urls: Vec::new(),
551 start_index: 0,
552 seek_index: -1,
553 allow_pip: false,
554 }
555}
556#[must_use]
562pub fn video_playlist<E: Serialize>(id: impl Into<String>, urls: Vec<String>, start_index: i64, playing: bool, on_ended: E) -> Widget {
563 Widget::Video {
564 url: urls.first().cloned().unwrap_or_default(),
565 id: id.into(),
566 playing,
567 seek_to_ms: -1,
568 controls: true,
569 looping: false,
570 muted: false,
571 on_ended: Some(tok(on_ended)),
572 poster: None,
573 start_at_ms: -1,
574 captions: Vec::new(),
575 rate: 1.0,
576 volume: 1.0,
577 urls,
578 start_index,
579 seek_index: -1,
580 allow_pip: false,
581 }
582}
583fn map_video(widget: Widget, f: impl FnOnce(&mut VideoFields)) -> Widget {
586 match widget {
587 Widget::Video { url, id, playing, seek_to_ms, controls, looping, muted, on_ended,
588 poster, start_at_ms, captions, rate, volume, urls, start_index, seek_index, allow_pip } => {
589 let mut v = VideoFields { url, id, playing, seek_to_ms, controls, looping, muted, on_ended,
590 poster, start_at_ms, captions, rate, volume, urls, start_index, seek_index, allow_pip };
591 f(&mut v);
592 Widget::Video { url: v.url, id: v.id, playing: v.playing, seek_to_ms: v.seek_to_ms,
593 controls: v.controls, looping: v.looping, muted: v.muted, on_ended: v.on_ended,
594 poster: v.poster, start_at_ms: v.start_at_ms, captions: v.captions, rate: v.rate,
595 volume: v.volume, urls: v.urls, start_index: v.start_index, seek_index: v.seek_index,
596 allow_pip: v.allow_pip }
597 }
598 other => other,
599 }
600}
601struct VideoFields {
602 url: String, id: String, playing: bool, seek_to_ms: i64, controls: bool, looping: bool,
603 muted: bool, on_ended: Option<String>, poster: Option<String>, start_at_ms: i64,
604 captions: Vec<Caption>, rate: f32, volume: f32, urls: Vec<String>, start_index: i64,
605 seek_index: i64, allow_pip: bool,
606}
607#[must_use]
609pub fn with_loop(widget: Widget) -> Widget { map_video(widget, |v| v.looping = true) }
610#[must_use]
612pub fn with_muted(widget: Widget) -> Widget { map_video(widget, |v| v.muted = true) }
613#[must_use]
615pub fn without_controls(widget: Widget) -> Widget { map_video(widget, |v| v.controls = false) }
616#[must_use]
618pub fn with_poster(widget: Widget, poster: impl Into<String>) -> Widget {
619 let poster = poster.into();
620 map_video(widget, move |v| v.poster = Some(poster))
621}
622#[must_use]
624pub fn with_start_at(widget: Widget, start_at_ms: i64) -> Widget {
625 map_video(widget, move |v| v.start_at_ms = start_at_ms)
626}
627#[must_use]
629pub fn with_captions(widget: Widget, captions: Vec<Caption>) -> Widget {
630 map_video(widget, move |v| v.captions = captions)
631}
632#[must_use]
634pub fn with_rate(widget: Widget, rate: f32) -> Widget { map_video(widget, move |v| v.rate = rate) }
635#[must_use]
637pub fn with_volume(widget: Widget, volume: f32) -> Widget {
638 map_video(widget, move |v| v.volume = volume.clamp(0.0, 1.0))
639}
640#[must_use]
642pub fn with_seek_index(widget: Widget, index: i64) -> Widget {
643 map_video(widget, move |v| v.seek_index = index)
644}
645#[must_use]
647pub fn with_pip(widget: Widget) -> Widget { map_video(widget, |v| v.allow_pip = true) }
648#[must_use]
653pub fn web_view(url: impl Into<String>) -> Widget { Widget::WebView { url: url.into() } }
654
655#[must_use]
661pub fn map(id: impl Into<String>, center_lat: f64, center_lng: f64, zoom: f64) -> Widget {
662 Widget::Map {
663 id: id.into(),
664 center_lat,
665 center_lng,
666 zoom,
667 markers: Vec::new(),
668 style_url: None,
669 interactive: true,
670 }
671}
672#[must_use]
674pub fn with_markers(widget: Widget, markers: Vec<MapMarker>) -> Widget {
675 match widget {
676 Widget::Map { id, center_lat, center_lng, zoom, style_url, interactive, .. } =>
677 Widget::Map { id, center_lat, center_lng, zoom, markers, style_url, interactive },
678 other => other,
679 }
680}
681#[must_use]
683pub fn with_map_style(widget: Widget, url: impl Into<String>) -> Widget {
684 match widget {
685 Widget::Map { id, center_lat, center_lng, zoom, markers, interactive, .. } =>
686 Widget::Map { id, center_lat, center_lng, zoom, markers, style_url: Some(url.into()), interactive },
687 other => other,
688 }
689}
690#[must_use]
692pub fn marker(id: impl Into<String>, lat: f64, lng: f64) -> MapMarker {
693 MapMarker { id: id.into(), lat, lng, title: None }
694}
695#[must_use]
697pub fn marker_titled(id: impl Into<String>, lat: f64, lng: f64, title: impl Into<String>) -> MapMarker {
698 MapMarker { id: id.into(), lat, lng, title: Some(title.into()) }
699}
700fn one_series(values: Vec<f32>) -> Vec<ChartSeries> {
702 vec![ChartSeries { name: String::new(), values, color: None, goal: None }]
703}
704
705#[must_use]
708pub fn bar_chart(values: Vec<f32>, labels: Vec<String>) -> Widget {
709 Widget::Chart { series: one_series(values), labels, style: ChartStyle::Bar, axis: false, legend: false }
710}
711#[must_use]
714pub fn line_chart(values: Vec<f32>, labels: Vec<String>) -> Widget {
715 Widget::Chart { series: one_series(values), labels, style: ChartStyle::Line, axis: false, legend: false }
716}
717#[must_use]
721pub fn chart(series: Vec<ChartSeries>, labels: Vec<String>, style: ChartStyle, axis: bool, legend: bool) -> Widget {
722 Widget::Chart { series, labels, style, axis, legend }
723}
724#[must_use]
726pub fn stacked_bar_chart(series: Vec<ChartSeries>, labels: Vec<String>) -> Widget {
727 chart(series, labels, ChartStyle::StackedBar, true, true)
728}
729#[must_use]
731pub fn pct_stacked_bar_chart(series: Vec<ChartSeries>, labels: Vec<String>) -> Widget {
732 chart(series, labels, ChartStyle::StackedBar100, false, true)
733}
734#[must_use]
736pub fn pie_chart(series: Vec<ChartSeries>) -> Widget {
737 chart(series, vec![], ChartStyle::Pie, false, true)
738}
739#[must_use]
741pub fn donut_chart(series: Vec<ChartSeries>) -> Widget {
742 chart(series, vec![], ChartStyle::Donut, false, true)
743}
744#[must_use]
747pub fn rings_chart(series: Vec<ChartSeries>) -> Widget {
748 chart(series, vec![], ChartStyle::Rings, false, true)
749}
750#[must_use]
752pub fn gauge_chart(series: ChartSeries) -> Widget {
753 chart(vec![series], vec![], ChartStyle::Gauge, false, false)
754}
755
756#[must_use]
761pub fn region_chart(
762 regions: Vec<ChartRegion>,
763 ticks: Vec<ChartTick>,
764 x_max: f32,
765 y_max: f32,
766 ref_lines: Vec<ChartRefLine>,
767 legend: Vec<ChartLegendItem>,
768) -> Widget {
769 Widget::RegionChart { regions, ticks, x_max, y_max, ref_lines, bracket: None, legend }
770}
771
772#[must_use]
774pub fn with_bracket(widget: Widget, bracket: ChartBracket) -> Widget {
775 match widget {
776 Widget::RegionChart { regions, ticks, x_max, y_max, ref_lines, legend, .. } => {
777 Widget::RegionChart { regions, ticks, x_max, y_max, ref_lines, bracket: Some(bracket), legend }
778 }
779 other => other,
780 }
781}
782
783fn days_in_month(year: u32, month: u8) -> u8 {
785 match month {
786 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
787 4 | 6 | 9 | 11 => 30,
788 2 => if (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400) { 29 } else { 28 },
789 _ => 30,
790 }
791}
792
793fn weekday(year: u32, month: u8, day: u8) -> u8 {
795 const T: [u32; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
796 let y = if month < 3 { year - 1 } else { year };
797 let m = month as usize - 1;
798 ((y + y / 4 - y / 100 + y / 400 + T[m] + u32::from(day)) % 7) as u8
799}
800
801#[must_use]
805pub fn calendar<E: Serialize>(year: u32, month: u8, selected: Option<u8>, on_day: impl Fn(u8) -> E) -> Widget {
806 let n = days_in_month(year, month);
807 let on_day = (1..=n).map(|d| tok(on_day(d))).collect();
808 Widget::Calendar { year, month, first_weekday: weekday(year, month, 1), selected, on_day }
809}
810
811#[must_use]
814pub fn swipe_action<S: Into<String>, E: Serialize>(child: Widget, actions: Vec<(S, Tone, E)>) -> Widget {
815 Widget::SwipeAction {
816 child: Box::new(child),
817 actions: actions
818 .into_iter()
819 .map(|(label, tone, ev)| SwipeButton { label: label.into(), tone, on_tap: tok(ev) })
820 .collect(),
821 }
822}
823#[must_use]
824pub fn spacer(size: Spacing) -> Widget { Widget::Spacer { size } }
825
826#[must_use]
827pub fn row(children: Vec<Widget>) -> Widget { Widget::Row { children } }
828#[must_use]
829pub fn column(children: Vec<Widget>) -> Widget { Widget::Column { children } }
830#[must_use]
831pub fn card(child: Widget, style: CardStyle) -> Widget {
832 Widget::Card { child: Box::new(child), style, on_press: None, on_long_press: None }
833}
834#[must_use]
836pub fn card_button<E: Serialize>(child: Widget, style: CardStyle, on_press: E) -> Widget {
837 Widget::Card { child: Box::new(child), style, on_press: Some(tok(on_press)), on_long_press: None }
838}
839#[must_use]
842pub fn with_long_press<E: Serialize>(widget: Widget, on_long_press: E) -> Widget {
843 match widget {
844 Widget::Card { child, style, on_press, .. } => Widget::Card {
845 child,
846 style,
847 on_press,
848 on_long_press: Some(tok(on_long_press)),
849 },
850 other => other,
851 }
852}
853#[must_use]
856pub fn stack(align: BoxAlign, scrim: bool, children: Vec<Widget>) -> Widget {
857 Widget::Box { children, align, scrim }
858}
859#[must_use]
860pub fn grid(children: Vec<Widget>) -> Widget { Widget::Grid { children } }
861#[must_use]
866pub fn split<E: Serialize>(primary: Widget, detail: Widget, show_detail: bool, on_back: E) -> Widget {
867 Widget::Split { primary: Box::new(primary), detail: Box::new(detail), show_detail, on_back: Some(tok(on_back)) }
868}
869#[must_use]
871pub fn scroller(children: Vec<Widget>) -> Widget { Widget::Scroller { children } }
872#[must_use]
874pub fn avatar(source: impl Into<String>) -> Widget { Widget::Avatar { source: source.into(), status: None } }
875#[must_use]
877pub fn avatar_status(source: impl Into<String>, status: Tone) -> Widget {
878 Widget::Avatar { source: source.into(), status: Some(status) }
879}
880#[must_use]
882pub fn rating(value: u32, max: u8) -> Widget { Widget::Rating { value, max, on_rate: None } }
883#[must_use]
885pub fn rating_input<E: Serialize>(value: u32, max: u8, on_rate: Vec<E>) -> Widget {
886 Widget::Rating { value, max, on_rate: Some(on_rate.into_iter().map(tok).collect()) }
887}
888
889#[must_use]
890pub fn button<E: Serialize>(label: impl Into<String>, style: ButtonStyle, on_press: E) -> Widget {
891 Widget::Button { label: label.into(), style, on_press: tok(on_press) }
892}
893#[must_use]
894pub fn icon_button<E: Serialize>(icon: Icon, on_press: E) -> Widget {
895 Widget::IconButton { icon, on_press: tok(on_press) }
896}
897#[must_use]
898pub fn chip<E: Serialize>(label: impl Into<String>, selected: bool, on_press: E) -> Widget {
899 Widget::Chip { label: label.into(), selected, on_press: tok(on_press) }
900}
901#[must_use]
902pub fn text_field(id: impl Into<String>, placeholder: impl Into<String>, value: impl Into<String>) -> Widget {
903 Widget::TextField { id: id.into(), placeholder: placeholder.into(), value: value.into(), kind: FieldKind::Text, error: None }
904}
905#[must_use]
909pub fn field(id: impl Into<String>, placeholder: impl Into<String>, value: impl Into<String>, kind: FieldKind, error: Option<String>) -> Widget {
910 Widget::TextField { id: id.into(), placeholder: placeholder.into(), value: value.into(), kind, error }
911}
912#[must_use]
914pub fn secure_field(id: impl Into<String>, placeholder: impl Into<String>, value: impl Into<String>) -> Widget {
915 field(id, placeholder, value, FieldKind::Secure, None)
916}
917#[must_use]
919pub fn email_field(id: impl Into<String>, placeholder: impl Into<String>, value: impl Into<String>) -> Widget {
920 field(id, placeholder, value, FieldKind::Email, None)
921}
922#[must_use]
924pub fn number_field(id: impl Into<String>, placeholder: impl Into<String>, value: impl Into<String>) -> Widget {
925 field(id, placeholder, value, FieldKind::Number, None)
926}
927#[must_use]
929pub fn decimal_field(id: impl Into<String>, placeholder: impl Into<String>, value: impl Into<String>) -> Widget {
930 field(id, placeholder, value, FieldKind::Decimal, None)
931}
932#[must_use]
934pub fn phone_field(id: impl Into<String>, placeholder: impl Into<String>, value: impl Into<String>) -> Widget {
935 field(id, placeholder, value, FieldKind::Phone, None)
936}
937#[must_use]
939pub fn url_field(id: impl Into<String>, placeholder: impl Into<String>, value: impl Into<String>) -> Widget {
940 field(id, placeholder, value, FieldKind::Url, None)
941}
942#[must_use]
944pub fn multiline_field(id: impl Into<String>, placeholder: impl Into<String>, value: impl Into<String>) -> Widget {
945 field(id, placeholder, value, FieldKind::Multiline, None)
946}
947#[must_use]
950pub fn with_error(widget: Widget, message: impl Into<String>) -> Widget {
951 match widget {
952 Widget::TextField { id, placeholder, value, kind, .. } =>
953 Widget::TextField { id, placeholder, value, kind, error: Some(message.into()) },
954 other => other,
955 }
956}
957
958#[must_use]
962pub fn a11y(child: Widget, label: impl Into<String>) -> Widget {
963 Widget::A11y { child: Box::new(child), label: label.into(), hint: None, role: None }
964}
965#[must_use]
968pub fn with_a11y_hint(widget: Widget, hint: impl Into<String>) -> Widget {
969 match widget {
970 Widget::A11y { child, label, role, .. } =>
971 Widget::A11y { child, label, hint: Some(hint.into()), role },
972 other => Widget::A11y { child: Box::new(other), label: String::new(), hint: Some(hint.into()), role: None },
973 }
974}
975#[must_use]
977pub fn with_a11y_role(widget: Widget, role: A11yRole) -> Widget {
978 match widget {
979 Widget::A11y { child, label, hint, .. } =>
980 Widget::A11y { child, label, hint, role: Some(role) },
981 other => Widget::A11y { child: Box::new(other), label: String::new(), hint: None, role: Some(role) },
982 }
983}
984#[must_use]
986pub fn search_field(id: impl Into<String>, placeholder: impl Into<String>, value: impl Into<String>) -> Widget {
987 Widget::SearchField { id: id.into(), placeholder: placeholder.into(), value: value.into() }
988}
989#[must_use]
991pub fn segment<E: Serialize>(label: impl Into<String>, selected: bool, on_select: E) -> Segment {
992 Segment { label: label.into(), selected, on_select: tok(on_select) }
993}
994#[must_use]
996pub fn segmented(segments: Vec<Segment>) -> Widget {
997 Widget::Segmented { segments }
998}
999#[must_use]
1000pub fn toggle(id: impl Into<String>, label: impl Into<String>, value: bool) -> Widget {
1001 Widget::Toggle { id: id.into(), label: label.into(), value }
1002}
1003#[must_use]
1004pub fn checkbox(id: impl Into<String>, label: impl Into<String>, value: bool) -> Widget {
1005 Widget::Checkbox { id: id.into(), label: label.into(), value }
1006}
1007#[must_use]
1008pub fn slider(id: impl Into<String>, value: i32, max: i32) -> Widget {
1009 Widget::Slider { id: id.into(), value, max }
1010}
1011#[must_use]
1012pub fn stepper<E: Serialize>(value: i32, on_decrement: E, on_increment: E) -> Widget {
1013 Widget::Stepper { value, on_decrement: tok(on_decrement), on_increment: tok(on_increment) }
1014}
1015
1016#[must_use]
1018pub fn tab<E: Serialize>(label: impl Into<String>, selected: bool, on_select: E) -> Tab {
1019 Tab { label: label.into(), selected, on_select: tok(on_select), icon: None }
1020}
1021
1022#[must_use]
1024pub fn tab_icon<E: Serialize>(label: impl Into<String>, icon: Icon, selected: bool, on_select: E) -> Tab {
1025 Tab { label: label.into(), selected, on_select: tok(on_select), icon: Some(icon) }
1026}
1027
1028#[must_use]
1031pub fn scaffold(title: impl Into<String>, dark_mode: bool, tabs: Vec<Tab>, body: Widget) -> Widget {
1032 let title = title.into();
1033 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 }
1035}
1036
1037#[must_use]
1041pub fn scaffold_back<E: Serialize>(title: impl Into<String>, dark_mode: bool, tabs: Vec<Tab>, body: Widget, back: E) -> Widget {
1042 let title = title.into();
1043 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 }
1044}
1045
1046#[must_use]
1051pub fn nav_scaffold<R, E>(
1052 title: impl Into<String>,
1053 dark_mode: bool,
1054 tabs: Vec<Tab>,
1055 body: Widget,
1056 nav: &Nav<R>,
1057 on_back: E,
1058) -> Widget
1059where
1060 R: Clone + Serialize,
1061 E: Serialize,
1062{
1063 Widget::Scaffold {
1064 title: title.into(),
1065 body: Box::new(body),
1066 tabs,
1067 back: if nav.can_go_back() { Some(tok(on_back)) } else { None },
1068 dark_mode,
1069 theme: None,
1070 fab: None,
1071 sheet: None,
1072 on_refresh: None,
1073 refreshing: false,
1074 route: nav.route_key(),
1075 depth: nav.depth(),
1076 }
1077}
1078
1079pub fn with_theme(widget: Widget, theme: Theme) -> Widget {
1083 match widget {
1084 Widget::Scaffold { title, body, tabs, back, dark_mode, fab, sheet, on_refresh, refreshing, route, depth, .. } => Widget::Scaffold {
1085 title,
1086 body,
1087 tabs,
1088 back,
1089 dark_mode,
1090 theme: Some(theme),
1091 fab,
1092 sheet,
1093 on_refresh,
1094 refreshing,
1095 route,
1096 depth,
1097 },
1098 other => other,
1099 }
1100}
1101
1102pub fn with_fab<E: Serialize>(widget: Widget, icon: Icon, on_press: E) -> Widget {
1105 match widget {
1106 Widget::Scaffold { title, body, tabs, back, dark_mode, theme, sheet, on_refresh, refreshing, route, depth, .. } => Widget::Scaffold {
1107 title,
1108 body,
1109 tabs,
1110 back,
1111 dark_mode,
1112 theme,
1113 fab: Some(Fab { icon, on_press: tok(on_press) }),
1114 sheet,
1115 on_refresh,
1116 refreshing,
1117 route,
1118 depth,
1119 },
1120 other => other,
1121 }
1122}
1123
1124pub fn with_sheet<E: Serialize>(widget: Widget, title: impl Into<String>, child: Widget, on_dismiss: E) -> Widget {
1127 match widget {
1128 Widget::Scaffold { title: t, body, tabs, back, dark_mode, theme, fab, on_refresh, refreshing, route, depth, .. } => Widget::Scaffold {
1129 title: t,
1130 body,
1131 tabs,
1132 back,
1133 dark_mode,
1134 theme,
1135 fab,
1136 sheet: Some(Sheet { title: title.into(), child: Box::new(child), on_dismiss: tok(on_dismiss) }),
1137 on_refresh,
1138 refreshing,
1139 route,
1140 depth,
1141 },
1142 other => other,
1143 }
1144}
1145
1146pub fn with_refresh<E: Serialize>(widget: Widget, refreshing: bool, on_refresh: E) -> Widget {
1150 match widget {
1151 Widget::Scaffold { title, body, tabs, back, dark_mode, theme, fab, sheet, route, depth, .. } => Widget::Scaffold {
1152 title,
1153 body,
1154 tabs,
1155 back,
1156 dark_mode,
1157 theme,
1158 fab,
1159 sheet,
1160 on_refresh: Some(tok(on_refresh)),
1161 refreshing,
1162 route,
1163 depth,
1164 },
1165 Widget::LazyList { children, on_load_more, loading, has_more, .. } => Widget::LazyList {
1168 children,
1169 on_load_more,
1170 loading,
1171 has_more,
1172 on_refresh: Some(tok(on_refresh)),
1173 refreshing,
1174 },
1175 other => other,
1176 }
1177}
1178
1179#[must_use]
1185pub fn lazy_list<E: Serialize>(children: Vec<Widget>, loading: bool, has_more: bool, on_load_more: E) -> Widget {
1186 Widget::LazyList {
1187 children,
1188 on_load_more: Some(tok(on_load_more)),
1189 loading,
1190 has_more,
1191 on_refresh: None,
1192 refreshing: false,
1193 }
1194}
1195
1196#[must_use]
1198pub fn lazy_list_static(children: Vec<Widget>) -> Widget {
1199 Widget::LazyList { children, on_load_more: None, loading: false, has_more: false, on_refresh: None, refreshing: false }
1200}
1201
1202#[cfg(test)]
1203mod tests {
1204 use super::*;
1205 use serde::Serialize;
1206
1207 #[derive(Clone, Copy, Serialize, PartialEq, Debug)]
1208 enum Route {
1209 Home,
1210 Detail(u32),
1211 }
1212
1213 #[derive(Serialize)]
1214 enum Ev {
1215 Tap,
1216 Open(u32),
1217 }
1218
1219 #[test]
1222 fn plugin_response_carries_bytes_and_converts_text() {
1223 let r = PluginResponse::text(true, "hello");
1224 assert!(r.ok);
1225 assert_eq!(r.output, b"hello".to_vec());
1226 assert_eq!(r.as_text(), Some("hello"));
1227
1228 let binary = PluginResponse { ok: true, output: vec![0xff, 0xfe] };
1229 assert_eq!(binary.as_text(), None, "invalid UTF-8 must not panic");
1230 }
1231
1232 #[test]
1235 fn nav_push_pop_depth() {
1236 let mut nav = Nav::new(Route::Home);
1237 assert_eq!(nav.depth(), 1);
1238 assert!(!nav.can_go_back());
1239
1240 nav.push(Route::Detail(7));
1241 assert_eq!(nav.depth(), 2);
1242 assert!(nav.can_go_back());
1243 assert!(matches!(nav.current(), Route::Detail(7)));
1244
1245 nav.pop();
1246 assert_eq!(nav.depth(), 1);
1247 assert!(matches!(nav.current(), Route::Home));
1248
1249 nav.pop(); assert_eq!(nav.depth(), 1);
1251 }
1252
1253 #[test]
1254 fn nav_reset_replaces_stack() {
1255 let mut nav = Nav::new(Route::Home);
1256 nav.push(Route::Detail(1));
1257 nav.push(Route::Detail(2));
1258 nav.reset(Route::Detail(9));
1259 assert_eq!(nav.depth(), 1);
1260 assert!(matches!(nav.current(), Route::Detail(9)));
1261 }
1262
1263 #[test]
1264 fn nav_route_key_is_serialization() {
1265 let nav = Nav::new(Route::Detail(3));
1266 assert_eq!(nav.route_key(), serde_json::to_string(&Route::Detail(3)).unwrap());
1267 }
1268
1269 #[test]
1272 fn scaffold_sets_route_depth_and_no_back() {
1273 match scaffold("Home", false, vec![], text("x")) {
1274 Widget::Scaffold { route, depth, back, dark_mode, .. } => {
1275 assert_eq!(route, "Home");
1276 assert_eq!(depth, 1);
1277 assert!(back.is_none());
1278 assert!(!dark_mode);
1279 }
1280 other => panic!("expected Scaffold, got {other:?}"),
1281 }
1282 }
1283
1284 #[test]
1285 fn scaffold_back_is_depth_2_with_back() {
1286 match scaffold_back("Detail", true, vec![], text("x"), Ev::Tap) {
1287 Widget::Scaffold { depth, back, dark_mode, .. } => {
1288 assert_eq!(depth, 2);
1289 assert_eq!(back, Some(serde_json::to_string(&Ev::Tap).unwrap()));
1290 assert!(dark_mode);
1291 }
1292 other => panic!("expected Scaffold, got {other:?}"),
1293 }
1294 }
1295
1296 #[test]
1297 fn nav_scaffold_shows_back_only_when_poppable() {
1298 let mut nav = Nav::new(Route::Home);
1299 match nav_scaffold("T", false, vec![], text("x"), &nav, Ev::Tap) {
1301 Widget::Scaffold { back, depth, route, .. } => {
1302 assert!(back.is_none());
1303 assert_eq!(depth, 1);
1304 assert_eq!(route, serde_json::to_string(&Route::Home).unwrap());
1305 }
1306 other => panic!("expected Scaffold, got {other:?}"),
1307 }
1308 nav.push(Route::Detail(2));
1310 match nav_scaffold("T", false, vec![], text("x"), &nav, Ev::Tap) {
1311 Widget::Scaffold { back, depth, .. } => {
1312 assert_eq!(back, Some(serde_json::to_string(&Ev::Tap).unwrap()));
1313 assert_eq!(depth, 2);
1314 }
1315 other => panic!("expected Scaffold, got {other:?}"),
1316 }
1317 }
1318
1319 #[test]
1320 fn buttons_carry_serialized_event_tokens() {
1321 match button("Go", ButtonStyle::Filled, Ev::Open(5)) {
1322 Widget::Button { label, on_press, .. } => {
1323 assert_eq!(label, "Go");
1324 assert_eq!(on_press, serde_json::to_string(&Ev::Open(5)).unwrap());
1325 }
1326 other => panic!("expected Button, got {other:?}"),
1327 }
1328 match card_button(text("c"), CardStyle::Elevated, Ev::Tap) {
1329 Widget::Card { on_press, .. } => {
1330 assert_eq!(on_press, Some(serde_json::to_string(&Ev::Tap).unwrap()));
1331 }
1332 other => panic!("expected Card, got {other:?}"),
1333 }
1334 match card(text("c"), CardStyle::Elevated) {
1336 Widget::Card { on_press, on_long_press, .. } => {
1337 assert!(on_press.is_none());
1338 assert!(on_long_press.is_none());
1339 }
1340 other => panic!("expected Card, got {other:?}"),
1341 }
1342 match with_long_press(card_button(text("c"), CardStyle::Filled, Ev::Tap), Ev::Open(7)) {
1344 Widget::Card { on_press, on_long_press, .. } => {
1345 assert_eq!(on_press, Some(serde_json::to_string(&Ev::Tap).unwrap()));
1346 assert_eq!(on_long_press, Some(serde_json::to_string(&Ev::Open(7)).unwrap()));
1347 }
1348 other => panic!("expected Card, got {other:?}"),
1349 }
1350 assert!(matches!(with_long_press(text("x"), Ev::Tap), Widget::Text { .. }));
1352 }
1353
1354 #[test]
1357 fn cx_notify_and_save_enqueue_notifications() {
1358 let mut cx = Cx::<Ev>::default();
1359 cx.notify("toast", "show", "hi");
1360 cx.save("blob");
1361 assert_eq!(cx.notifications.len(), 2);
1362 assert_eq!(cx.notifications[0], PluginNotify { plugin: "toast".into(), op: "show".into(), input: "hi".into() });
1363 assert_eq!(cx.notifications[1], PluginNotify { plugin: "storage".into(), op: "save".into(), input: "blob".into() });
1364 assert!(cx.requests.is_empty());
1365 }
1366
1367 #[test]
1368 fn cx_http_helpers_build_requests() {
1369 let mut cx = Cx::<Ev>::default();
1370 cx.get("http://h/x", |_| Ev::Tap);
1371 cx.post("http://h/y", "hello", |_| Ev::Tap);
1372 cx.put("http://h/p", "putbody", |_| Ev::Tap);
1373 cx.patch("http://h/z", "patch", |_| Ev::Tap);
1374 cx.delete("http://h/d", |_| Ev::Tap);
1375
1376 let methods: Vec<&str> = cx.requests.iter().map(|(c, _)| c.op.as_str()).collect();
1377 assert_eq!(methods, ["GET", "POST", "PUT", "PATCH", "DELETE"]);
1378 assert!(cx.requests.iter().all(|(c, _)| c.plugin == "http"));
1379
1380 let get_input: serde_json::Value = serde_json::from_str(&cx.requests[0].0.input).unwrap();
1381 assert_eq!(get_input["url"], "http://h/x");
1382 assert!(get_input["body"].is_null());
1383
1384 let put_input: serde_json::Value = serde_json::from_str(&cx.requests[2].0.input).unwrap();
1385 assert_eq!(put_input["url"], "http://h/p");
1386 assert_eq!(put_input["body"], "putbody");
1387 }
1388
1389 #[test]
1390 fn request_builder_emits_headers_in_order() {
1391 let mut cx = Cx::<Ev>::default();
1392 cx.request("PUT", "http://h/access-key")
1393 .bearer("tok123")
1394 .header("X-Trace-Id", "abc")
1395 .body("{}")
1396 .send(|_| Ev::Tap);
1397
1398 assert_eq!(cx.requests.len(), 1);
1399 let (call, _) = &cx.requests[0];
1400 assert_eq!(call.plugin, "http");
1401 assert_eq!(call.op, "PUT");
1402
1403 let input: serde_json::Value = serde_json::from_str(&call.input).unwrap();
1404 assert_eq!(input["url"], "http://h/access-key");
1405 assert_eq!(input["body"], "{}");
1406 assert_eq!(input["headers"][0]["name"], "Authorization");
1407 assert_eq!(input["headers"][0]["value"], "Bearer tok123");
1408 assert_eq!(input["headers"][1]["name"], "X-Trace-Id");
1409 assert_eq!(input["headers"][1]["value"], "abc");
1410 }
1411
1412 #[test]
1413 fn helpers_emit_no_headers_field_content() {
1414 let mut cx = Cx::<Ev>::default();
1415 cx.get("http://h/x", |_| Ev::Tap);
1416 let input: serde_json::Value = serde_json::from_str(&cx.requests[0].0.input).unwrap();
1417 assert_eq!(input["headers"].as_array().unwrap().len(), 0);
1418 }
1419
1420 #[test]
1421 fn continuation_receives_decoded_outcome() {
1422 #[derive(Debug, PartialEq)]
1423 enum Got { Conflict, Offline, Other }
1424
1425 let classify = |r: PluginResponse| -> Got {
1426 match HttpOutcome::decode(&r.output).unwrap() {
1427 HttpOutcome::Response { status: 409, .. } => Got::Conflict,
1428 HttpOutcome::TransportError { .. } => Got::Offline,
1429 _ => Got::Other,
1430 }
1431 };
1432
1433 let conflict = HttpOutcome::Response { status: 409, headers: vec![], body: b"c".to_vec() };
1434 assert_eq!(classify(PluginResponse { ok: false, output: conflict.encode() }), Got::Conflict);
1435
1436 let offline = HttpOutcome::TransportError { message: "refused".into() };
1437 assert_eq!(classify(PluginResponse { ok: false, output: offline.encode() }), Got::Offline);
1438 }
1439
1440 #[test]
1441 fn decode_failure_in_continuation_surfaces_as_transport_error() {
1442 let mut cx = Cx::<Ev>::default();
1447
1448 cx.request("GET", "http://h/x").send(|outcome| {
1449 match outcome {
1450 HttpOutcome::TransportError { message } => {
1451 assert!(
1452 message.contains("malformed http response"),
1453 "unexpected message: {message}"
1454 );
1455 }
1456 HttpOutcome::Response { .. } => {
1457 panic!("garbage bytes must not decode as a Response")
1458 }
1459 }
1460 Ev::Tap
1461 });
1462
1463 assert_eq!(cx.requests.len(), 1);
1464 let (_, continuation) = cx.requests.remove(0);
1465 continuation(PluginResponse { ok: true, output: vec![0xff, 0xff, 0xff] });
1468 }
1469
1470 #[test]
1471 fn cx_pick_and_capture_photo_request_the_right_plugin() {
1472 let mut cx = Cx::<Ev>::default();
1473 cx.pick_photo(|_| Ev::Tap);
1474 cx.capture_photo(|_| Ev::Tap);
1475 assert_eq!(cx.requests.len(), 2);
1476 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", ""));
1479 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", ""));
1480 }
1481
1482 #[test]
1483 fn cx_capture_photo_routes_success_and_cancel() {
1484 let mut cx = Cx::<Ev>::default();
1486 cx.capture_photo(|r| if r.ok { Ev::Open(7) } else { Ev::Tap });
1487 let (_, then) = cx.requests.pop().unwrap();
1488 assert!(matches!(then(PluginResponse { ok: true, output: "file:///tmp/shot.jpg".into() }), Ev::Open(7)));
1489
1490 let mut cx = Cx::<Ev>::default();
1492 cx.capture_photo(|r| if r.ok { Ev::Open(7) } else { Ev::Tap });
1493 let (_, then) = cx.requests.pop().unwrap();
1494 assert!(matches!(then(PluginResponse { ok: false, output: Vec::new() }), Ev::Tap));
1495 }
1496
1497 #[test]
1498 fn cx_notify_capabilities_map_to_the_right_plugin_and_op() {
1499 let mut cx = Cx::<Ev>::default();
1500 cx.copy("c");
1501 cx.share("s");
1502 cx.open_url("u");
1503 cx.toast("t");
1504 cx.haptic("heavy");
1505 let got: Vec<(&str, &str, &str)> = cx
1506 .notifications
1507 .iter()
1508 .map(|n| (n.plugin.as_str(), n.op.as_str(), n.input.as_str()))
1509 .collect();
1510 assert_eq!(
1511 got,
1512 vec![
1513 ("clipboard", "copy", "c"),
1514 ("share", "text", "s"),
1515 ("browser", "open", "u"),
1516 ("toast", "show", "t"),
1517 ("haptics", "heavy", ""), ]
1519 );
1520 assert!(cx.requests.is_empty());
1521 }
1522
1523 #[test]
1524 fn cx_device_model_is_a_request_not_a_notification() {
1525 let mut cx = Cx::<Ev>::default();
1526 cx.device_model(|_| Ev::Tap);
1527 assert!(cx.notifications.is_empty());
1528 assert_eq!(cx.requests.len(), 1);
1529 let (call, _) = &cx.requests[0];
1530 assert_eq!((call.plugin.as_str(), call.op.as_str(), call.input.as_str()), ("device", "model", ""));
1531 }
1532
1533 #[test]
1534 fn cx_device_locale_requests_the_device_locale_op() {
1535 let mut cx = Cx::<Ev>::default();
1536 cx.device_locale(|_| Ev::Tap);
1537 assert!(cx.notifications.is_empty());
1538 assert_eq!(cx.requests.len(), 1);
1539 let (call, _) = &cx.requests[0];
1540 assert_eq!((call.plugin.as_str(), call.op.as_str(), call.input.as_str()), ("device", "locale", ""));
1541 }
1542
1543 #[test]
1544 fn cx_now_requests_the_datetime_now_op() {
1545 let mut cx = Cx::<Ev>::default();
1546 cx.now(|_| Ev::Tap);
1547 assert_eq!(cx.requests.len(), 1);
1548 let (call, _) = &cx.requests[0];
1549 assert_eq!((call.plugin.as_str(), call.op.as_str(), call.input.as_str()), ("datetime", "now", ""));
1550 }
1551
1552 #[test]
1553 fn cx_subscribe_enqueues_a_keyed_stream_and_maps_each_event() {
1554 let mut cx = Cx::<Ev>::default();
1555 cx.subscribe("ws", "websocket", "stream", "wss://h/x", |r| if r.ok { Ev::Tap } else { Ev::Open(0) });
1556 assert!(cx.notifications.is_empty());
1558 assert!(cx.requests.is_empty());
1559 assert_eq!(cx.streams.len(), 1);
1560 let (call, on_event) = &cx.streams[0];
1561 assert_eq!(
1562 (call.key.as_str(), call.plugin.as_str(), call.op.as_str(), call.input.as_str()),
1563 ("ws", "websocket", "stream", "wss://h/x")
1564 );
1565 assert!(matches!(on_event(PluginResponse { ok: true, output: "frame1".into() }), Ev::Tap));
1567 assert!(matches!(on_event(PluginResponse { ok: true, output: "frame2".into() }), Ev::Tap));
1568 assert!(matches!(on_event(PluginResponse { ok: false, output: "closed".into() }), Ev::Open(0)));
1569 }
1570
1571 #[test]
1572 fn cx_unsubscribe_enqueues_the_teardown_notify_keyed_by_subscription() {
1573 let mut cx = Cx::<Ev>::default();
1574 cx.unsubscribe("ws");
1575 assert!(cx.streams.is_empty());
1576 assert_eq!(cx.notifications.len(), 1);
1577 assert_eq!(
1579 cx.notifications[0],
1580 PluginNotify { plugin: "stream".into(), op: "unsubscribe".into(), input: "ws".into() }
1581 );
1582 }
1583
1584 #[test]
1585 fn cx_confirm_serializes_title_message_and_routes_ok() {
1586 let mut cx = Cx::<Ev>::default();
1587 cx.confirm("Delete?", "This cannot be undone.", |r| if r.ok { Ev::Tap } else { Ev::Open(0) });
1588 let (call, then) = cx.requests.pop().unwrap();
1589 assert_eq!((call.plugin.as_str(), call.op.as_str()), ("dialog", "confirm"));
1590 let v: serde_json::Value = serde_json::from_str(&call.input).unwrap();
1591 assert_eq!(v["title"], "Delete?");
1592 assert_eq!(v["message"], "This cannot be undone.");
1593 assert!(matches!(then(PluginResponse { ok: true, output: "ok".into() }), Ev::Tap));
1595 }
1596
1597 #[test]
1600 fn text_builders_carry_their_style() {
1601 assert!(matches!(text("b"), Widget::Text { style: TextStyle::Body, .. }));
1602 assert!(matches!(title("t"), Widget::Text { style: TextStyle::Title, .. }));
1603 assert!(matches!(subtitle("s"), Widget::Text { style: TextStyle::Subtitle, .. }));
1604 assert!(matches!(caption("c"), Widget::Text { style: TextStyle::Caption, .. }));
1605 assert!(matches!(emphasis("e"), Widget::Text { style: TextStyle::Emphasis, .. }));
1606 }
1607
1608 #[test]
1609 fn layout_and_content_builders_produce_their_variants() {
1610 assert!(matches!(row(vec![text("a")]), Widget::Row { children } if children.len() == 1));
1611 assert!(matches!(column(vec![]), Widget::Column { children } if children.is_empty()));
1612 assert!(matches!(grid(vec![text("a"), text("b")]), Widget::Grid { children } if children.len() == 2));
1613 assert!(matches!(divider(), Widget::Divider));
1614 assert!(matches!(bar_chart(vec![1.0, 2.0], vec![]), Widget::Chart { style: ChartStyle::Bar, series, .. } if series[0].values.len() == 2));
1615 assert!(matches!(line_chart(vec![1.0], vec![]), Widget::Chart { style: ChartStyle::Line, .. }));
1616 assert!(matches!(donut_chart(vec![ChartSeries::new("a", vec![1.0])]), Widget::Chart { style: ChartStyle::Donut, legend: true, .. }));
1617 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)));
1618 let rc = with_bracket(
1619 region_chart(
1620 vec![ChartRegion::new(0.0, 3.0, 0.0, 80.0, "80%").vertical()],
1621 vec![ChartTick::new(3.0, "3 Mt.")],
1622 65.0, 80.0,
1623 vec![ChartRefLine::target(80.0, "CHF 80'000"), ChartRefLine::max(90.0, "CHF 90'000")],
1624 vec![ChartLegendItem::new("Gap", Rgb::new(0x5A, 0x7D, 0x9A))],
1625 ),
1626 ChartBracket::new(60.0, 80.0, "Ceiling").with_info(),
1627 );
1628 assert!(matches!(rc, Widget::RegionChart { bracket: Some(b), regions, ref_lines, .. } if regions[0].vertical && ref_lines[1].dashed && b.info));
1629 assert!(matches!(
1631 calendar(2026, 6, Some(3), |d| Ev::Open(u32::from(d))),
1632 Widget::Calendar { first_weekday: 1, selected: Some(3), on_day, .. } if on_day.len() == 30
1633 ));
1634 assert!(matches!(
1635 swipe_action(text("row"), vec![("Delete", Tone::Danger, Ev::Tap)]),
1636 Widget::SwipeAction { actions, .. } if actions.len() == 1
1637 ));
1638 assert!(matches!(
1640 lazy_list(vec![text("a"), text("b")], false, true, Ev::Tap),
1641 Widget::LazyList { children, on_load_more: Some(t), loading: false, has_more: true, on_refresh: None, refreshing: false }
1642 if children.len() == 2 && t == serde_json::to_string(&Ev::Tap).unwrap()
1643 ));
1644 assert!(matches!(lazy_list_static(vec![text("a")]), Widget::LazyList { on_load_more: None, on_refresh: None, .. }));
1645 assert!(matches!(
1647 with_refresh(lazy_list(vec![text("a")], true, false, Ev::Tap), true, Ev::Open(9)),
1648 Widget::LazyList { on_load_more: Some(_), loading: true, has_more: false, on_refresh: Some(r), refreshing: true, .. }
1649 if r == serde_json::to_string(&Ev::Open(9)).unwrap()
1650 ));
1651 assert!(matches!(spacer(Spacing::Lg), Widget::Spacer { .. }));
1652 assert!(matches!(image("u", ImageShape::Circle, ImageRatio::Square), Widget::Image { .. }));
1653 assert!(matches!(badge("new", Tone::Success), Widget::Badge { .. }));
1654 assert!(matches!(color_dot(ProjectColor::Teal), Widget::ColorDot { .. }));
1655 assert!(matches!(card(text("x"), CardStyle::Filled), Widget::Card { on_press: None, .. }));
1656 assert!(matches!(stack(BoxAlign::Center, true, vec![]), Widget::Box { scrim: true, .. }));
1658 assert!(matches!(split(text("list"), text("detail"), true, Ev::Tap),
1660 Widget::Split { show_detail: true, on_back: Some(_), .. }));
1661 }
1662
1663 #[test]
1664 fn input_builders_carry_ids_values_and_event_tokens() {
1665 assert!(matches!(text_field("id", "ph", "v"), Widget::TextField { kind: FieldKind::Text, error: None, .. }));
1666 assert!(matches!(pdf_view("https://x/report.pdf"), Widget::PdfView { url } if url == "https://x/report.pdf"));
1667 assert!(matches!(web_view("https://iframe.mediadelivery.net/embed/1/abc"), Widget::WebView { url } if url == "https://iframe.mediadelivery.net/embed/1/abc"));
1668 assert!(matches!(video_player("v", "https://x/c.mp4", false, -1, Ev::Tap),
1670 Widget::Video { id, playing: false, seek_to_ms: -1, controls: true, looping: false, muted: false, on_ended: Some(_), .. } if id == "v"));
1671 assert!(matches!(without_controls(with_muted(with_loop(video_player("v", "u", true, 0, Ev::Tap)))),
1672 Widget::Video { playing: true, controls: false, looping: true, muted: true, .. }));
1673 assert!(matches!(video_player("v", "u", false, -1, Ev::Tap),
1675 Widget::Video { poster: None, start_at_ms: -1, rate, volume, allow_pip: false, .. }
1676 if (rate - 1.0).abs() < f32::EPSILON && (volume - 1.0).abs() < f32::EPSILON));
1677 let tuned = with_pip(with_volume(with_rate(with_start_at(with_poster(
1678 with_captions(video_player("v", "u", true, -1, Ev::Tap),
1679 vec![Caption { url: "e.vtt".into(), label: "EN".into(), language: "en".into(), default_on: true }]),
1680 "p.jpg"), 9000), 1.5), 0.5));
1681 assert!(matches!(tuned,
1682 Widget::Video { poster: Some(p), start_at_ms: 9000, rate, volume, allow_pip: true, captions, .. }
1683 if p == "p.jpg" && (rate - 1.5).abs() < f32::EPSILON && (volume - 0.5).abs() < f32::EPSILON && captions.len() == 1));
1684 assert!(matches!(with_seek_index(video_playlist("pl", vec!["a.mp4".into(), "b.mp4".into()], 1, true, Ev::Tap), 0),
1686 Widget::Video { url, urls, start_index: 1, seek_index: 0, .. } if url == "a.mp4" && urls.len() == 2));
1687 assert!(matches!(with_pip(divider()), Widget::Divider));
1689 assert!(matches!(secure_field("pw", "Password", ""), Widget::TextField { kind: FieldKind::Secure, .. }));
1690 assert!(matches!(email_field("e", "", ""), Widget::TextField { kind: FieldKind::Email, .. }));
1691 assert!(matches!(multiline_field("note", "", ""), Widget::TextField { kind: FieldKind::Multiline, .. }));
1692 assert!(matches!(with_error(email_field("e", "", "x"), "Invalid"), Widget::TextField { error: Some(m), kind: FieldKind::Email, .. } if m == "Invalid"));
1693 assert!(matches!(with_error(divider(), "ignored"), Widget::Divider));
1694 assert!(matches!(toggle("t", "l", true), Widget::Toggle { value: true, .. }));
1695 assert!(matches!(checkbox("c", "l", false), Widget::Checkbox { value: false, .. }));
1696 assert!(matches!(slider("s", 3, 10), Widget::Slider { value: 3, max: 10, .. }));
1697
1698 match chip("Latte", true, Ev::Open(2)) {
1699 Widget::Chip { selected, on_press, .. } => {
1700 assert!(selected);
1701 assert_eq!(on_press, serde_json::to_string(&Ev::Open(2)).unwrap());
1702 }
1703 other => panic!("expected Chip, got {other:?}"),
1704 }
1705 match stepper(5, Ev::Tap, Ev::Open(1)) {
1706 Widget::Stepper { value, on_decrement, on_increment } => {
1707 assert_eq!(value, 5);
1708 assert_eq!(on_decrement, serde_json::to_string(&Ev::Tap).unwrap());
1709 assert_eq!(on_increment, serde_json::to_string(&Ev::Open(1)).unwrap());
1710 }
1711 other => panic!("expected Stepper, got {other:?}"),
1712 }
1713 let t = tab("Home", true, Ev::Tap);
1714 assert_eq!(t.label, "Home");
1715 assert!(t.selected);
1716 assert_eq!(t.on_select, serde_json::to_string(&Ev::Tap).unwrap());
1717 }
1718
1719 #[test]
1722 fn widget_tree_round_trips_through_serde() {
1723 let tree = scaffold(
1724 "Home",
1725 true,
1726 vec![tab("A", true, Ev::Tap)],
1727 column(vec![
1728 title("Hi"),
1729 row(vec![button("Go", ButtonStyle::Filled, Ev::Open(3)), chip("x", false, Ev::Tap)]),
1730 image("u", ImageShape::Rounded, ImageRatio::Wide),
1731 slider("s", 2, 5),
1732 ]),
1733 );
1734 let s = serde_json::to_string(&tree).unwrap();
1735 let back: Widget = serde_json::from_str(&s).unwrap();
1736 assert_eq!(s, serde_json::to_string(&back).unwrap());
1737 }
1738
1739 #[test]
1740 fn actions_and_input_values_round_trip() {
1741 let actions = vec![
1742 Action::Fired { token: serde_json::to_string(&Ev::Open(1)).unwrap() },
1743 Action::Input { id: "n".into(), value: InputValue::Int(7) },
1744 Action::Input { id: "n".into(), value: InputValue::Text("hi".into()) },
1745 Action::Input { id: "n".into(), value: InputValue::Bool(true) },
1746 Action::Restore { data: "blob".into() },
1747 Action::Start,
1748 ];
1749 for a in actions {
1750 let s = serde_json::to_string(&a).unwrap();
1751 let back: Action = serde_json::from_str(&s).unwrap();
1752 assert_eq!(s, serde_json::to_string(&back).unwrap());
1753 }
1754 }
1755
1756 #[derive(Default)]
1759 struct CounterModel {
1760 count: i32,
1761 restored: String,
1762 started: bool,
1763 last_input: String,
1764 }
1765
1766 #[derive(serde::Serialize, serde::Deserialize)]
1767 enum CounterEv {
1768 Inc,
1769 Add(i32),
1770 }
1771
1772 #[derive(Default)]
1773 struct CounterApp;
1774
1775 impl MobilerApp for CounterApp {
1776 type Event = CounterEv;
1777 type Model = CounterModel;
1778 fn update(&self, ev: CounterEv, model: &mut CounterModel, _cx: &mut Cx<CounterEv>) {
1779 match ev {
1780 CounterEv::Inc => model.count += 1,
1781 CounterEv::Add(n) => model.count += n,
1782 }
1783 }
1784 fn input(&self, id: &str, value: InputValue, model: &mut CounterModel, _cx: &mut Cx<CounterEv>) {
1785 if let InputValue::Text(t) = value {
1786 model.last_input = format!("{id}={t}");
1787 }
1788 }
1789 fn restore(&self, data: &str, model: &mut CounterModel) {
1790 model.restored = data.to_string();
1791 }
1792 fn init(&self, model: &mut CounterModel, _cx: &mut Cx<CounterEv>) {
1793 model.started = true;
1794 }
1795 fn view(&self, model: &CounterModel) -> Widget {
1796 text(format!("{}", model.count))
1797 }
1798 }
1799
1800 #[test]
1801 fn shell_dispatches_fired_input_restore_and_start() {
1802 use crux_core::App as _;
1803 let shell = MobilerShell::<CounterApp>::default();
1804 let mut m = CounterModel::default();
1805
1806 let _ = shell.update(Action::Fired { token: serde_json::to_string(&CounterEv::Add(5)).unwrap() }, &mut m);
1808 assert_eq!(m.count, 5);
1809 let _ = shell.update(Action::Input { id: "name".into(), value: InputValue::Text("bob".into()) }, &mut m);
1811 assert_eq!(m.last_input, "name=bob");
1812 let _ = shell.update(Action::Restore { data: "saved".into() }, &mut m);
1814 assert_eq!(m.restored, "saved");
1815 let _ = shell.update(Action::Start, &mut m);
1817 assert!(m.started);
1818 assert!(matches!(shell.view(&m), Widget::Text { .. }));
1820 }
1821
1822 #[test]
1823 fn shell_ignores_a_malformed_fired_token() {
1824 use crux_core::App as _;
1825 let shell = MobilerShell::<CounterApp>::default();
1826 let mut m = CounterModel::default();
1827 let _ = shell.update(Action::Fired { token: "not a valid token".into() }, &mut m);
1830 assert_eq!(m.count, 0);
1831 }
1832}