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}