Skip to main content

frus_widgets/
navigator.rs

1//! [`Navigator`]: shows a full-window **screen**, with a slide transition
2//! between the outgoing and the incoming screen on a push or a pop.
3//!
4//! The `Navigator` is **controlled**: the application holds the route stack and
5//! the transition's progress, and (re)builds the screens on every frame.
6
7use std::hash::Hash;
8
9use frus_core::{Rect, Scene};
10use frus_layout::{Dimension, Style};
11
12use crate::interaction::Status;
13use crate::keyed::Keyed;
14use crate::media::MediaQuery;
15use crate::theme::Theme;
16use crate::widget::Widget;
17
18/// A screen container with a slide transition.
19///
20/// # Every page has a key
21///
22/// What a page keeps from one frame to the next — how far it is scrolled, where its
23/// caret is, the sheet it has raised — is kept under the identity of the widget it
24/// belongs to, and an identity is a position in the tree unless a key says otherwise. A
25/// navigator's pages have no position worth the name. The page on show is its first
26/// child whichever page it is, so two pages built the same way had one scroll offset
27/// between them; and during a transition the page arriving is its second child, then its
28/// first once it has arrived, so it had one identity while it slid in and another when it
29/// stopped. [`Navigator::new`] and [`Navigator::from`] therefore each take a **key**, and
30/// everything inside a page takes its identity from that key rather than from where the
31/// page sits. The reference gives every route its own subtree and its own storage for the
32/// same reason.
33///
34/// **A key names an entry of the stack, not a route.** The same route can be on the stack
35/// twice — a thread opened from a thread — and those are two pages, each scrolled on its
36/// own; a key made of the route alone would give them one offset again. `(depth, route)`
37/// is the shape to reach for: the entry's index in the stack and what it shows. The two
38/// pages of a transition must never share a key, and a debug build says so.
39///
40/// **A page that is not in the tree keeps its scroll offsets.** A page below the top is
41/// not built, and when it is shown again under the same key it comes back where it was
42/// left. What a page was animating is not kept: a widget seen again adopts its target,
43/// as one seen for the first time does. And since nothing forgets the offsets of an entry
44/// that was popped, an entry pushed again under the same key opens where the last one
45/// was left.
46///
47/// A key a page's own root declares is replaced by the page's key. To name something
48/// inside a page for a request by key — a focus, a scroll, a sheet — key it inside the
49/// page.
50///
51/// ```
52/// use frus_widgets::{Navigator, Text};
53///
54/// #[derive(Clone, Copy, Hash)]
55/// enum Route {
56///     Inbox,
57///     Thread(u64),
58/// }
59///
60/// // The stack the application holds: the inbox at depth 0, a thread pushed on it.
61/// let settled: Navigator<()> = Navigator::new((1, Route::Thread(7)), Text::new("Thread 7"));
62///
63/// // Part-way through that push: the thread arriving, the inbox it came from leaving.
64/// let pushing: Navigator<()> = Navigator::new((1, Route::Thread(7)), Text::new("Thread 7"))
65///     .from((0, Route::Inbox), Text::new("Inbox"), 0.4, true);
66/// # let _ = (settled, pushing);
67/// ```
68pub struct Navigator<Msg = crate::callback::Callback> {
69    width: f32,
70    height: f32,
71    /// Transition progress (`1.0` = no transition in flight).
72    progress: f32,
73    /// Whether the pages are cut off at the navigator's own edge.
74    clips: bool,
75    /// `true` = push (entering from the right), `false` = pop (entering from the left).
76    forward: bool,
77    /// `[retained.., screen]` or `[retained.., outgoing, incoming]`, each wrapped in the
78    /// [`Keyed`] its key makes.
79    children: Vec<Box<dyn Widget<Msg>>>,
80    /// How many of the first children are retained: kept, and not shown.
81    hidden: usize,
82}
83
84impl<Msg: 'static> Navigator<Msg> {
85    /// Shows a full-window screen (no transition), on **the surface it is being built
86    /// for**, under the key `key` — unique to its entry of the stack (see
87    /// [`Navigator`]).
88    ///
89    /// The size comes from [`MediaQuery::of`] — a window is a window, and the
90    /// application has no business measuring one to say how far a screen slides.
91    /// [`Navigator::size`] overrides it for a stack that is not the whole window, and
92    /// for a test that would rather state a size than install a description.
93    pub fn new(key: impl Hash, screen: impl Widget<Msg> + 'static) -> Self {
94        let surface = MediaQuery::of();
95        Self {
96            width: surface.size.width,
97            height: surface.size.height,
98            progress: 1.0,
99            forward: true,
100            clips: true,
101            children: vec![Box::new(Keyed::new(key, screen))],
102            hidden: 0,
103        }
104    }
105
106    /// Keeps a page **under** the one on show: it is built, so what its components hold is
107    /// still there when the page is shown again, but it is not laid out, drawn or given
108    /// input while it is covered.
109    ///
110    /// It is an entry of the stack like the others, so `key` is its `(depth, route)` and must
111    /// not be the key of any page shown. Because the key — not the page's place among the
112    /// children — is what identifies it, a page that goes from kept to shown, or back,
113    /// is the same page throughout.
114    pub fn retain(mut self, key: impl Hash, page: impl Widget<Msg> + 'static) -> Self {
115        self.children
116            .insert(self.hidden, Box::new(Keyed::new(key, page)));
117        self.hidden += 1;
118        self
119    }
120
121    /// Whether the pages are **cut off at the navigator's own edge**. `true` by default,
122    /// as the reference's `Clip.hardEdge` is.
123    ///
124    /// A screen sliding in comes from outside the box and one sliding out goes outside
125    /// it, so without this the only thing stopping them is the window: a navigator that
126    /// is not the whole window paints its pages over whatever is beside it. `false` is
127    /// for the rare transition meant to spill — a card that grows past its own frame —
128    /// and it is a decision, not a default.
129    pub fn clip_behavior(mut self, clips: bool) -> Self {
130        self.clips = clips;
131        self
132    }
133
134    /// The window's size, in logical pixels — an **override** of what
135    /// [`Navigator::new`] read from the ambient description.
136    pub fn size(mut self, width: f32, height: f32) -> Self {
137        self.width = width;
138        self.height = height;
139        self
140    }
141
142    /// Adds the **outgoing** screen, under its own key, and the progress of a transition
143    /// in flight.
144    ///
145    /// `key` is the key the outgoing screen had while it was on show, so that it keeps
146    /// its state as it leaves; it must differ from the incoming screen's.
147    pub fn from(
148        mut self,
149        key: impl Hash,
150        previous: impl Widget<Msg> + 'static,
151        progress: f32,
152        forward: bool,
153    ) -> Self {
154        let previous = Keyed::new(key, previous);
155        debug_assert!(
156            self.children
157                .last()
158                .is_none_or(|incoming| incoming.key() != Widget::<Msg>::key(&previous)),
159            "a navigator's two pages share a key, and would share their state: key each \
160             page by its entry of the stack, `(depth, route)`"
161        );
162        self.children.insert(self.hidden, Box::new(previous));
163        self.progress = progress.clamp(0.0, 1.0);
164        self.forward = forward;
165        self
166    }
167}
168
169impl<Msg> Widget<Msg> for Navigator<Msg> {
170    fn style(&self) -> Style {
171        Style {
172            width: Dimension::Length(self.width),
173            height: Dimension::Length(self.height),
174            ..Default::default()
175        }
176    }
177
178    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
179        &self.children
180    }
181
182    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
183
184    fn on_click(&self) -> Option<Msg> {
185        None
186    }
187
188    fn navigator(&self) -> Option<(f32, bool)> {
189        Some((self.progress, self.forward))
190    }
191
192    fn navigator_clips(&self) -> bool {
193        self.clips
194    }
195
196    fn navigator_retained(&self) -> usize {
197        self.hidden
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use crate::{build_ui, Container, Runtime, Size};
205    use frus_core::{Color, Primitive};
206
207    fn screen(color: Color) -> Container<()> {
208        Container::<()>::new()
209            .width(400.0)
210            .height(300.0)
211            .color(color)
212    }
213
214    /// **A navigator's pages stop at its own edge.**
215    ///
216    /// A screen sliding in comes from outside the box and one sliding out goes outside
217    /// it. Until milestone 398 the only thing stopping them was the window, so a
218    /// navigator that was **not** the whole window painted its pages straight over
219    /// whatever sat beside it — and a full-window one spent every transition frame
220    /// drawing a screen nobody could see. The reference clips by default
221    /// (`Clip.hardEdge`).
222    ///
223    /// The check is a 200×200 navigator in a 400×400 viewport, mid-transition: every
224    /// primitive it paints has to be confined to its own box.
225    #[test]
226    fn a_navigators_pages_stop_at_its_own_edge() {
227        let nav = |clips: bool| {
228            let red = Color::rgb(1.0, 0.0, 0.0);
229            let blue = Color::rgb(0.0, 0.0, 1.0);
230            let mut navigator = Navigator::new("blue", screen(blue)).size(200.0, 200.0);
231            navigator = navigator.clip_behavior(clips);
232            let navigator = navigator.from("red", screen(red), 0.5, true);
233            let ui = build_ui(
234                &navigator,
235                Size::new(400.0, 400.0),
236                &Runtime::default(),
237                &crate::Theme::default(),
238            );
239            // The furthest right anything is allowed to be painted.
240            ui.scene()
241                .primitives()
242                .iter()
243                .filter_map(|p| match p {
244                    Primitive::Rect { clip, .. } => Some(clip.x + clip.width),
245                    _ => None,
246                })
247                .fold(0.0_f32, f32::max)
248        };
249        assert!(
250            nav(true) <= 200.5,
251            "clipped, nothing may be painted past x = 200: got {}",
252            nav(true)
253        );
254        assert!(
255            nav(false) > 200.5,
256            "and a navigator told not to clip really does not"
257        );
258    }
259
260    #[test]
261    fn transition_renders_both_screens() {
262        let red = Color::rgb(1.0, 0.0, 0.0);
263        let blue = Color::rgb(0.0, 0.0, 1.0);
264        let nav = Navigator::new("blue", screen(blue))
265            .size(400.0, 300.0)
266            .from("red", screen(red), 0.5, true);
267        let ui = build_ui(
268            &nav,
269            Size::new(400.0, 300.0),
270            &Runtime::default(),
271            &crate::Theme::default(),
272        );
273        let has = |c: Color| {
274            ui.scene()
275                .primitives()
276                .iter()
277                .any(|p| matches!(p, Primitive::Rect { color, .. } if *color == c))
278        };
279        assert!(has(red), "the outgoing screen is rendered");
280        assert!(has(blue), "the incoming screen is rendered");
281    }
282
283    /// **An overlay belongs to its screen (milestone 326).** Found on a device: an app
284    /// bar's overflow menu, left open while choosing an item that navigates, stayed drawn
285    /// over the screen that replaced it.
286    ///
287    /// `process_overlays` runs after both screens and paints above the whole window, so a
288    /// deferred overlay outranks everything — including the screen that covers its owner.
289    /// The parallax is why nothing corrected it on its own: the outgoing screen travels
290    /// only 30 % of the width, so the anchor the menu hangs from never leaves the window.
291    ///
292    /// `from` inserts the screen being left at index 0, so `children[1]` is always the
293    /// destination — on a push, on a pop, and under a back gesture alike.
294    #[test]
295    fn a_departing_screens_overlay_is_not_drawn_over_the_incoming_one() {
296        let mark = Color::rgb(0.0, 1.0, 0.0);
297        let menu = || {
298            crate::OverlayPortal::new(screen(Color::rgb(1.0, 0.0, 0.0))).overlay(
299                Container::<()>::new().width(80.0).height(30.0).color(mark),
300                crate::Placement::Below,
301            )
302        };
303        let drawn = |nav: &Navigator<()>| {
304            let ui = build_ui(
305                nav,
306                Size::new(400.0, 300.0),
307                &Runtime::default(),
308                &crate::Theme::default(),
309            );
310            ui.scene()
311                .primitives()
312                .iter()
313                .any(|p| matches!(p, Primitive::Rect { color, .. } if *color == mark))
314        };
315
316        // The screen being left holds the menu: it goes with the screen.
317        let blue = Color::rgb(0.0, 0.0, 1.0);
318        assert!(
319            !drawn(
320                &Navigator::new("blue", screen(blue))
321                    .size(400.0, 300.0)
322                    .from("menu", menu(), 0.5, true)
323            ),
324            "a push: the menu belongs to the screen being left"
325        );
326        assert!(
327            !drawn(
328                &Navigator::new("blue", screen(blue))
329                    .size(400.0, 300.0)
330                    .from("menu", menu(), 0.5, false)
331            ),
332            "a pop: the same, and the screen being left is the *front* one here"
333        );
334
335        // The destination's own overlay is untouched — this must not suppress overlays
336        // wholesale, only the ones belonging to a screen on its way out.
337        assert!(
338            drawn(&Navigator::new("menu", menu()).size(400.0, 300.0).from(
339                "blue",
340                screen(blue),
341                0.5,
342                true
343            )),
344            "the destination's own menu is still drawn"
345        );
346        // And with no transition in flight, nothing changes at all.
347        assert!(
348            drawn(&Navigator::new("menu", menu()).size(400.0, 300.0)),
349            "no transition: the menu is simply drawn"
350        );
351    }
352
353    /// A page whose content scrolls — the shape of nearly every page an application has,
354    /// which is what made two different ones indistinguishable by position. Its content
355    /// is painted in `mark`, so where the content is drawn says how far it is scrolled.
356    fn scrolling(mark: Color) -> Container<()> {
357        Container::<()>::new().width(400.0).height(300.0).child(
358            crate::SingleChildScrollView::new()
359                .width(400.0)
360                .height(300.0)
361                .child(Container::new().width(400.0).height(1200.0).color(mark)),
362        )
363    }
364
365    /// A navigator showing one page, settled.
366    fn settled(key: impl Hash, page: Container<()>) -> Navigator<()> {
367        Navigator::new(key, page).size(400.0, 300.0)
368    }
369
370    /// A navigator half-way through a transition from `(from, previous)` to `(key, page)`.
371    fn moving(
372        key: &str,
373        page: Container<()>,
374        from: &str,
375        previous: Container<()>,
376        forward: bool,
377    ) -> Navigator<()> {
378        Navigator::new(key, page)
379            .size(400.0, 300.0)
380            .from(from, previous, 0.5, forward)
381    }
382
383    /// How far the content painted in `mark` is scrolled, as drawn: its top edge sits at
384    /// minus the offset. `None` when nothing in that colour is painted.
385    fn drawn_offset(nav: &Navigator<()>, runtime: &Runtime, mark: Color) -> Option<f32> {
386        let ui = build_ui(
387            nav,
388            Size::new(400.0, 300.0),
389            runtime,
390            &crate::Theme::default(),
391        );
392        ui.scene().primitives().iter().find_map(|p| match p {
393            Primitive::Rect { color, rect, .. } if *color == mark => Some(-rect.y),
394            _ => None,
395        })
396    }
397
398    /// The one scroll region a settled navigator registers.
399    fn region(nav: &Navigator<()>) -> crate::WidgetId {
400        let ui = build_ui(
401            nav,
402            Size::new(400.0, 300.0),
403            &Runtime::default(),
404            &crate::Theme::default(),
405        );
406        let regions = ui.scroll_regions();
407        assert_eq!(regions.len(), 1, "one page, one region");
408        regions[0].id
409    }
410
411    const INBOX: Color = Color {
412        r: 0.0,
413        g: 1.0,
414        b: 0.0,
415        a: 1.0,
416    };
417    const ARCHIVE: Color = Color {
418        r: 1.0,
419        g: 0.0,
420        b: 1.0,
421        a: 1.0,
422    };
423
424    /// **Scrolling one page does not scroll another (milestone 528).** Found on a phone:
425    /// "when I scroll another page, the one I just left scrolls too."
426    ///
427    /// Retained state is kept by identity, and identity was the position in the tree. A
428    /// navigator's settled page is always its first child, so two pages built the same
429    /// way — a scroll view in a box — had the same identity for their scroll regions and
430    /// read one offset between them.
431    #[test]
432    fn two_pages_of_the_same_shape_keep_two_offsets() {
433        let mut runtime = Runtime::default();
434        let inbox = settled("inbox", scrolling(INBOX));
435        runtime.scroll.insert(region(&inbox), (0.0, 400.0));
436        assert_eq!(drawn_offset(&inbox, &runtime, INBOX), Some(400.0));
437
438        let archive = settled("archive", scrolling(ARCHIVE));
439        assert_eq!(
440            drawn_offset(&archive, &runtime, ARCHIVE),
441            Some(0.0),
442            "the archive was never scrolled"
443        );
444    }
445
446    /// **A page keeps its state through a transition (milestone 528).** Two children in
447    /// flight and one at rest put the arriving page at index 1 during the slide and at
448    /// index 0 once it had arrived — a different identity on each side of the settle, so
449    /// an inbox scrolled down came back at the top all the way through the pop, and
450    /// jumped to where it had been only when the pop was over.
451    #[test]
452    fn a_transition_does_not_move_state_between_its_pages() {
453        let mut runtime = Runtime::default();
454        runtime
455            .scroll
456            .insert(region(&settled("inbox", scrolling(INBOX))), (0.0, 400.0));
457        runtime.scroll.insert(
458            region(&settled("archive", scrolling(ARCHIVE))),
459            (0.0, 700.0),
460        );
461
462        for forward in [true, false] {
463            // Inbox to archive, and archive to inbox, each way round.
464            let there = moving(
465                "archive",
466                scrolling(ARCHIVE),
467                "inbox",
468                scrolling(INBOX),
469                forward,
470            );
471            let back = moving(
472                "inbox",
473                scrolling(INBOX),
474                "archive",
475                scrolling(ARCHIVE),
476                forward,
477            );
478            for nav in [&there, &back] {
479                assert_eq!(
480                    drawn_offset(nav, &runtime, INBOX),
481                    Some(400.0),
482                    "the inbox, forward = {forward}"
483                );
484                assert_eq!(
485                    drawn_offset(nav, &runtime, ARCHIVE),
486                    Some(700.0),
487                    "the archive, forward = {forward}"
488                );
489            }
490        }
491    }
492
493    /// **The same route twice is two pages (milestone 528).** A stack can hold one route
494    /// more than once — a thread opened from a thread — and each entry scrolls on its own.
495    /// That is why the key names the entry and not the route: `(depth, route)` tells the
496    /// two apart where the route alone would not.
497    #[test]
498    fn the_same_route_pushed_twice_keeps_two_offsets() {
499        let mut runtime = Runtime::default();
500        let lower = settled((1, "thread"), scrolling(INBOX));
501        runtime.scroll.insert(region(&lower), (0.0, 400.0));
502
503        let upper = settled((2, "thread"), scrolling(INBOX));
504        assert_eq!(
505            drawn_offset(&upper, &runtime, INBOX),
506            Some(0.0),
507            "the thread opened on top starts at its own top"
508        );
509        assert_eq!(
510            drawn_offset(&lower, &runtime, INBOX),
511            Some(400.0),
512            "and the one below it is still where it was left"
513        );
514    }
515
516    /// Two pages under one key would be one identity drawn twice in a frame, and a debug
517    /// build refuses it rather than let them share their state.
518    #[test]
519    #[should_panic(expected = "share a key")]
520    fn a_transitions_two_pages_may_not_share_a_key() {
521        let _ = Navigator::<()>::new((1, "thread"), screen(Color::rgb(0.0, 0.0, 1.0))).from(
522            (1, "thread"),
523            screen(Color::rgb(1.0, 0.0, 0.0)),
524            0.5,
525            true,
526        );
527    }
528
529    #[test]
530    fn pop_parallaxes_and_orders_back_screen() {
531        let red = Color::rgb(1.0, 0.0, 0.0);
532        let blue = Color::rgb(0.0, 0.0, 1.0);
533        // A pop half-way through: `red` = outgoing screen (front), `blue` = revealed back.
534        let nav = Navigator::new("blue", screen(blue))
535            .size(400.0, 300.0)
536            .from("red", screen(red), 0.5, false);
537        let ui = build_ui(
538            &nav,
539            Size::new(400.0, 300.0),
540            &Runtime::default(),
541            &crate::Theme::default(),
542        );
543        let x_of = |c: Color| {
544            ui.scene()
545                .primitives()
546                .iter()
547                .find_map(|p| match p {
548                    Primitive::Rect { color, rect, .. } if *color == c => Some(rect.x),
549                    _ => None,
550                })
551                .expect("screen present")
552        };
553        let front = x_of(red); // +0.5·400 = 200
554        let back = x_of(blue); // parallaxe : -0.5·400·0.3 = -60
555        assert!(
556            front > back,
557            "the front ({front}) is to the right of the back ({back})"
558        );
559        // Without parallax the back would sit at -200; it is compressed toward 0.
560        assert!(back > -200.0 && back < 0.0, "back parallaxed: {back}");
561    }
562}
563
564#[cfg(test)]
565mod retained_tests {
566    use super::*;
567    use crate::{build_ui, Container, Runtime, Size};
568    use frus_core::{Color, Primitive};
569
570    const RED: Color = Color::rgb(1.0, 0.0, 0.0);
571    const GREEN: Color = Color::rgb(0.0, 1.0, 0.0);
572    const BLUE: Color = Color::rgb(0.0, 0.0, 1.0);
573
574    fn screen(color: Color) -> Container<()> {
575        Container::<()>::new()
576            .width(400.0)
577            .height(300.0)
578            .color(color)
579    }
580
581    fn painted(nav: &Navigator<()>) -> impl Fn(Color) -> bool {
582        let ui = build_ui(
583            nav,
584            Size::new(400.0, 300.0),
585            &Runtime::default(),
586            &crate::Theme::default(),
587        );
588        let colours: Vec<Color> = ui
589            .scene()
590            .primitives()
591            .iter()
592            .filter_map(|p| match p {
593                Primitive::Rect { color, .. } => Some(*color),
594                _ => None,
595            })
596            .collect();
597        move |c| colours.contains(&c)
598    }
599
600    /// **The pages under the one on show are kept, and stay out of the picture.** They are
601    /// children of the navigator, so a walk that builds the tree builds them — which is what
602    /// keeps what their components hold — and the paint walk does not reach them.
603    #[test]
604    fn retained_pages_are_in_the_tree_and_out_of_the_picture() {
605        let nav = Navigator::new((2, 0), screen(BLUE))
606            .size(400.0, 300.0)
607            .retain((0, 0), screen(RED))
608            .retain((1, 0), screen(GREEN));
609        assert_eq!(Widget::<()>::children(&nav).len(), 3);
610        assert_eq!(Widget::<()>::navigator_retained(&nav), 2);
611        let has = painted(&nav);
612        assert!(has(BLUE), "the page on show is drawn");
613        assert!(!has(RED) && !has(GREEN), "the pages under it are not");
614    }
615
616    /// A transition still draws its two pages, whatever is kept under them — and the outgoing
617    /// one goes *after* the retained ones, so the pair is where the walk expects it.
618    #[test]
619    fn a_transition_draws_its_two_pages_over_the_retained_ones() {
620        let nav = Navigator::new("c", screen(BLUE))
621            .size(400.0, 300.0)
622            .retain("a", screen(RED))
623            .from("b", screen(GREEN), 0.5, true);
624        assert_eq!(Widget::<()>::navigator_retained(&nav), 1);
625        let has = painted(&nav);
626        assert!(has(GREEN) && has(BLUE), "both pages of the transition");
627        assert!(!has(RED), "and not the one kept under them");
628    }
629
630    /// What identifies a page is its key, not where it sits among the children: the same page
631    /// kept and then shown is the same page.
632    #[test]
633    fn a_page_keeps_its_identity_from_kept_to_shown() {
634        let kept = Navigator::new("top", screen(BLUE)).retain("page", screen(RED));
635        let shown = Navigator::new("page", screen(RED));
636        let key = |nav: &Navigator<()>, at: usize| Widget::<()>::children(nav)[at].key();
637        assert_eq!(key(&kept, 0), key(&shown, 0));
638    }
639}