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}