1use std::rc::Rc;
24
25use frus_widgets::host::{self, Effect};
26use frus_widgets::{
27 BuildContext, Callback, Component, GoRouter, Locale, Localizations, StatefulWidget,
28 StatelessWidget, Theme, ThemeMode, Widget,
29};
30
31use crate::application::{Application, LocationStrategy};
32use crate::command::Command;
33use crate::subscription::Subscription;
34
35type Save = Box<dyn Fn() -> Option<Vec<u8>>>;
37type Restore = Box<dyn Fn(&[u8])>;
38
39type Root = Rc<dyn Fn(&BuildContext) -> Box<dyn Widget>>;
41
42type Wrap = Rc<dyn Fn(&BuildContext, Box<dyn Widget>) -> Box<dyn Widget>>;
44
45pub struct FrusApp {
63 root: Root,
64 builder: Option<Wrap>,
65 router: Option<GoRouter>,
66 window_size: Option<(f32, f32)>,
67 icon: crate::AppIcon,
68 on_start: Option<Box<dyn FnOnce()>>,
69 tray: Option<Box<dyn Fn() -> crate::Tray<Callback>>>,
70 taskbar: Option<Box<dyn Fn() -> crate::Taskbar>>,
71 shortcuts: Option<Box<dyn Fn() -> Vec<crate::GlobalShortcut<Callback>>>>,
72 save: Option<Save>,
73 restore: Option<Restore>,
74 strategy: LocationStrategy,
75 instance: Option<String>,
76}
77
78impl FrusApp {
79 pub fn new(root: impl StatelessWidget) -> Self {
81 Self::with_root(Rc::new(move |cx| root.build(cx)))
82 }
83
84 pub fn from_fn(root: impl Fn(&BuildContext) -> Box<dyn Widget> + 'static) -> Self {
87 Self::with_root(Rc::new(root))
88 }
89
90 pub fn stateful<W: StatefulWidget + Clone>(root: W) -> Self {
93 Self::with_root(Rc::new(move |_| {
94 Box::new(Component::stateful(root.clone()))
95 }))
96 }
97
98 pub fn router(router: GoRouter) -> Self {
113 let building = router.clone();
114 let mut app = Self::with_root(Rc::new(move |cx| building.build(cx)));
115 app.router = Some(router);
116 app
117 }
118
119 fn with_root(root: Root) -> Self {
120 let app = host::app();
122 app.set_title("frus".to_string());
123 app.set_theme(Theme::light());
124 app.set_dark_theme(None);
125 app.set_theme_mode(ThemeMode::System);
126 app.set_density(1.0);
127 app.set_locale(None);
128 app.set_supported_locales(Vec::new());
129 app.set_localizations(None);
130 Self {
131 root,
132 builder: None,
133 router: None,
134 window_size: None,
135 icon: crate::AppIcon::Frus,
136 on_start: None,
137 tray: None,
138 taskbar: None,
139 shortcuts: None,
140 save: None,
141 restore: None,
142 strategy: LocationStrategy::Hash,
143 instance: None,
144 }
145 }
146
147 pub fn builder(
183 mut self,
184 builder: impl Fn(&BuildContext, Box<dyn Widget>) -> Box<dyn Widget> + 'static,
185 ) -> Self {
186 self.builder = Some(Rc::new(builder));
187 self
188 }
189
190 pub fn single_instance(mut self, id: impl Into<String>) -> Self {
195 self.instance = Some(id.into());
196 self
197 }
198
199 pub fn location_strategy(mut self, strategy: LocationStrategy) -> Self {
204 self.strategy = strategy;
205 self
206 }
207
208 pub fn title(self, title: impl Into<String>) -> Self {
210 host::app().set_title(title.into());
211 self
212 }
213
214 pub fn theme(self, theme: Theme) -> Self {
216 host::app().set_theme(theme);
217 self
218 }
219
220 pub fn dark_theme(self, theme: Theme) -> Self {
223 host::app().set_dark_theme(Some(theme));
224 self
225 }
226
227 pub fn theme_mode(self, mode: ThemeMode) -> Self {
229 host::app().set_theme_mode(mode);
230 self
231 }
232
233 pub fn supported_locales(self, locales: Vec<Locale>) -> Self {
236 host::app().set_supported_locales(locales);
237 self
238 }
239
240 pub fn localizations(self, table: std::rc::Rc<dyn Localizations>) -> Self {
242 host::app().set_localizations(Some(table));
243 self
244 }
245
246 pub fn on_start(mut self, start: impl FnOnce() + 'static) -> Self {
249 self.on_start = Some(Box::new(start));
250 self
251 }
252
253 pub fn persist(
256 mut self,
257 save: impl Fn() -> Option<Vec<u8>> + 'static,
258 restore: impl Fn(&[u8]) + 'static,
259 ) -> Self {
260 self.save = Some(Box::new(save));
261 self.restore = Some(Box::new(restore));
262 self
263 }
264
265 pub fn icon(mut self, icon: crate::AppIcon) -> Self {
270 self.icon = icon;
271 self
272 }
273
274 pub fn tray(mut self, tray: impl Fn() -> crate::Tray<Callback> + 'static) -> Self {
278 self.tray = Some(Box::new(tray));
279 self
280 }
281
282 pub fn taskbar(mut self, taskbar: impl Fn() -> crate::Taskbar + 'static) -> Self {
286 self.taskbar = Some(Box::new(taskbar));
287 self
288 }
289
290 pub fn global_shortcuts(
294 mut self,
295 shortcuts: impl Fn() -> Vec<crate::GlobalShortcut<Callback>> + 'static,
296 ) -> Self {
297 self.shortcuts = Some(Box::new(shortcuts));
298 self
299 }
300
301 pub fn window_size(mut self, width: f32, height: f32) -> Self {
303 self.window_size = Some((width, height));
304 self
305 }
306
307 pub fn density(self, density: f32) -> Self {
309 host::app().set_density(density);
310 self
311 }
312}
313
314impl Application for FrusApp {
315 type Message = Callback;
316
317 fn update(&mut self, callback: Callback) -> Command<Callback> {
318 callback.call();
319 Command::none()
320 }
321
322 fn init(&mut self) -> Command<Callback> {
323 if let Some(start) = self.on_start.take() {
324 start();
325 }
326 self.effects()
327 }
328
329 fn effects(&mut self) -> Command<Callback> {
330 Command::batch(host::take_effects().into_iter().map(|effect| match effect {
331 Effect::Focus(key) => Command::focus_hashed(key),
332 Effect::Scroll(key, to) => Command::scroll_hashed(key, to),
333 Effect::Sheet(key, to) => Command::sheet_hashed(key, to),
334 Effect::Spawn(task) => Command::run(task),
335 Effect::After(delay, callback) => Command::after(delay, callback),
336 Effect::Async(task) => Command::run_async(task),
337 }))
338 }
339
340 fn save_state(&self) -> Option<Vec<u8>> {
341 self.save.as_ref().and_then(|save| save())
342 }
343
344 fn restore_state(&mut self, bytes: &[u8]) {
345 if let Some(restore) = &self.restore {
346 restore(bytes);
347 }
348 }
349
350 fn view(&self, _theme: &Theme) -> Box<dyn Widget<Callback>> {
351 let root = self.root.clone();
352 let Some(builder) = self.builder.clone() else {
353 return Box::new(Component::stateless(move |cx: &BuildContext| root(cx)));
354 };
355 let router = self.router.clone();
356 Box::new(Component::stateless(move |cx: &BuildContext| {
357 if let Some(router) = &router {
360 cx.runtime().states.provide(Rc::new(router.clone()));
361 }
362 let root = root.clone();
365 let pages: Box<dyn Widget> =
366 Box::new(Component::stateless(move |cx: &BuildContext| root(cx)));
367 builder(cx, pages)
368 }))
369 }
370
371 fn taskbar(&self) -> crate::Taskbar {
372 self.taskbar
373 .as_ref()
374 .map(|taskbar| taskbar())
375 .unwrap_or_default()
376 }
377
378 fn global_shortcuts(&self) -> Vec<crate::GlobalShortcut<Callback>> {
379 self.shortcuts
380 .as_ref()
381 .map(|shortcuts| shortcuts())
382 .unwrap_or_default()
383 }
384
385 fn tray(&self) -> Option<crate::Tray<Callback>> {
386 self.tray.as_ref().map(|tray| tray())
387 }
388
389 fn subscription(&self) -> Subscription<Callback> {
390 Subscription::batch(frus_widgets::intervals().into_iter().map(|interval| {
393 let callback = interval.callback;
394 Subscription::every_keyed(interval.id, interval.period, move |_| callback.clone())
395 }))
396 }
397
398 fn tick(&mut self, dt: f32) -> bool {
399 self.router.as_ref().is_some_and(|router| router.tick(dt))
400 }
401
402 fn location(&self) -> Option<String> {
403 self.router.as_ref().map(GoRouter::location)
404 }
405
406 fn location_replaces(&self) -> bool {
407 self.router.as_ref().is_some_and(GoRouter::replaced)
408 }
409
410 fn location_stack(&self) -> Vec<String> {
411 self.router
412 .as_ref()
413 .map(GoRouter::locations)
414 .unwrap_or_default()
415 }
416
417 fn open_location(&mut self, location: &str) -> Command<Callback> {
418 if let Some(router) = &self.router {
419 router.open(location);
420 }
421 Command::none()
422 }
423
424 fn location_strategy(&self) -> LocationStrategy {
425 self.strategy
426 }
427
428 fn instance_id(&self) -> Option<String> {
429 self.instance.clone()
430 }
431
432 fn can_go_back(&self) -> bool {
433 !frus_widgets::back_blocked()
435 && self
436 .router
437 .as_ref()
438 .is_some_and(|router| router.can_go_back())
439 }
440
441 fn back_gesture(&mut self, progress: f32) {
442 if let Some(router) = &self.router {
443 router.back_gesture(progress);
444 }
445 }
446
447 fn back_gesture_end(&mut self, velocity: f32) {
448 if let Some(router) = &self.router {
449 router.back_gesture_end(velocity);
450 }
451 }
452
453 fn go_back(&mut self) {
456 if let Some(router) = &self.router {
457 router.pop();
458 }
459 }
460
461 fn theme(&self) -> Theme {
462 host::app().theme()
463 }
464
465 fn dark_theme(&self) -> Option<Theme> {
466 host::app().dark_theme()
467 }
468
469 fn theme_mode(&self) -> ThemeMode {
470 host::app().theme_mode()
471 }
472
473 fn supported_locales(&self) -> Vec<Locale> {
474 host::app().supported_locales()
475 }
476
477 fn locale(&self) -> Option<Locale> {
478 host::app().locale()
479 }
480
481 fn localizations(&self) -> Option<std::rc::Rc<dyn Localizations>> {
482 host::app().localizations()
483 }
484
485 fn title(&self) -> String {
486 host::app().title()
487 }
488
489 fn window_size(&self) -> Option<(f32, f32)> {
490 self.window_size
491 }
492
493 fn icon(&self) -> crate::AppIcon {
494 self.icon.clone()
495 }
496
497 fn density(&self) -> f32 {
498 host::app().density()
499 }
500}
501
502#[cfg(test)]
503mod tests {
504 use super::*;
505 use crate::app::testing::Driver;
506 use frus_widgets::{Container, Point, State, StateContext};
507 use std::cell::RefCell;
508
509 const SIDE: f32 = 200.0;
510 type Seen = Rc<RefCell<Vec<i32>>>;
511
512 fn target(on_tap: impl Into<Callback>) -> Box<dyn Widget> {
514 Box::new(Container::new().width(SIDE).height(SIDE).on_click(on_tap))
515 }
516
517 #[test]
521 fn the_builder_reaches_the_router_from_the_first_frame() {
522 let router = GoRouter::new(vec![frus_widgets::GoRoute::new("/", |_, _| {
523 frus_widgets::text("home")
524 })]);
525 let seen: Rc<RefCell<Option<String>>> = Rc::default();
526 let app = {
527 let seen = seen.clone();
528 FrusApp::router(router).builder(move |cx, pages| {
529 *seen.borrow_mut() = Some(cx.router().location());
530 pages
531 })
532 };
533 let mut driver = Driver::new(app, SIDE, SIDE);
534 driver.frame(0.016);
535 assert_eq!(seen.borrow().as_deref(), Some("/"));
536 assert!(
537 driver.texts().iter().any(|(text, _)| text == "home"),
538 "and the pages are there"
539 );
540 }
541
542 fn tap(driver: &mut Driver<FrusApp>) {
543 let at = Point::new(SIDE / 2.0, SIDE / 2.0);
544 driver.press(at);
545 driver.release(at);
546 driver.frame(1.0 / 60.0);
547 }
548
549 #[test]
552 fn an_application_has_the_frus_logo_until_it_names_its_own_icon() {
553 use crate::{AppIcon, Application};
554 let plain = FrusApp::from_fn(|_| target(|| {}));
555 assert!(
556 matches!(Application::icon(&plain), AppIcon::Frus),
557 "the default"
558 );
559 let own = FrusApp::from_fn(|_| target(|| {})).icon(AppIcon::from_png(&b"png"[..]));
560 assert_eq!(Application::icon(&own).png(), Some(&b"png"[..]));
561 let none = FrusApp::from_fn(|_| target(|| {})).icon(AppIcon::none());
562 assert_eq!(Application::icon(&none).png(), None);
563 }
564
565 #[test]
566 fn a_hook_survives_a_tap_and_the_view_is_built_again_with_the_new_value() {
567 let seen: Seen = Rc::default();
568 let probe = seen.clone();
569 let mut driver = Driver::new(
570 FrusApp::from_fn(move |cx| {
571 let count = cx.use_state(|| 0);
572 probe.borrow_mut().push(count.get());
573 let add = count.clone();
574 target(move || add.update(|n| *n += 1))
575 }),
576 SIDE,
577 SIDE,
578 );
579 driver.frame(1.0 / 60.0);
580 assert_eq!(*seen.borrow(), [0], "built once to begin with");
581 tap(&mut driver);
582 tap(&mut driver);
583 tap(&mut driver);
584 assert_eq!(
585 seen.borrow().last(),
586 Some(&3),
587 "three taps, three changes, each kept: {:?}",
588 seen.borrow()
589 );
590 }
591
592 #[derive(Clone)]
593 struct Counter {
594 seen: Seen,
595 }
596
597 struct CounterState {
598 count: i32,
599 seen: Seen,
600 }
601
602 impl StatefulWidget for Counter {
603 type State = CounterState;
604 fn create_state(&self) -> CounterState {
605 CounterState {
606 count: 100,
607 seen: self.seen.clone(),
608 }
609 }
610 }
611
612 impl State for CounterState {
613 type Widget = Counter;
614 fn build(&self, cx: &StateContext<Self>) -> Box<dyn Widget> {
615 self.seen.borrow_mut().push(self.count);
616 target(cx.callback(|state| state.count += 1))
617 }
618 }
619
620 #[test]
621 fn a_stateful_root_keeps_its_state_between_taps() {
622 let seen: Seen = Rc::default();
623 let mut driver = Driver::new(
624 FrusApp::stateful(Counter { seen: seen.clone() }),
625 SIDE,
626 SIDE,
627 );
628 driver.frame(1.0 / 60.0);
629 tap(&mut driver);
630 tap(&mut driver);
631 assert_eq!(seen.borrow().last(), Some(&102), "{:?}", seen.borrow());
632 }
633
634 #[test]
635 fn a_value_handler_that_returns_nothing_runs_once_per_event() {
636 use frus_widgets::Checkbox;
637 let toggles: Rc<RefCell<Vec<bool>>> = Rc::default();
638 let log = toggles.clone();
639 let mut driver = Driver::new(
640 FrusApp::from_fn(move |_| {
641 let log = log.clone();
642 Box::new(Checkbox::new(false).on_toggle(move |on| log.borrow_mut().push(on)))
643 }),
644 SIDE,
645 SIDE,
646 );
647 driver.frame(1.0 / 60.0);
648 let at = Point::new(12.0, 12.0);
650 driver.press(at);
651 driver.release(at);
652 driver.frame(1.0 / 60.0);
653 assert_eq!(*toggles.borrow(), [true], "one tap, one call");
654 }
655
656 #[test]
657 fn a_timer_a_component_asks_for_is_a_subscription_while_it_asks() {
658 let on = Rc::new(std::cell::Cell::new(true));
659 let wanted = on.clone();
660 let mut driver = Driver::new(
661 FrusApp::from_fn(move |cx| {
662 if wanted.get() {
663 cx.use_interval(std::time::Duration::from_secs(1), || {});
664 }
665 target(|| {})
666 }),
667 SIDE,
668 SIDE,
669 );
670 assert!(driver.app().subscription().is_empty(), "nothing built yet");
671 driver.frame(1.0 / 60.0);
672 assert_eq!(driver.app().subscription().ids().len(), 1);
673 on.set(false);
674 frus_widgets::request_rebuild();
675 driver.frame(1.0 / 60.0);
676 assert!(
677 driver.app().subscription().is_empty(),
678 "and it stops when it is not asked for"
679 );
680 }
681
682 #[test]
683 fn an_effect_runs_after_the_frame_and_a_change_it_makes_is_shown() {
684 let seen: Seen = Rc::default();
685 let probe = seen.clone();
686 let mut driver = Driver::new(
687 FrusApp::from_fn(move |cx| {
688 let count = cx.use_state(|| 0);
689 probe.borrow_mut().push(count.get());
690 let set = count.clone();
691 cx.use_effect((), move || set.set(7));
692 target(|| {})
693 }),
694 SIDE,
695 SIDE,
696 );
697 driver.frame(1.0 / 60.0);
698 driver.frame(1.0 / 60.0);
699 assert_eq!(
700 *seen.borrow(),
701 [0, 7],
702 "built with 0, the effect set 7, and the next frame built with it"
703 );
704 }
705}
706
707#[cfg(test)]
708mod router_tests {
709 use super::*;
710 use crate::app::testing::Driver;
711 use frus_widgets::{component, Container, GoRoute, Point};
712 use std::cell::RefCell;
713
714 const SIDE: f32 = 200.0;
715
716 fn target(on_tap: impl Into<Callback>) -> Box<dyn Widget> {
717 Box::new(Container::new().width(SIDE).height(SIDE).on_click(on_tap))
718 }
719
720 fn tap(driver: &mut Driver<FrusApp>) {
721 let at = Point::new(SIDE / 2.0, SIDE / 2.0);
722 driver.press(at);
723 driver.release(at);
724 driver.frame(1.0 / 60.0);
725 }
726
727 fn app(seen: Rc<RefCell<Vec<i32>>>) -> (FrusApp, GoRouter) {
729 let router = GoRouter::new(vec![
730 GoRoute::new("/", move |_, _| {
731 let seen = seen.clone();
732 component(move |cx| {
733 let visits = cx.use_state(|| 0);
734 seen.borrow_mut().push(visits.get());
735 let router = cx.router();
736 target(move || {
737 visits.update(|n| *n += 1);
738 router.push("/second");
739 })
740 })
741 }),
742 GoRoute::new("/second", |cx, _| {
743 let router = cx.router();
744 target(move || {
745 router.pop();
746 })
747 }),
748 ]);
749 (FrusApp::router(router.clone()), router)
750 }
751
752 #[test]
753 fn a_tap_pushes_a_page_and_the_covered_page_keeps_what_its_components_hold() {
754 let seen = Rc::default();
755 let (app, router) = app(Rc::clone(&seen));
756 let mut driver = Driver::new(app, SIDE, SIDE);
757 driver.frame(1.0 / 60.0);
758 assert_eq!((router.location(), router.depth()), ("/".to_string(), 1));
759
760 tap(&mut driver);
761 driver.run(1.5);
762 assert_eq!(router.location(), "/second", "the tap pushed a page");
763 assert_eq!(router.depth(), 2);
764 assert_eq!(
765 seen.borrow().last(),
766 Some(&1),
767 "the page underneath is still built, holding what it held: {:?}",
768 seen.borrow()
769 );
770
771 tap(&mut driver);
772 driver.run(1.5);
773 assert_eq!(
774 router.location(),
775 "/",
776 "the tap on the second page went back"
777 );
778 assert_eq!(
779 seen.borrow().last(),
780 Some(&1),
781 "and the first page is shown again with its state, not made anew"
782 );
783 }
784
785 #[test]
789 fn the_system_back_pops_the_page() {
790 let (app, router) = app(Rc::default());
791 let mut driver = Driver::new(app, SIDE, SIDE);
792 driver.frame(1.0 / 60.0);
793 router.push("/second");
794 driver.run(1.5);
795 assert_eq!(router.depth(), 2);
796
797 assert!(driver.system_back(), "there was a page to go back from");
798 assert_eq!(
799 (router.location(), router.depth()),
800 ("/".to_string(), 1),
801 "popped at once: a pop, not a swipe settling"
802 );
803 driver.run(1.5);
804 assert!(
805 !driver.system_back(),
806 "and with nothing left to go back from, Back leaves the application"
807 );
808 }
809
810 #[test]
811 fn a_back_gesture_carries_the_top_page_away() {
812 let (app, router) = app(Rc::default());
813 let mut driver = Driver::new(app, SIDE, SIDE);
814 driver.frame(1.0 / 60.0);
815 router.push("/second");
816 driver.run(1.5);
817 assert!(FrusApp::router(router.clone()).can_go_back());
818
819 router.back_gesture(0.9);
820 driver.frame(1.0 / 60.0);
821 router.back_gesture_end(0.0);
822 driver.run(1.5);
823 assert_eq!((router.location(), router.depth()), ("/".to_string(), 1));
824 }
825
826 #[test]
827 fn a_popped_page_is_disposed_once_its_transition_is_over() {
828 let disposed = Rc::new(std::cell::Cell::new(0));
829 let counted = disposed.clone();
830 let router = GoRouter::new(vec![
831 GoRoute::new("/", |_, _| Container::new().width(SIDE).height(SIDE)),
832 GoRoute::new("/gone", move |_, _| {
833 let counted = counted.clone();
834 component(move |cx| {
835 let counted = counted.clone();
836 cx.use_effect((), move || move || counted.set(counted.get() + 1));
837 Box::new(Container::new().width(SIDE).height(SIDE)) as Box<dyn Widget>
838 })
839 }),
840 ]);
841 let mut driver = Driver::new(FrusApp::router(router.clone()), SIDE, SIDE);
842 driver.frame(1.0 / 60.0);
843 router.push("/gone");
844 driver.run(1.5);
845 assert_eq!(disposed.get(), 0, "on the stack, it is alive");
846 router.pop();
847 driver.run(1.5);
848 assert_eq!(disposed.get(), 1, "popped and gone, its cleanup has run");
849 }
850}
851
852#[cfg(test)]
853mod host_tests {
854 use super::*;
855 use crate::app::testing::Driver;
856 use frus_widgets::{component, Container, GoRoute, ScrollTo};
857 use std::cell::{Cell, RefCell};
858
859 const SIDE: f32 = 200.0;
860
861 fn target(on_tap: impl Into<Callback>) -> Box<dyn Widget> {
862 Box::new(Container::new().width(SIDE).height(SIDE).on_click(on_tap))
863 }
864
865 fn quiet() -> FrusApp {
866 FrusApp::from_fn(|_| target(|| {}))
867 }
868
869 #[test]
870 fn the_builders_and_a_component_write_to_the_same_settings_the_shell_reads() {
871 let app = quiet()
872 .title("first")
873 .theme_mode(ThemeMode::Light)
874 .density(1.1);
875 assert_eq!(Application::title(&app), "first");
876 assert_eq!(Application::theme_mode(&app), ThemeMode::Light);
877 assert_eq!(Application::density(&app), 1.1);
878 host::app().set_title("second".to_string());
880 host::app().set_theme_mode(ThemeMode::Dark);
881 host::app().set_dark_theme(Some(Theme::dark()));
882 assert_eq!(Application::title(&app), "second");
883 assert_eq!(Application::theme_mode(&app), ThemeMode::Dark);
884 assert!(Application::dark_theme(&app).is_some());
885 }
886
887 #[test]
888 fn a_new_application_starts_from_the_defaults_not_from_the_last_one() {
889 host::app().set_theme_mode(ThemeMode::Dark);
890 host::app().set_density(1.4);
891 host::app().set_locale(Some(Locale::new("fr")));
892 let app = quiet();
893 assert_eq!(Application::theme_mode(&app), ThemeMode::System);
894 assert_eq!(Application::density(&app), 1.0);
895 assert_eq!(Application::locale(&app), None);
896 }
897
898 #[test]
899 fn the_language_a_component_picks_is_the_one_the_application_answers() {
900 let app = quiet().supported_locales(vec![Locale::new("en"), Locale::new("fr")]);
901 assert_eq!(Application::supported_locales(&app).len(), 2);
902 host::app().set_locale(Some(Locale::new("fr")));
903 assert_eq!(Application::locale(&app), Some(Locale::new("fr")));
904 }
905
906 #[test]
907 fn what_a_component_asks_of_the_shell_becomes_a_command_once() {
908 let mut app = quiet();
909 let _ = host::take_effects();
910 assert!(app.effects().is_empty(), "nothing asked, nothing to run");
911 host::focus("name");
912 host::scroll_to("list", ScrollTo::start());
913 host::after(std::time::Duration::from_secs(1), || {});
914 host::spawn(|| 1 + 1, |_| {});
915 assert!(!app.effects().is_empty());
916 assert!(app.effects().is_empty(), "and taken once");
917 }
918
919 #[test]
922 fn an_awaited_work_becomes_an_awaited_command() {
923 let mut app = quiet();
924 let _ = host::take_effects();
925 host::spawn_async(async { 1 + 1 }, |_| {});
926 let parts = app.effects().into_parts();
927 assert_eq!(parts.async_tasks.len(), 1);
928 assert!(parts.tasks.is_empty(), "not a blocking task");
929 }
930
931 #[test]
932 fn on_start_runs_once_before_the_first_frame() {
933 let started = Rc::new(Cell::new(0));
934 let count = started.clone();
935 let mut app = quiet().on_start(move || count.set(count.get() + 1));
936 let _ = app.init();
937 let _ = app.init();
938 assert_eq!(started.get(), 1);
939 }
940
941 #[test]
942 fn a_state_can_be_kept_across_a_live_reload() {
943 let held = Rc::new(RefCell::new(0u8));
944 let (read, write) = (held.clone(), held.clone());
945 let mut app = quiet().persist(
946 move || Some(vec![*read.borrow()]),
947 move |bytes| *write.borrow_mut() = bytes[0],
948 );
949 *held.borrow_mut() = 7;
950 let bytes = app.save_state().expect("something to keep");
951 *held.borrow_mut() = 0;
952 app.restore_state(&bytes);
953 assert_eq!(*held.borrow(), 7);
954 assert!(quiet().save_state().is_none(), "nothing kept unless asked");
955 }
956
957 #[test]
958 fn a_page_is_told_how_far_in_it_is_while_it_arrives() {
959 let seen = Rc::new(RefCell::new(Vec::<f32>::new()));
960 let log = seen.clone();
961 let router = GoRouter::new(vec![GoRoute::new("/", |_, _| target(|| {})).routes(vec![
962 GoRoute::new("second", move |_, state| {
963 log.borrow_mut().push(state.entering());
964 target(|| {})
965 }),
966 ])]);
967 let mut driver = Driver::new(FrusApp::router(router.clone()), SIDE, SIDE);
968 driver.frame(1.0 / 60.0);
969 router.push("/second");
970 driver.run(1.5);
971 let seen = seen.borrow();
972 assert!(
973 seen.iter().any(|e| (0.0..1.0).contains(e)),
974 "it was built part of the way in: {seen:?}"
975 );
976 assert!(
977 seen.windows(2).all(|w| w[1] >= w[0] - 1e-3),
978 "and only ever further in: {seen:?}"
979 );
980 assert_eq!(seen.last(), Some(&1.0), "settled, it is all the way in");
981 }
982
983 #[test]
984 fn an_open_menu_takes_the_back_gesture_before_the_router_does() {
985 let open = Rc::new(Cell::new(false));
986 let asked = open.clone();
987 let router = GoRouter::new(vec![GoRoute::new("/", |_, _| target(|| {})).routes(vec![
988 GoRoute::new("second", move |_, _| {
989 let asked = asked.clone();
990 component(move |cx| {
991 cx.block_back(asked.get());
992 target(|| {})
993 })
994 }),
995 ])]);
996 let mut driver = Driver::new(FrusApp::router(router.clone()), SIDE, SIDE);
997 driver.frame(1.0 / 60.0);
998 assert!(
999 !driver.app().can_go_back(),
1000 "nowhere to go back to from the first page"
1001 );
1002 router.push("/second");
1003 driver.run(1.5);
1004 assert!(driver.app().can_go_back(), "a page to go back to");
1005 open.set(true);
1006 frus_widgets::request_rebuild();
1007 driver.frame(1.0 / 60.0);
1008 assert!(
1009 !driver.app().can_go_back(),
1010 "the menu is open: back closes it, it does not leave the page"
1011 );
1012 open.set(false);
1013 frus_widgets::request_rebuild();
1014 driver.frame(1.0 / 60.0);
1015 assert!(driver.app().can_go_back(), "and once it is shut, it does");
1016 }
1017}
1018
1019#[cfg(test)]
1020mod address_tests {
1021 use super::*;
1022 use crate::history::{History, Step};
1023 use frus_widgets::{text, GoRoute};
1024
1025 fn router() -> GoRouter {
1026 GoRouter::new(vec![
1027 GoRoute::new("/", |_, _| text("home"))
1028 .routes(vec![GoRoute::new("users/:id", |_, _| text("user"))]),
1029 GoRoute::new("/settings", |_, _| text("settings")),
1030 ])
1031 }
1032
1033 #[test]
1034 fn an_application_of_pages_reports_the_page_it_is_on() {
1035 let router = router();
1036 let app = FrusApp::router(router.clone());
1037 assert_eq!(app.location(), Some("/".to_string()));
1038 router.push("/settings");
1039 assert_eq!(app.location(), Some("/settings".to_string()));
1040 }
1041
1042 #[test]
1043 fn an_application_with_no_router_has_no_address() {
1044 let mut app = FrusApp::from_fn(|_| Box::new(text("one screen")));
1045 assert_eq!(app.location(), None);
1046 let _ = app.open_location("/anywhere");
1047 assert_eq!(app.location(), None, "nothing to move");
1048 }
1049
1050 #[test]
1051 fn an_address_opens_the_page_it_names_with_the_pages_before_it_underneath() {
1052 let router = router();
1053 let mut app = FrusApp::router(router.clone());
1054 let _ = app.open_location("/users/42");
1055 assert_eq!(router.location(), "/users/42");
1056 assert_eq!(router.depth(), 2, "home is underneath, to go back to");
1057 assert!(
1058 !app.tick(1.0),
1059 "opened at an address, nothing slides: there was no page to leave"
1060 );
1061 }
1062
1063 #[test]
1064 fn the_same_address_twice_changes_nothing() {
1065 let router = router();
1066 let mut app = FrusApp::router(router.clone());
1067 let _ = app.open_location("/settings");
1068 let _ = app.open_location("/settings");
1069 assert_eq!(router.depth(), 1);
1070 }
1071
1072 #[test]
1073 fn the_strategy_is_the_hash_unless_the_application_says_otherwise() {
1074 let app = FrusApp::router(router());
1075 assert_eq!(Application::location_strategy(&app), LocationStrategy::Hash);
1076 let app = app.location_strategy(LocationStrategy::Path);
1077 assert_eq!(Application::location_strategy(&app), LocationStrategy::Path);
1078 }
1079
1080 #[test]
1083 fn a_page_opened_deep_gets_the_pages_beneath_it_as_entries_so_its_own_back_goes_back() {
1084 let router = router();
1085 let mut app = FrusApp::router(router.clone());
1086 let mut history = History::new();
1087 history.opened(Some("/users/42".to_string()), None);
1088 let _ = app.open_location("/users/42");
1089 assert_eq!(app.location_stack(), ["/", "/users/42"]);
1090 assert_eq!(
1091 history.seed(&app.location_stack()),
1092 [
1093 (Step::Replace("/".to_string()), 0),
1094 (Step::Push("/users/42".to_string()), 1)
1095 ],
1096 "the entry the page opened at becomes home, and the deep page is pushed on it"
1097 );
1098 assert_eq!(history.reflect(&app.location().unwrap(), false), None);
1099
1100 router.pop();
1101 assert_eq!(router.location(), "/");
1102 assert_eq!(
1103 history.reflect(&app.location().unwrap(), false),
1104 Some(Step::Go(-1)),
1105 "back in the browser's list, not a copy of home on top of it"
1106 );
1107 }
1108
1109 #[test]
1110 fn a_swapped_page_is_reported_as_swapped_and_only_until_the_next_move() {
1111 let router = router();
1112 let app = FrusApp::router(router.clone());
1113 assert!(!app.location_replaces());
1114 router.push("/settings");
1115 assert!(!app.location_replaces());
1116 router.replace("/users/1");
1117 assert!(app.location_replaces(), "a swap: the entry is swapped too");
1118 router.push("/settings");
1119 assert!(!app.location_replaces());
1120 router.replace("/users/2");
1121 router.pop();
1122 assert!(!app.location_replaces(), "a pop is not a swap");
1123 }
1124
1125 #[test]
1126 fn a_redirect_at_the_door_is_the_first_address() {
1127 let router = GoRouter::new(vec![
1128 GoRoute::new("/", |_, _| text("home")),
1129 GoRoute::new("/login", |_, _| text("login")),
1130 ])
1131 .redirect(|state| (state.location() != "/login").then(|| "/login".to_string()));
1132 let mut app = FrusApp::router(router);
1133 let mut history = History::new();
1134 history.opened(Some("/private".to_string()), None);
1135 let _ = app.open_location("/private");
1136 assert_eq!(app.location(), Some("/login".to_string()));
1137 assert_eq!(
1138 history.reflect("/login", false),
1139 Some(Step::Replace("/login".to_string())),
1140 "the address the page opened at is replaced, so back has no `/private` to bounce off"
1141 );
1142 }
1143}
1144
1145#[cfg(test)]
1148mod async_builder_tests {
1149 use super::*;
1150 use crate::app::testing::Driver;
1151 use frus_widgets::{
1152 text, ConnectionState, FutureBuilder, StreamBuilder, ValueListenableBuilder, ValueNotifier,
1153 };
1154 use std::cell::Cell;
1155 use std::sync::atomic::{AtomicUsize, Ordering};
1156 use std::sync::Arc;
1157 use std::time::{Duration, Instant};
1158
1159 fn texts(d: &Driver<FrusApp>) -> Vec<String> {
1161 d.texts().into_iter().map(|(text, _)| text).collect()
1162 }
1163
1164 fn until(d: &mut Driver<FrusApp>, wanted: &str) -> bool {
1167 let start = Instant::now();
1168 while start.elapsed() < Duration::from_secs(2) {
1169 d.frame(1.0 / 60.0);
1170 if texts(d).iter().any(|t| t == wanted) {
1171 return true;
1172 }
1173 std::thread::sleep(Duration::from_millis(5));
1174 }
1175 false
1176 }
1177
1178 #[test]
1181 fn a_future_is_shown_when_it_comes_and_started_once() {
1182 let started = Arc::new(AtomicUsize::new(0));
1183 let counted = started.clone();
1184 let app = FrusApp::from_fn(move |_| {
1185 let counted = counted.clone();
1186 Box::new(FutureBuilder::new(
1187 (),
1188 move |_| {
1189 counted.fetch_add(1, Ordering::SeqCst);
1190 async {
1191 std::thread::sleep(Duration::from_millis(30));
1192 42
1193 }
1194 },
1195 |_, snapshot| {
1196 Box::new(text(match (snapshot.state, snapshot.data) {
1197 (ConnectionState::Done, Some(value)) => format!("done {value}"),
1198 (ConnectionState::Waiting, None) => "waiting".to_string(),
1199 other => format!("{other:?}"),
1200 }))
1201 },
1202 ))
1203 });
1204 let mut d = Driver::new(app, 200.0, 200.0);
1205 d.frame(1.0 / 60.0);
1206 assert!(
1207 texts(&d).contains(&"waiting".to_string()),
1208 "{:?}",
1209 texts(&d)
1210 );
1211 assert!(until(&mut d, "done 42"), "{:?}", texts(&d));
1212 for _ in 0..10 {
1213 d.frame(1.0 / 60.0);
1214 }
1215 assert_eq!(started.load(Ordering::SeqCst), 1, "started once");
1216 }
1217
1218 #[test]
1220 fn a_new_key_starts_again() {
1221 let key = Rc::new(Cell::new(1));
1222 let read = key.clone();
1223 let app = FrusApp::from_fn(move |_| {
1224 Box::new(FutureBuilder::new(
1225 read.get(),
1226 |k: &i32| {
1227 let k = *k;
1228 async move { k * 10 }
1229 },
1230 |_, snapshot| Box::new(text(format!("{:?}", snapshot.data))),
1231 ))
1232 });
1233 let mut d = Driver::new(app, 200.0, 200.0);
1234 assert!(until(&mut d, "Some(10)"), "{:?}", texts(&d));
1235 key.set(2);
1236 frus_widgets::request_rebuild();
1237 assert!(until(&mut d, "Some(20)"), "{:?}", texts(&d));
1238 }
1239
1240 #[test]
1242 fn a_stream_shows_its_latest_then_is_done() {
1243 let app = FrusApp::from_fn(|_| {
1244 Box::new(StreamBuilder::new(
1245 (),
1246 |_, sink| async move {
1247 for n in 1..=3 {
1248 sink.add(n);
1249 std::thread::sleep(Duration::from_millis(10));
1250 }
1251 },
1252 |_, snapshot| Box::new(text(format!("{:?} {:?}", snapshot.state, snapshot.data))),
1253 ))
1254 });
1255 let mut d = Driver::new(app, 200.0, 200.0);
1256 assert!(until(&mut d, "Done Some(3)"), "{:?}", texts(&d));
1257 }
1258
1259 #[test]
1261 fn a_value_listened_to_is_shown_when_it_changes() {
1262 let count = ValueNotifier::new(1);
1263 let shown = count.clone();
1264 let app = FrusApp::from_fn(move |_| {
1265 Box::new(ValueListenableBuilder::new(shown.clone(), |_, n: &i32| {
1266 Box::new(text(format!("count {n}")))
1267 }))
1268 });
1269 let mut d = Driver::new(app, 200.0, 200.0);
1270 d.frame(1.0 / 60.0);
1271 assert!(
1272 texts(&d).contains(&"count 1".to_string()),
1273 "{:?}",
1274 texts(&d)
1275 );
1276 count.set(5);
1277 d.frame(1.0 / 60.0);
1278 assert!(
1279 texts(&d).contains(&"count 5".to_string()),
1280 "{:?}",
1281 texts(&d)
1282 );
1283 }
1284}
1285
1286#[cfg(test)]
1291mod suffix_tests {
1292 use super::*;
1293 use crate::app::testing::Driver;
1294 use frus_widgets::{Container, Icons, Point, State, StateContext, TextField};
1295 use std::cell::RefCell;
1296
1297 const SIDE: f32 = 200.0;
1298 const PAD: f32 = 16.0;
1300 const FIELD: f32 = 160.0;
1301
1302 #[derive(Clone)]
1303 struct Password {
1304 seen: Rc<RefCell<Vec<bool>>>,
1305 }
1306
1307 struct PasswordState {
1308 revealed: bool,
1309 seen: Rc<RefCell<Vec<bool>>>,
1310 }
1311
1312 impl StatefulWidget for Password {
1313 type State = PasswordState;
1314 fn create_state(&self) -> PasswordState {
1315 PasswordState {
1316 revealed: false,
1317 seen: self.seen.clone(),
1318 }
1319 }
1320 }
1321
1322 impl State for PasswordState {
1323 type Widget = Password;
1324 fn build(&self, cx: &StateContext<Self>) -> Box<dyn Widget> {
1325 self.seen.borrow_mut().push(self.revealed);
1326 Box::new(
1327 Container::new().padding_each(13.0, PAD, 13.0, PAD).child(
1328 TextField::new("secret")
1329 .width(FIELD)
1330 .borderless()
1331 .obscure(!self.revealed)
1332 .suffix_icon(Icons::VISIBILITY)
1333 .on_suffix(cx.callback(|s: &mut PasswordState| s.revealed = !s.revealed)),
1334 ),
1335 )
1336 }
1337 }
1338
1339 fn tap(driver: &mut Driver<FrusApp>, at: Point) {
1340 driver.press(at);
1341 driver.release(at);
1342 driver.frame(1.0 / 60.0);
1343 }
1344
1345 #[test]
1346 fn a_tap_on_a_fields_suffix_reaches_the_state_and_a_tap_on_its_text_does_not() {
1347 let seen: Rc<RefCell<Vec<bool>>> = Rc::default();
1348 let mut driver = Driver::new(
1349 FrusApp::stateful(Password { seen: seen.clone() }),
1350 SIDE,
1351 SIDE,
1352 );
1353 driver.frame(1.0 / 60.0);
1354 let y = 13.0 + 10.0;
1355
1356 tap(&mut driver, Point::new(PAD + 20.0, y));
1357 assert_eq!(
1358 seen.borrow().last(),
1359 Some(&false),
1360 "the text: {:?}",
1361 seen.borrow()
1362 );
1363
1364 let eye = Point::new(PAD + FIELD - 8.0, y);
1365 tap(&mut driver, eye);
1366 assert_eq!(
1367 seen.borrow().last(),
1368 Some(&true),
1369 "revealed: {:?}",
1370 seen.borrow()
1371 );
1372 tap(&mut driver, eye);
1373 assert_eq!(
1374 seen.borrow().last(),
1375 Some(&false),
1376 "hidden again: {:?}",
1377 seen.borrow()
1378 );
1379 }
1380}