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