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