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