Skip to main content

frus_shell/
widget_app.rs

1//! [`FrusApp`]: an application made of components.
2//!
3//! An application here is a root widget, and whatever it builds. Nothing else is
4//! declared: no message type, no `update`, no `view`. State lives in the components that
5//! use it, and an interaction runs the closure the widget was given.
6//!
7//! ```ignore
8//! use frus::{button, column, text, FrusApp};
9//!
10//! frus::main!(FrusApp::from_fn(|cx| {
11//!     let count = cx.use_state(|| 0);
12//!     let add = count.clone();
13//!     Box::new(column![
14//!         text(format!("{}", count.get())),
15//!         button("+", move || add.update(|n| *n += 1)),
16//!     ])
17//! }));
18//! ```
19//!
20//! It is an [`Application`] like any other — the shell drives it through the same
21//! contract — whose message is a [`Callback`] and whose `update` runs it.
22
23use 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
35/// What a live reload keeps, and what it hands back.
36type Save = Box<dyn Fn() -> Option<Vec<u8>>>;
37type Restore = Box<dyn Fn(&[u8])>;
38
39/// What builds the root: called on every rebuild.
40type Root = Rc<dyn Fn(&BuildContext) -> Box<dyn Widget>>;
41
42/// An application: a root component, and how it is dressed.
43///
44/// ```
45/// use frus_shell::FrusApp;
46/// use frus_widgets::{text, Theme, ThemeMode};
47///
48/// let app = FrusApp::from_fn(|_| Box::new(text("hello")))
49///     .title("Hello")
50///     .theme(Theme::light())
51///     .dark_theme(Theme::dark())
52///     .theme_mode(ThemeMode::System);
53/// # let _ = app;
54/// ```
55///
56/// How it is dressed — its title, themes, language, zoom — is kept where a component can reach
57/// it too, in [`host::app`]: what the builders here set, a component changes with the same
58/// names (`host::app().set_theme_mode(..)`), and the shell reads it every frame.
59pub struct FrusApp {
60    root: Root,
61    router: Option<GoRouter>,
62    window_size: Option<(f32, f32)>,
63    on_start: Option<Box<dyn FnOnce()>>,
64    save: Option<Save>,
65    restore: Option<Restore>,
66    strategy: LocationStrategy,
67    instance: Option<String>,
68}
69
70impl FrusApp {
71    /// An application whose interface is the [`StatelessWidget`] `root`.
72    pub fn new(root: impl StatelessWidget) -> Self {
73        Self::with_root(Rc::new(move |cx| root.build(cx)))
74    }
75
76    /// An application whose interface is a function of the build context — hooks may be
77    /// called in it.
78    pub fn from_fn(root: impl Fn(&BuildContext) -> Box<dyn Widget> + 'static) -> Self {
79        Self::with_root(Rc::new(root))
80    }
81
82    /// An application whose root is a [`StatefulWidget`]. It is configuration, made again
83    /// on every rebuild — hence `Clone`.
84    pub fn stateful<W: StatefulWidget + Clone>(root: W) -> Self {
85        Self::with_root(Rc::new(move |_| {
86            Box::new(Component::stateful(root.clone()))
87        }))
88    }
89
90    /// An application whose pages are the routes of `router`: the router builds the interface,
91    /// moves it when told a location, and answers the back gesture.
92    ///
93    /// ```
94    /// use frus_shell::FrusApp;
95    /// use frus_widgets::{text, GoRoute, GoRouter};
96    ///
97    /// let router = GoRouter::new(vec![
98    ///     GoRoute::new("/", |_, _| text("home")),
99    ///     GoRoute::new("/about", |_, _| text("about")),
100    /// ]);
101    /// let app = FrusApp::router(router).title("Pages");
102    /// # let _ = app;
103    /// ```
104    pub fn router(router: GoRouter) -> Self {
105        let building = router.clone();
106        let mut app = Self::with_root(Rc::new(move |cx| building.build(cx)));
107        app.router = Some(router);
108        app
109    }
110
111    fn with_root(root: Root) -> Self {
112        // A new application starts from the defaults, whatever the last one left behind.
113        let app = host::app();
114        app.set_title("frus".to_string());
115        app.set_theme(Theme::light());
116        app.set_dark_theme(None);
117        app.set_theme_mode(ThemeMode::System);
118        app.set_density(1.0);
119        app.set_locale(None);
120        app.set_supported_locales(Vec::new());
121        app.set_localizations(None);
122        Self {
123            root,
124            router: None,
125            window_size: None,
126            on_start: None,
127            save: None,
128            restore: None,
129            strategy: LocationStrategy::Hash,
130            instance: None,
131        }
132    }
133
134    /// Makes the application **one window** on a desktop, named `id` — a reverse domain such as
135    /// `com.example.myapp`. A second process started with a link (`myapp://orders/42`, how a
136    /// desktop opens a registered scheme) hands it to the first, which opens the location it
137    /// names, and ends. See [`Application::instance_id`].
138    pub fn single_instance(mut self, id: impl Into<String>) -> Self {
139        self.instance = Some(id.into());
140        self
141    }
142
143    /// How the router's location is written in a browser's address bar: after a `#` (the
144    /// default, which any static host serves) or as a path (which needs the server to answer
145    /// every address with the page). Only the web has an address bar to show it in. See
146    /// [`LocationStrategy`].
147    pub fn location_strategy(mut self, strategy: LocationStrategy) -> Self {
148        self.strategy = strategy;
149        self
150    }
151
152    /// The window's title.
153    pub fn title(self, title: impl Into<String>) -> Self {
154        host::app().set_title(title.into());
155        self
156    }
157
158    /// The application's theme — its light one, where it has two.
159    pub fn theme(self, theme: Theme) -> Self {
160        host::app().set_theme(theme);
161        self
162    }
163
164    /// The theme for a dark interface. With one, the application follows the system's
165    /// brightness, or [`theme_mode`](Self::theme_mode).
166    pub fn dark_theme(self, theme: Theme) -> Self {
167        host::app().set_dark_theme(Some(theme));
168        self
169    }
170
171    /// Which of the two themes is on display: the system's choice by default.
172    pub fn theme_mode(self, mode: ThemeMode) -> Self {
173        host::app().set_theme_mode(mode);
174        self
175    }
176
177    /// The languages the application has, best first. The framework resolves the device's
178    /// list against them.
179    pub fn supported_locales(self, locales: Vec<Locale>) -> Self {
180        host::app().set_supported_locales(locales);
181        self
182    }
183
184    /// The words the framework says on the application's behalf, where they are not English.
185    pub fn localizations(self, table: std::rc::Rc<dyn Localizations>) -> Self {
186        host::app().set_localizations(Some(table));
187        self
188    }
189
190    /// Runs `start` once, when the application starts and before anything is drawn: the place
191    /// for what has to be true from the first frame — registering data, opening a store.
192    pub fn on_start(mut self, start: impl FnOnce() + 'static) -> Self {
193        self.on_start = Some(Box::new(start));
194        self
195    }
196
197    /// Keeps what `save` returns across a live reload, and hands it to `restore` afterwards.
198    /// A development convenience: the state of an application survives a recompilation.
199    pub fn persist(
200        mut self,
201        save: impl Fn() -> Option<Vec<u8>> + 'static,
202        restore: impl Fn(&[u8]) + 'static,
203    ) -> Self {
204        self.save = Some(Box::new(save));
205        self.restore = Some(Box::new(restore));
206        self
207    }
208
209    /// The window's initial size, in logical pixels.
210    pub fn window_size(mut self, width: f32, height: f32) -> Self {
211        self.window_size = Some((width, height));
212        self
213    }
214
215    /// A zoom on the whole interface, on top of the system's scale.
216    pub fn density(self, density: f32) -> Self {
217        host::app().set_density(density);
218        self
219    }
220}
221
222impl Application for FrusApp {
223    type Message = Callback;
224
225    fn update(&mut self, callback: Callback) -> Command<Callback> {
226        callback.call();
227        Command::none()
228    }
229
230    fn init(&mut self) -> Command<Callback> {
231        if let Some(start) = self.on_start.take() {
232            start();
233        }
234        self.effects()
235    }
236
237    fn effects(&mut self) -> Command<Callback> {
238        Command::batch(host::take_effects().into_iter().map(|effect| match effect {
239            Effect::Focus(key) => Command::focus_hashed(key),
240            Effect::Scroll(key, to) => Command::scroll_hashed(key, to),
241            Effect::Sheet(key, to) => Command::sheet_hashed(key, to),
242            Effect::Spawn(task) => Command::run(task),
243            Effect::After(delay, callback) => Command::after(delay, callback),
244        }))
245    }
246
247    fn save_state(&self) -> Option<Vec<u8>> {
248        self.save.as_ref().and_then(|save| save())
249    }
250
251    fn restore_state(&mut self, bytes: &[u8]) {
252        if let Some(restore) = &self.restore {
253            restore(bytes);
254        }
255    }
256
257    fn view(&self, _theme: &Theme) -> Box<dyn Widget<Callback>> {
258        let root = self.root.clone();
259        Box::new(Component::stateless(move |cx: &BuildContext| root(cx)))
260    }
261
262    fn subscription(&self) -> Subscription<Callback> {
263        // The timers the components of the latest build asked for. The shell diffs them: a
264        // timer whose component no longer asks for it is stopped.
265        Subscription::batch(frus_widgets::intervals().into_iter().map(|interval| {
266            let callback = interval.callback;
267            Subscription::every_keyed(interval.id, interval.period, move |_| callback.clone())
268        }))
269    }
270
271    fn tick(&mut self, dt: f32) -> bool {
272        self.router.as_ref().is_some_and(|router| router.tick(dt))
273    }
274
275    fn location(&self) -> Option<String> {
276        self.router.as_ref().map(GoRouter::location)
277    }
278
279    fn location_replaces(&self) -> bool {
280        self.router.as_ref().is_some_and(GoRouter::replaced)
281    }
282
283    fn location_stack(&self) -> Vec<String> {
284        self.router
285            .as_ref()
286            .map(GoRouter::locations)
287            .unwrap_or_default()
288    }
289
290    fn open_location(&mut self, location: &str) -> Command<Callback> {
291        if let Some(router) = &self.router {
292            router.open(location);
293        }
294        Command::none()
295    }
296
297    fn location_strategy(&self) -> LocationStrategy {
298        self.strategy
299    }
300
301    fn instance_id(&self) -> Option<String> {
302        self.instance.clone()
303    }
304
305    fn can_go_back(&self) -> bool {
306        // What an open menu or dialog takes first is not the router's to answer.
307        !frus_widgets::back_blocked()
308            && self
309                .router
310                .as_ref()
311                .is_some_and(|router| router.can_go_back())
312    }
313
314    fn back_gesture(&mut self, progress: f32) {
315        if let Some(router) = &self.router {
316            router.back_gesture(progress);
317        }
318    }
319
320    fn back_gesture_end(&mut self, velocity: f32) {
321        if let Some(router) = &self.router {
322            router.back_gesture_end(velocity);
323        }
324    }
325
326    fn theme(&self) -> Theme {
327        host::app().theme()
328    }
329
330    fn dark_theme(&self) -> Option<Theme> {
331        host::app().dark_theme()
332    }
333
334    fn theme_mode(&self) -> ThemeMode {
335        host::app().theme_mode()
336    }
337
338    fn supported_locales(&self) -> Vec<Locale> {
339        host::app().supported_locales()
340    }
341
342    fn locale(&self) -> Option<Locale> {
343        host::app().locale()
344    }
345
346    fn localizations(&self) -> Option<std::rc::Rc<dyn Localizations>> {
347        host::app().localizations()
348    }
349
350    fn title(&self) -> String {
351        host::app().title()
352    }
353
354    fn window_size(&self) -> Option<(f32, f32)> {
355        self.window_size
356    }
357
358    fn density(&self) -> f32 {
359        host::app().density()
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use crate::app::testing::Driver;
367    use frus_widgets::{Container, Point, State, StateContext};
368    use std::cell::RefCell;
369
370    const SIDE: f32 = 200.0;
371    type Seen = Rc<RefCell<Vec<i32>>>;
372
373    /// A window-sized target: a tap anywhere lands on it.
374    fn target(on_tap: impl Into<Callback>) -> Box<dyn Widget> {
375        Box::new(Container::new().width(SIDE).height(SIDE).on_click(on_tap))
376    }
377
378    fn tap(driver: &mut Driver<FrusApp>) {
379        let at = Point::new(SIDE / 2.0, SIDE / 2.0);
380        driver.press(at);
381        driver.release(at);
382        driver.frame(1.0 / 60.0);
383    }
384
385    #[test]
386    fn a_hook_survives_a_tap_and_the_view_is_built_again_with_the_new_value() {
387        let seen: Seen = Rc::default();
388        let probe = seen.clone();
389        let mut driver = Driver::new(
390            FrusApp::from_fn(move |cx| {
391                let count = cx.use_state(|| 0);
392                probe.borrow_mut().push(count.get());
393                let add = count.clone();
394                target(move || add.update(|n| *n += 1))
395            }),
396            SIDE,
397            SIDE,
398        );
399        driver.frame(1.0 / 60.0);
400        assert_eq!(*seen.borrow(), [0], "built once to begin with");
401        tap(&mut driver);
402        tap(&mut driver);
403        tap(&mut driver);
404        assert_eq!(
405            seen.borrow().last(),
406            Some(&3),
407            "three taps, three changes, each kept: {:?}",
408            seen.borrow()
409        );
410    }
411
412    #[derive(Clone)]
413    struct Counter {
414        seen: Seen,
415    }
416
417    struct CounterState {
418        count: i32,
419        seen: Seen,
420    }
421
422    impl StatefulWidget for Counter {
423        type State = CounterState;
424        fn create_state(&self) -> CounterState {
425            CounterState {
426                count: 100,
427                seen: self.seen.clone(),
428            }
429        }
430    }
431
432    impl State for CounterState {
433        type Widget = Counter;
434        fn build(&self, cx: &StateContext<Self>) -> Box<dyn Widget> {
435            self.seen.borrow_mut().push(self.count);
436            target(cx.callback(|state| state.count += 1))
437        }
438    }
439
440    #[test]
441    fn a_stateful_root_keeps_its_state_between_taps() {
442        let seen: Seen = Rc::default();
443        let mut driver = Driver::new(
444            FrusApp::stateful(Counter { seen: seen.clone() }),
445            SIDE,
446            SIDE,
447        );
448        driver.frame(1.0 / 60.0);
449        tap(&mut driver);
450        tap(&mut driver);
451        assert_eq!(seen.borrow().last(), Some(&102), "{:?}", seen.borrow());
452    }
453
454    #[test]
455    fn a_value_handler_that_returns_nothing_runs_once_per_event() {
456        use frus_widgets::Checkbox;
457        let toggles: Rc<RefCell<Vec<bool>>> = Rc::default();
458        let log = toggles.clone();
459        let mut driver = Driver::new(
460            FrusApp::from_fn(move |_| {
461                let log = log.clone();
462                Box::new(Checkbox::new(false).on_toggle(move |on| log.borrow_mut().push(on)))
463            }),
464            SIDE,
465            SIDE,
466        );
467        driver.frame(1.0 / 60.0);
468        // a checkbox is small, and at the window's corner
469        let at = Point::new(12.0, 12.0);
470        driver.press(at);
471        driver.release(at);
472        driver.frame(1.0 / 60.0);
473        assert_eq!(*toggles.borrow(), [true], "one tap, one call");
474    }
475
476    #[test]
477    fn a_timer_a_component_asks_for_is_a_subscription_while_it_asks() {
478        let on = Rc::new(std::cell::Cell::new(true));
479        let wanted = on.clone();
480        let mut driver = Driver::new(
481            FrusApp::from_fn(move |cx| {
482                if wanted.get() {
483                    cx.use_interval(std::time::Duration::from_secs(1), || {});
484                }
485                target(|| {})
486            }),
487            SIDE,
488            SIDE,
489        );
490        assert!(driver.app().subscription().is_empty(), "nothing built yet");
491        driver.frame(1.0 / 60.0);
492        assert_eq!(driver.app().subscription().ids().len(), 1);
493        on.set(false);
494        frus_widgets::request_rebuild();
495        driver.frame(1.0 / 60.0);
496        assert!(
497            driver.app().subscription().is_empty(),
498            "and it stops when it is not asked for"
499        );
500    }
501
502    #[test]
503    fn an_effect_runs_after_the_frame_and_a_change_it_makes_is_shown() {
504        let seen: Seen = Rc::default();
505        let probe = seen.clone();
506        let mut driver = Driver::new(
507            FrusApp::from_fn(move |cx| {
508                let count = cx.use_state(|| 0);
509                probe.borrow_mut().push(count.get());
510                let set = count.clone();
511                cx.use_effect((), move || set.set(7));
512                target(|| {})
513            }),
514            SIDE,
515            SIDE,
516        );
517        driver.frame(1.0 / 60.0);
518        driver.frame(1.0 / 60.0);
519        assert_eq!(
520            *seen.borrow(),
521            [0, 7],
522            "built with 0, the effect set 7, and the next frame built with it"
523        );
524    }
525}
526
527#[cfg(test)]
528mod router_tests {
529    use super::*;
530    use crate::app::testing::Driver;
531    use frus_widgets::{component, Container, GoRoute, Point};
532    use std::cell::RefCell;
533
534    const SIDE: f32 = 200.0;
535
536    fn target(on_tap: impl Into<Callback>) -> Box<dyn Widget> {
537        Box::new(Container::new().width(SIDE).height(SIDE).on_click(on_tap))
538    }
539
540    fn tap(driver: &mut Driver<FrusApp>) {
541        let at = Point::new(SIDE / 2.0, SIDE / 2.0);
542        driver.press(at);
543        driver.release(at);
544        driver.frame(1.0 / 60.0);
545    }
546
547    /// Two pages: the first counts its taps in a hook and pushes the second; the second pops.
548    fn app(seen: Rc<RefCell<Vec<i32>>>) -> (FrusApp, GoRouter) {
549        let router = GoRouter::new(vec![
550            GoRoute::new("/", move |_, _| {
551                let seen = seen.clone();
552                component(move |cx| {
553                    let visits = cx.use_state(|| 0);
554                    seen.borrow_mut().push(visits.get());
555                    let router = cx.router();
556                    target(move || {
557                        visits.update(|n| *n += 1);
558                        router.push("/second");
559                    })
560                })
561            }),
562            GoRoute::new("/second", |cx, _| {
563                let router = cx.router();
564                target(move || {
565                    router.pop();
566                })
567            }),
568        ]);
569        (FrusApp::router(router.clone()), router)
570    }
571
572    #[test]
573    fn a_tap_pushes_a_page_and_the_covered_page_keeps_what_its_components_hold() {
574        let seen = Rc::default();
575        let (app, router) = app(Rc::clone(&seen));
576        let mut driver = Driver::new(app, SIDE, SIDE);
577        driver.frame(1.0 / 60.0);
578        assert_eq!((router.location(), router.depth()), ("/".to_string(), 1));
579
580        tap(&mut driver);
581        driver.run(1.5);
582        assert_eq!(router.location(), "/second", "the tap pushed a page");
583        assert_eq!(router.depth(), 2);
584        assert_eq!(
585            seen.borrow().last(),
586            Some(&1),
587            "the page underneath is still built, holding what it held: {:?}",
588            seen.borrow()
589        );
590
591        tap(&mut driver);
592        driver.run(1.5);
593        assert_eq!(
594            router.location(),
595            "/",
596            "the tap on the second page went back"
597        );
598        assert_eq!(
599            seen.borrow().last(),
600            Some(&1),
601            "and the first page is shown again with its state, not made anew"
602        );
603    }
604
605    #[test]
606    fn a_back_gesture_carries_the_top_page_away() {
607        let (app, router) = app(Rc::default());
608        let mut driver = Driver::new(app, SIDE, SIDE);
609        driver.frame(1.0 / 60.0);
610        router.push("/second");
611        driver.run(1.5);
612        assert!(FrusApp::router(router.clone()).can_go_back());
613
614        router.back_gesture(0.9);
615        driver.frame(1.0 / 60.0);
616        router.back_gesture_end(0.0);
617        driver.run(1.5);
618        assert_eq!((router.location(), router.depth()), ("/".to_string(), 1));
619    }
620
621    #[test]
622    fn a_popped_page_is_disposed_once_its_transition_is_over() {
623        let disposed = Rc::new(std::cell::Cell::new(0));
624        let counted = disposed.clone();
625        let router = GoRouter::new(vec![
626            GoRoute::new("/", |_, _| Container::new().width(SIDE).height(SIDE)),
627            GoRoute::new("/gone", move |_, _| {
628                let counted = counted.clone();
629                component(move |cx| {
630                    let counted = counted.clone();
631                    cx.use_effect((), move || move || counted.set(counted.get() + 1));
632                    Box::new(Container::new().width(SIDE).height(SIDE)) as Box<dyn Widget>
633                })
634            }),
635        ]);
636        let mut driver = Driver::new(FrusApp::router(router.clone()), SIDE, SIDE);
637        driver.frame(1.0 / 60.0);
638        router.push("/gone");
639        driver.run(1.5);
640        assert_eq!(disposed.get(), 0, "on the stack, it is alive");
641        router.pop();
642        driver.run(1.5);
643        assert_eq!(disposed.get(), 1, "popped and gone, its cleanup has run");
644    }
645}
646
647#[cfg(test)]
648mod host_tests {
649    use super::*;
650    use crate::app::testing::Driver;
651    use frus_widgets::{component, Container, GoRoute, ScrollTo};
652    use std::cell::{Cell, RefCell};
653
654    const SIDE: f32 = 200.0;
655
656    fn target(on_tap: impl Into<Callback>) -> Box<dyn Widget> {
657        Box::new(Container::new().width(SIDE).height(SIDE).on_click(on_tap))
658    }
659
660    fn quiet() -> FrusApp {
661        FrusApp::from_fn(|_| target(|| {}))
662    }
663
664    #[test]
665    fn the_builders_and_a_component_write_to_the_same_settings_the_shell_reads() {
666        let app = quiet()
667            .title("first")
668            .theme_mode(ThemeMode::Light)
669            .density(1.1);
670        assert_eq!(Application::title(&app), "first");
671        assert_eq!(Application::theme_mode(&app), ThemeMode::Light);
672        assert_eq!(Application::density(&app), 1.1);
673        // What a handler does from inside the tree.
674        host::app().set_title("second".to_string());
675        host::app().set_theme_mode(ThemeMode::Dark);
676        host::app().set_dark_theme(Some(Theme::dark()));
677        assert_eq!(Application::title(&app), "second");
678        assert_eq!(Application::theme_mode(&app), ThemeMode::Dark);
679        assert!(Application::dark_theme(&app).is_some());
680    }
681
682    #[test]
683    fn a_new_application_starts_from_the_defaults_not_from_the_last_one() {
684        host::app().set_theme_mode(ThemeMode::Dark);
685        host::app().set_density(1.4);
686        host::app().set_locale(Some(Locale::new("fr")));
687        let app = quiet();
688        assert_eq!(Application::theme_mode(&app), ThemeMode::System);
689        assert_eq!(Application::density(&app), 1.0);
690        assert_eq!(Application::locale(&app), None);
691    }
692
693    #[test]
694    fn the_language_a_component_picks_is_the_one_the_application_answers() {
695        let app = quiet().supported_locales(vec![Locale::new("en"), Locale::new("fr")]);
696        assert_eq!(Application::supported_locales(&app).len(), 2);
697        host::app().set_locale(Some(Locale::new("fr")));
698        assert_eq!(Application::locale(&app), Some(Locale::new("fr")));
699    }
700
701    #[test]
702    fn what_a_component_asks_of_the_shell_becomes_a_command_once() {
703        let mut app = quiet();
704        let _ = host::take_effects();
705        assert!(app.effects().is_empty(), "nothing asked, nothing to run");
706        host::focus("name");
707        host::scroll_to("list", ScrollTo::start());
708        host::after(std::time::Duration::from_secs(1), || {});
709        host::spawn(|| 1 + 1, |_| {});
710        assert!(!app.effects().is_empty());
711        assert!(app.effects().is_empty(), "and taken once");
712    }
713
714    #[test]
715    fn on_start_runs_once_before_the_first_frame() {
716        let started = Rc::new(Cell::new(0));
717        let count = started.clone();
718        let mut app = quiet().on_start(move || count.set(count.get() + 1));
719        let _ = app.init();
720        let _ = app.init();
721        assert_eq!(started.get(), 1);
722    }
723
724    #[test]
725    fn a_state_can_be_kept_across_a_live_reload() {
726        let held = Rc::new(RefCell::new(0u8));
727        let (read, write) = (held.clone(), held.clone());
728        let mut app = quiet().persist(
729            move || Some(vec![*read.borrow()]),
730            move |bytes| *write.borrow_mut() = bytes[0],
731        );
732        *held.borrow_mut() = 7;
733        let bytes = app.save_state().expect("something to keep");
734        *held.borrow_mut() = 0;
735        app.restore_state(&bytes);
736        assert_eq!(*held.borrow(), 7);
737        assert!(quiet().save_state().is_none(), "nothing kept unless asked");
738    }
739
740    #[test]
741    fn a_page_is_told_how_far_in_it_is_while_it_arrives() {
742        let seen = Rc::new(RefCell::new(Vec::<f32>::new()));
743        let log = seen.clone();
744        let router = GoRouter::new(vec![GoRoute::new("/", |_, _| target(|| {})).routes(vec![
745            GoRoute::new("second", move |_, state| {
746                log.borrow_mut().push(state.entering());
747                target(|| {})
748            }),
749        ])]);
750        let mut driver = Driver::new(FrusApp::router(router.clone()), SIDE, SIDE);
751        driver.frame(1.0 / 60.0);
752        router.push("/second");
753        driver.run(1.5);
754        let seen = seen.borrow();
755        assert!(
756            seen.iter().any(|e| (0.0..1.0).contains(e)),
757            "it was built part of the way in: {seen:?}"
758        );
759        assert!(
760            seen.windows(2).all(|w| w[1] >= w[0] - 1e-3),
761            "and only ever further in: {seen:?}"
762        );
763        assert_eq!(seen.last(), Some(&1.0), "settled, it is all the way in");
764    }
765
766    #[test]
767    fn an_open_menu_takes_the_back_gesture_before_the_router_does() {
768        let open = Rc::new(Cell::new(false));
769        let asked = open.clone();
770        let router = GoRouter::new(vec![GoRoute::new("/", |_, _| target(|| {})).routes(vec![
771            GoRoute::new("second", move |_, _| {
772                let asked = asked.clone();
773                component(move |cx| {
774                    cx.block_back(asked.get());
775                    target(|| {})
776                })
777            }),
778        ])]);
779        let mut driver = Driver::new(FrusApp::router(router.clone()), SIDE, SIDE);
780        driver.frame(1.0 / 60.0);
781        assert!(
782            !driver.app().can_go_back(),
783            "nowhere to go back to from the first page"
784        );
785        router.push("/second");
786        driver.run(1.5);
787        assert!(driver.app().can_go_back(), "a page to go back to");
788        open.set(true);
789        frus_widgets::request_rebuild();
790        driver.frame(1.0 / 60.0);
791        assert!(
792            !driver.app().can_go_back(),
793            "the menu is open: back closes it, it does not leave the page"
794        );
795        open.set(false);
796        frus_widgets::request_rebuild();
797        driver.frame(1.0 / 60.0);
798        assert!(driver.app().can_go_back(), "and once it is shut, it does");
799    }
800}
801
802#[cfg(test)]
803mod address_tests {
804    use super::*;
805    use crate::history::{History, Step};
806    use frus_widgets::{text, GoRoute};
807
808    fn router() -> GoRouter {
809        GoRouter::new(vec![
810            GoRoute::new("/", |_, _| text("home"))
811                .routes(vec![GoRoute::new("users/:id", |_, _| text("user"))]),
812            GoRoute::new("/settings", |_, _| text("settings")),
813        ])
814    }
815
816    #[test]
817    fn an_application_of_pages_reports_the_page_it_is_on() {
818        let router = router();
819        let app = FrusApp::router(router.clone());
820        assert_eq!(app.location(), Some("/".to_string()));
821        router.push("/settings");
822        assert_eq!(app.location(), Some("/settings".to_string()));
823    }
824
825    #[test]
826    fn an_application_with_no_router_has_no_address() {
827        let mut app = FrusApp::from_fn(|_| Box::new(text("one screen")));
828        assert_eq!(app.location(), None);
829        let _ = app.open_location("/anywhere");
830        assert_eq!(app.location(), None, "nothing to move");
831    }
832
833    #[test]
834    fn an_address_opens_the_page_it_names_with_the_pages_before_it_underneath() {
835        let router = router();
836        let mut app = FrusApp::router(router.clone());
837        let _ = app.open_location("/users/42");
838        assert_eq!(router.location(), "/users/42");
839        assert_eq!(router.depth(), 2, "home is underneath, to go back to");
840        assert!(
841            !app.tick(1.0),
842            "opened at an address, nothing slides: there was no page to leave"
843        );
844    }
845
846    #[test]
847    fn the_same_address_twice_changes_nothing() {
848        let router = router();
849        let mut app = FrusApp::router(router.clone());
850        let _ = app.open_location("/settings");
851        let _ = app.open_location("/settings");
852        assert_eq!(router.depth(), 1);
853    }
854
855    #[test]
856    fn the_strategy_is_the_hash_unless_the_application_says_otherwise() {
857        let app = FrusApp::router(router());
858        assert_eq!(Application::location_strategy(&app), LocationStrategy::Hash);
859        let app = app.location_strategy(LocationStrategy::Path);
860        assert_eq!(Application::location_strategy(&app), LocationStrategy::Path);
861    }
862
863    /// The application and the browser's history, wired the way the shell wires them, with
864    /// the router as the application.
865    #[test]
866    fn a_page_opened_deep_gets_the_pages_beneath_it_as_entries_so_its_own_back_goes_back() {
867        let router = router();
868        let mut app = FrusApp::router(router.clone());
869        let mut history = History::new();
870        history.opened(Some("/users/42".to_string()), None);
871        let _ = app.open_location("/users/42");
872        assert_eq!(app.location_stack(), ["/", "/users/42"]);
873        assert_eq!(
874            history.seed(&app.location_stack()),
875            [
876                (Step::Replace("/".to_string()), 0),
877                (Step::Push("/users/42".to_string()), 1)
878            ],
879            "the entry the page opened at becomes home, and the deep page is pushed on it"
880        );
881        assert_eq!(history.reflect(&app.location().unwrap(), false), None);
882
883        router.pop();
884        assert_eq!(router.location(), "/");
885        assert_eq!(
886            history.reflect(&app.location().unwrap(), false),
887            Some(Step::Go(-1)),
888            "back in the browser's list, not a copy of home on top of it"
889        );
890    }
891
892    #[test]
893    fn a_swapped_page_is_reported_as_swapped_and_only_until_the_next_move() {
894        let router = router();
895        let app = FrusApp::router(router.clone());
896        assert!(!app.location_replaces());
897        router.push("/settings");
898        assert!(!app.location_replaces());
899        router.replace("/users/1");
900        assert!(app.location_replaces(), "a swap: the entry is swapped too");
901        router.push("/settings");
902        assert!(!app.location_replaces());
903        router.replace("/users/2");
904        router.pop();
905        assert!(!app.location_replaces(), "a pop is not a swap");
906    }
907
908    #[test]
909    fn a_redirect_at_the_door_is_the_first_address() {
910        let router = GoRouter::new(vec![
911            GoRoute::new("/", |_, _| text("home")),
912            GoRoute::new("/login", |_, _| text("login")),
913        ])
914        .redirect(|state| (state.location() != "/login").then(|| "/login".to_string()));
915        let mut app = FrusApp::router(router);
916        let mut history = History::new();
917        history.opened(Some("/private".to_string()), None);
918        let _ = app.open_location("/private");
919        assert_eq!(app.location(), Some("/login".to_string()));
920        assert_eq!(
921            history.reflect("/login", false),
922            Some(Step::Replace("/login".to_string())),
923            "the address the page opened at is replaced, so back has no `/private` to bounce off"
924        );
925    }
926}