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/// What goes around the root: called on every rebuild, with the root to put inside.
43type Wrap = Rc<dyn Fn(&BuildContext, Box<dyn Widget>) -> Box<dyn Widget>>;
44
45/// An application: a root component, and how it is dressed.
46///
47/// ```
48/// use frus_shell::FrusApp;
49/// use frus_widgets::{text, Theme, ThemeMode};
50///
51/// let app = FrusApp::from_fn(|_| Box::new(text("hello")))
52///     .title("Hello")
53///     .theme(Theme::light())
54///     .dark_theme(Theme::dark())
55///     .theme_mode(ThemeMode::System);
56/// # let _ = app;
57/// ```
58///
59/// How it is dressed — its title, themes, language, zoom — is kept where a component can reach
60/// it too, in [`host::app`]: what the builders here set, a component changes with the same
61/// names (`host::app().set_theme_mode(..)`), and the shell reads it every frame.
62pub 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    /// An application whose interface is the [`StatelessWidget`] `root`.
77    pub fn new(root: impl StatelessWidget) -> Self {
78        Self::with_root(Rc::new(move |cx| root.build(cx)))
79    }
80
81    /// An application whose interface is a function of the build context — hooks may be
82    /// called in it.
83    pub fn from_fn(root: impl Fn(&BuildContext) -> Box<dyn Widget> + 'static) -> Self {
84        Self::with_root(Rc::new(root))
85    }
86
87    /// An application whose root is a [`StatefulWidget`]. It is configuration, made again
88    /// on every rebuild — hence `Clone`.
89    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    /// An application whose pages are the routes of `router`: the router builds the interface,
96    /// moves it when told a location, and answers the back gesture.
97    ///
98    /// ```
99    /// use frus_shell::FrusApp;
100    /// use frus_widgets::{text, GoRoute, GoRouter};
101    ///
102    /// let router = GoRouter::new(vec![
103    ///     GoRoute::new("/", |_, _| text("home")),
104    ///     GoRoute::new("/about", |_, _| text("about")),
105    /// ]);
106    /// let app = FrusApp::router(router).title("Pages");
107    /// # let _ = app;
108    /// ```
109    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        // A new application starts from the defaults, whatever the last one left behind.
118        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    /// **What goes around the application's pages**: `builder` is handed them, as `child`, and
142    /// returns what the window shows. The place for what belongs to the window rather than to
143    /// a page — what must stay put while the pages change under it, and stay there whichever
144    /// page is on show: a [`WindowMenuBar`](frus_widgets::WindowMenuBar), a banner across every
145    /// page.
146    ///
147    /// ```
148    /// use frus_shell::FrusApp;
149    /// use frus_widgets::{
150    ///     text, Callback, GoRoute, GoRouter, MenuBar, MenuPath, SubmenuButton, WindowMenuBar,
151    /// };
152    ///
153    /// let router = GoRouter::new(vec![GoRoute::new("/", |_, _| text("home"))]);
154    /// let app = FrusApp::router(router).builder(|cx, pages| {
155    ///     // Which menu is open: the window's to keep, as the bar is.
156    ///     let menus = cx.use_state(MenuPath::closed);
157    ///     let set = menus.clone();
158    ///     let bar = MenuBar::new(&menus.get(), move |path: MenuPath| {
159    ///         let set = set.clone();
160    ///         Callback::new(move || set.set(path.clone()))
161    ///     })
162    ///     .menu(SubmenuButton::new("File"));
163    ///     Box::new(WindowMenuBar::new(bar, pages))
164    /// });
165    /// # let _ = app;
166    /// ```
167    ///
168    /// `child` is built where `builder` puts it, so what it reads of the window there — a
169    /// [`MediaQuery`](frus_widgets::MediaQuery) a wrapper narrowed — is what its pages are built
170    /// for. `builder` may call hooks, and reach the router with
171    /// [`BuildContext::router`](frus_widgets::BuildContext::router).
172    ///
173    /// Keep the shape of what it returns: the pages are kept under their place in the tree,
174    /// and a `child` that moves loses their state. A wrapper that is sometimes not needed is
175    /// told so — `WindowMenuBar::new(None, child)` — rather than left out.
176    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    /// Makes the application **one window** on a desktop, named `id` — a reverse domain such as
185    /// `com.example.myapp`. A second process started with a link (`myapp://orders/42`, how a
186    /// desktop opens a registered scheme) hands it to the first, which opens the location it
187    /// names, and ends. See [`Application::instance_id`].
188    pub fn single_instance(mut self, id: impl Into<String>) -> Self {
189        self.instance = Some(id.into());
190        self
191    }
192
193    /// How the router's location is written in a browser's address bar: after a `#` (the
194    /// default, which any static host serves) or as a path (which needs the server to answer
195    /// every address with the page). Only the web has an address bar to show it in. See
196    /// [`LocationStrategy`].
197    pub fn location_strategy(mut self, strategy: LocationStrategy) -> Self {
198        self.strategy = strategy;
199        self
200    }
201
202    /// The window's title.
203    pub fn title(self, title: impl Into<String>) -> Self {
204        host::app().set_title(title.into());
205        self
206    }
207
208    /// The application's theme — its light one, where it has two.
209    pub fn theme(self, theme: Theme) -> Self {
210        host::app().set_theme(theme);
211        self
212    }
213
214    /// The theme for a dark interface. With one, the application follows the system's
215    /// brightness, or [`theme_mode`](Self::theme_mode).
216    pub fn dark_theme(self, theme: Theme) -> Self {
217        host::app().set_dark_theme(Some(theme));
218        self
219    }
220
221    /// Which of the two themes is on display: the system's choice by default.
222    pub fn theme_mode(self, mode: ThemeMode) -> Self {
223        host::app().set_theme_mode(mode);
224        self
225    }
226
227    /// The languages the application has, best first. The framework resolves the device's
228    /// list against them.
229    pub fn supported_locales(self, locales: Vec<Locale>) -> Self {
230        host::app().set_supported_locales(locales);
231        self
232    }
233
234    /// The words the framework says on the application's behalf, where they are not English.
235    pub fn localizations(self, table: std::rc::Rc<dyn Localizations>) -> Self {
236        host::app().set_localizations(Some(table));
237        self
238    }
239
240    /// Runs `start` once, when the application starts and before anything is drawn: the place
241    /// for what has to be true from the first frame — registering data, opening a store.
242    pub fn on_start(mut self, start: impl FnOnce() + 'static) -> Self {
243        self.on_start = Some(Box::new(start));
244        self
245    }
246
247    /// Keeps what `save` returns across a live reload, and hands it to `restore` afterwards.
248    /// A development convenience: the state of an application survives a recompilation.
249    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    /// The application's icon. The frus logo is the default — an application has it without
260    /// writing a line — and this is how to change it: `.icon(AppIcon::from_png(include_bytes!(
261    /// "../assets/icon.png")))`, or `.icon(AppIcon::none())` for none of the framework's. On
262    /// Android the launcher icon is the manifest's instead; see [`AppIcon`](crate::AppIcon).
263    pub fn icon(mut self, icon: crate::AppIcon) -> Self {
264        self.icon = icon;
265        self
266    }
267
268    /// The window's initial size, in logical pixels.
269    pub fn window_size(mut self, width: f32, height: f32) -> Self {
270        self.window_size = Some((width, height));
271        self
272    }
273
274    /// A zoom on the whole interface, on top of the system's scale.
275    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            // The router provides itself when it builds the pages, which is after this; the
324            // builder reaches it from the first frame.
325            if let Some(router) = &router {
326                cx.runtime().states.provide(Rc::new(router.clone()));
327            }
328            // The pages are a component of their own, built where the builder puts them —
329            // under what it says of the window there.
330            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        // The timers the components of the latest build asked for. The shell diffs them: a
339        // timer whose component no longer asks for it is stopped.
340        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        // What an open menu or dialog takes first is not the router's to answer.
382        !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    /// The router pops: the page leaves with its transition, as it does for a back
402    /// button in the reference, and not as a swipe let go (milestone 622).
403    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    /// A window-sized target: a tap anywhere lands on it.
461    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    /// **What goes around the pages reaches the router from the first frame** (milestone
466    /// 642): the router provides itself when it builds the pages, which is after the
467    /// builder has run.
468    #[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    /// **Every application has the frus logo** until it says otherwise, and can say otherwise or
498    /// say none: the default costs no line, the change costs one.
499    #[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        // a checkbox is small, and at the window's corner
597        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    /// Two pages: the first counts its taps in a hook and pushes the second; the second pops.
676    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    /// **The system's Back pops the page** (milestone 622): the page leaves with its own
734    /// transition, at once the stack's, rather than as a swipe let go — which would keep it
735    /// on the stack until a settle had carried it off.
736    #[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        // What a handler does from inside the tree.
827        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    /// The application and the browser's history, wired the way the shell wires them, with
1017    /// the router as the application.
1018    #[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/// Work that finishes later, in a component, driven through the shell and its executor
1082/// (milestone 585).
1083#[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    /// The texts on screen.
1096    fn texts(d: &Driver<FrusApp>) -> Vec<String> {
1097        d.texts().into_iter().map(|(text, _)| text).collect()
1098    }
1099
1100    /// Frames until `wanted` is on screen, for up to two seconds of real time: the work runs
1101    /// on the executor's threads, and arrives when it arrives.
1102    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    /// **A future's value is shown once it comes**, waiting until then; and the future is
1115    /// started once, however many frames are built while it runs.
1116    #[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    /// **A new key starts a new future**, and the old one's value is not shown.
1155    #[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    /// **A stream shows its latest value while it runs, and its last one when it is done.**
1177    #[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    /// **A value listened to is shown again when it changes.**
1196    #[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/// **A field's clickable suffix did nothing in a real application** (the reveal-password
1223/// eye). A field answers no tap of its own, so it was never a hit target, and the shell
1224/// only asked a widget it had hit for its positional click: `on_suffix` worked when called
1225/// on the widget and never through the pointer.
1226#[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    /// The field's corner, inside the card's padding.
1235    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}