Skip to main content

frus_widgets/
router.rs

1//! **A router**: locations, pages, and moving between them.
2//!
3//! An application with more than one screen names them. A [`GoRoute`] says which pattern of
4//! path it answers to and what page it builds; a [`GoRouter`] holds the routes and the stack
5//! of pages the person is looking at, and moves that stack when told a *location* — the
6//! string a link, a deep link or a button would carry: `/users/42?tab=posts`.
7//!
8//! ```
9//! use frus_widgets::{text, GoRoute, GoRouter};
10//!
11//! let router = GoRouter::new(vec![
12//!     GoRoute::new("/", |_, _| text("home")).routes(vec![
13//!         // relative to its parent: `/users/:id`
14//!         GoRoute::new("users/:id", |_, state| {
15//!             let id = state.param("id").unwrap_or("?").to_string();
16//!             text(format!("user {id}"))
17//!         })
18//!         .name("user"),
19//!     ]),
20//!     GoRoute::new("/settings", |_, _| text("settings")),
21//! ]);
22//!
23//! router.go("/users/42?tab=posts");
24//! assert_eq!(router.location(), "/users/42?tab=posts");
25//! assert!(router.can_pop(), "home is underneath");
26//!
27//! router.pop();
28//! assert_eq!(router.location(), "/");
29//! ```
30//!
31//! ## Two ways to move
32//!
33//! [`GoRouter::go`] says *where*: it makes the stack whatever the location's routes are — a
34//! location that lives under `/` has `/` beneath it, so there is somewhere to go back to.
35//! [`GoRouter::push`] says *on top of what is here*: it adds one page and leaves the rest,
36//! which is how the same page can be open twice.
37//!
38//! ## Redirects
39//!
40//! A redirect looks at where a navigation is heading and may send it elsewhere — to a sign-in
41//! page while nobody is signed in, away from it once someone is. It runs on every navigation,
42//! and again when a [`refresh listenable`](GoRouter::refresh_listenable) changes; a redirect
43//! that answers with another location is asked again about that one, up to a limit.
44//!
45//! ## Pages under the top
46//!
47//! A page covered by another is kept: what its components hold is still there when the top
48//! page is popped. It is built, but not laid out or drawn, until it is on show again.
49
50use std::any::Any;
51use std::cell::{Cell, RefCell};
52use std::collections::HashMap;
53use std::fmt;
54use std::hash::{Hash, Hasher};
55use std::rc::{Rc, Weak};
56
57use frus_core::{AnimationController, Curve, SpringDescription};
58
59use crate::component::{request_rebuild, BuildContext};
60use crate::navigator::Navigator;
61use crate::notifier::{Listenable, Subscription};
62use crate::widget::Widget;
63
64// ---------------------------------------------------------------------------------------
65// Motion
66// ---------------------------------------------------------------------------------------
67
68/// Horizon (s) over which a finger's velocity is projected to decide between going back and
69/// staying.
70const BACK_PROJECT: f32 = 0.12;
71/// Projected position (a fraction) beyond which going back is committed.
72const BACK_COMMIT_POS: f32 = 0.5;
73/// How fast a released iOS page must be moving, in screen widths per second, for its
74/// direction to decide whether it goes (`route.dart:32`).
75const CUPERTINO_FLING: f32 = 1.0;
76/// How long a released iOS page takes to finish going, or to come back
77/// (`route.dart:35`).
78const CUPERTINO_SETTLE: f32 = 0.35;
79/// Stiffness of the page transition's spring.
80const SPRING_K: f32 = 220.0;
81/// Its damping: close to critical, so a page arrives without overshooting.
82const SPRING_C: f32 = 30.0;
83
84fn spring() -> SpringDescription {
85    SpringDescription::new(1.0, SPRING_K, SPRING_C)
86}
87
88// ---------------------------------------------------------------------------------------
89// Locations
90// ---------------------------------------------------------------------------------------
91
92fn decode(text: &str, plus_is_space: bool) -> String {
93    let bytes = text.as_bytes();
94    let mut out = Vec::with_capacity(bytes.len());
95    let mut i = 0;
96    while i < bytes.len() {
97        match bytes[i] {
98            b'%' if i + 2 < bytes.len() => {
99                let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok();
100                match hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
101                    Some(byte) => {
102                        out.push(byte);
103                        i += 3;
104                    }
105                    None => {
106                        out.push(b'%');
107                        i += 1;
108                    }
109                }
110            }
111            b'+' if plus_is_space => {
112                out.push(b' ');
113                i += 1;
114            }
115            byte => {
116                out.push(byte);
117                i += 1;
118            }
119        }
120    }
121    String::from_utf8_lossy(&out).into_owned()
122}
123
124fn encode(text: &str) -> String {
125    let mut out = String::with_capacity(text.len());
126    for byte in text.bytes() {
127        match byte {
128            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
129                out.push(byte as char)
130            }
131            other => out.push_str(&format!("%{other:02X}")),
132        }
133    }
134    out
135}
136
137/// A location taken apart: its path's segments, and its query.
138struct Parts {
139    segments: Vec<String>,
140    query: Vec<(String, String)>,
141}
142
143fn split_location(location: &str) -> Parts {
144    let without_fragment = location.split('#').next().unwrap_or("");
145    let (path, query) = without_fragment
146        .split_once('?')
147        .unwrap_or((without_fragment, ""));
148    let segments = path
149        .split('/')
150        .filter(|segment| !segment.is_empty())
151        .map(str::to_string)
152        .collect();
153    let query = query
154        .split('&')
155        .filter(|pair| !pair.is_empty())
156        .map(|pair| {
157            let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
158            (decode(key, true), decode(value, true))
159        })
160        .collect();
161    Parts { segments, query }
162}
163
164// ---------------------------------------------------------------------------------------
165// State
166// ---------------------------------------------------------------------------------------
167
168/// Where a page is: what location it was reached at, which route it is, and what the
169/// location said — the values of the path's `:parameters`, and the query.
170#[derive(Clone)]
171pub struct GoRouterState {
172    location: String,
173    path: String,
174    name: Option<String>,
175    params: HashMap<String, String>,
176    query: HashMap<String, String>,
177    extra: Option<Rc<dyn Any>>,
178    entering: f32,
179}
180
181impl GoRouterState {
182    /// How far into view this page is: `1.0` once it has settled, and while a transition
183    /// brings it in or takes it out, the number that transition is driven by — `0.0` not yet
184    /// arrived. A page that wants to move its own contents as it arrives reads it, and one
185    /// that does not ignores it.
186    pub fn entering(&self) -> f32 {
187        self.entering
188    }
189
190    /// The whole location, query included: `/users/42?tab=posts`.
191    pub fn location(&self) -> &str {
192        &self.location
193    }
194
195    /// The pattern of the route that answered: `/users/:id`. Empty when no route did.
196    pub fn path(&self) -> &str {
197        &self.path
198    }
199
200    /// The route's name, if it has one.
201    pub fn name(&self) -> Option<&str> {
202        self.name.as_deref()
203    }
204
205    /// The value of the path parameter `name` — `42` for `:id` in `/users/42`.
206    pub fn param(&self, name: &str) -> Option<&str> {
207        self.params.get(name).map(String::as_str)
208    }
209
210    /// Every path parameter of the location.
211    pub fn params(&self) -> &HashMap<String, String> {
212        &self.params
213    }
214
215    /// The value of the query parameter `name` — `posts` for `tab` in `?tab=posts`. When it
216    /// is given more than once, the last one.
217    pub fn query(&self, name: &str) -> Option<&str> {
218        self.query.get(name).map(String::as_str)
219    }
220
221    /// Every query parameter of the location.
222    pub fn query_params(&self) -> &HashMap<String, String> {
223        &self.query
224    }
225
226    /// The object that came along with the navigation ([`GoRouter::go_with_extra`]), if it
227    /// is a `T`.
228    pub fn extra<T: 'static>(&self) -> Option<Rc<T>> {
229        self.extra.clone().and_then(|extra| extra.downcast().ok())
230    }
231}
232
233impl fmt::Debug for GoRouterState {
234    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
235        f.debug_struct("GoRouterState")
236            .field("location", &self.location)
237            .field("path", &self.path)
238            .field("name", &self.name)
239            .field("params", &self.params)
240            .field("query", &self.query)
241            .finish()
242    }
243}
244
245// ---------------------------------------------------------------------------------------
246// Routes
247// ---------------------------------------------------------------------------------------
248
249type RouteBuilder = Rc<dyn Fn(&BuildContext, &GoRouterState) -> Box<dyn Widget>>;
250type Redirect = Rc<dyn Fn(&GoRouterState) -> Option<String>>;
251
252/// One place the router can go: a pattern of path, and the page it builds.
253///
254/// A path is made of segments: a literal (`users`), a parameter (`:id`), or a trailing `*`
255/// that takes whatever is left. A top-level route's path starts with `/`; a sub-route's is
256/// relative to its parent's and does not.
257pub struct GoRoute {
258    path: String,
259    name: Option<String>,
260    builder: RouteBuilder,
261    routes: Vec<GoRoute>,
262    redirect: Option<Redirect>,
263}
264
265impl GoRoute {
266    /// A route answering to `path`, building its page with `builder`.
267    ///
268    /// `builder` is told the [`GoRouterState`] — where the location put the person — and may
269    /// return any widget: a [`Component`](crate::Component), a `Box<dyn Widget>`, a
270    /// scaffold.
271    pub fn new<W: Widget + 'static>(
272        path: impl Into<String>,
273        builder: impl Fn(&BuildContext, &GoRouterState) -> W + 'static,
274    ) -> Self {
275        Self {
276            path: path.into(),
277            name: None,
278            builder: Rc::new(move |cx, state| Box::new(builder(cx, state))),
279            routes: Vec::new(),
280            redirect: None,
281        }
282    }
283
284    /// Names the route, so it can be reached without writing its path:
285    /// [`GoRouter::go_named`].
286    pub fn name(mut self, name: impl Into<String>) -> Self {
287        self.name = Some(name.into());
288        self
289    }
290
291    /// The routes under this one. A location that one of them answers to has this route's page
292    /// beneath its own.
293    pub fn routes(mut self, routes: Vec<GoRoute>) -> Self {
294        self.routes = routes;
295        self
296    }
297
298    /// Where to send a navigation heading to this route or below it, if not here — `None` to
299    /// let it through.
300    pub fn redirect(
301        mut self,
302        redirect: impl Fn(&GoRouterState) -> Option<String> + 'static,
303    ) -> Self {
304        self.redirect = Some(Rc::new(redirect));
305        self
306    }
307}
308
309enum Segment {
310    Literal(String),
311    Param(String),
312    Splat,
313}
314
315struct Node {
316    full_path: String,
317    segments: Vec<Segment>,
318    name: Option<String>,
319    builder: RouteBuilder,
320    redirect: Option<Redirect>,
321    children: Vec<Rc<Node>>,
322}
323
324fn build_node(
325    route: GoRoute,
326    parent: Option<&str>,
327    names: &mut HashMap<String, Rc<Node>>,
328) -> Rc<Node> {
329    let full_path = match parent {
330        None => {
331            assert!(
332                route.path.starts_with('/'),
333                "a top-level route's path starts with `/`: `{}`",
334                route.path
335            );
336            route.path.clone()
337        }
338        Some(parent) => {
339            assert!(
340                !route.path.starts_with('/'),
341                "a sub-route's path is relative to its parent's, and does not start with `/`: `{}`",
342                route.path
343            );
344            assert!(!route.path.is_empty(), "a sub-route needs a path");
345            format!("{}/{}", parent.trim_end_matches('/'), route.path)
346        }
347    };
348    let segments = route
349        .path
350        .split('/')
351        .filter(|segment| !segment.is_empty())
352        .map(|segment| match segment {
353            "*" => Segment::Splat,
354            other => match other.strip_prefix(':') {
355                Some(name) => Segment::Param(name.to_string()),
356                None => Segment::Literal(other.to_string()),
357            },
358        })
359        .collect();
360    let children = route
361        .routes
362        .into_iter()
363        .map(|child| build_node(child, Some(&full_path), names))
364        .collect();
365    let node = Rc::new(Node {
366        full_path,
367        segments,
368        name: route.name.clone(),
369        builder: route.builder,
370        redirect: route.redirect,
371        children,
372    });
373    if let Some(name) = route.name {
374        let clash = names.insert(name.clone(), node.clone());
375        assert!(clash.is_none(), "two routes are named `{name}`");
376    }
377    node
378}
379
380/// How many segments of `input` the pattern takes, filling `params` — or `None` if it does
381/// not match the start of it.
382fn consume(
383    segments: &[Segment],
384    input: &[String],
385    params: &mut HashMap<String, String>,
386) -> Option<usize> {
387    let mut at = 0;
388    for segment in segments {
389        match segment {
390            Segment::Literal(literal) => {
391                if input.get(at)? != literal {
392                    return None;
393                }
394                at += 1;
395            }
396            Segment::Param(name) => {
397                params.insert(name.clone(), decode(input.get(at)?, false));
398                at += 1;
399            }
400            Segment::Splat => {
401                params.insert("*".to_string(), input[at.min(input.len())..].join("/"));
402                at = input.len();
403            }
404        }
405    }
406    Some(at)
407}
408
409/// The chain of routes that answers to `input`, the parent first — the deepest route whose
410/// pattern takes all of it.
411fn match_nodes(
412    nodes: &[Rc<Node>],
413    input: &[String],
414    params: &mut HashMap<String, String>,
415) -> Option<Vec<Rc<Node>>> {
416    for node in nodes {
417        let mut found = params.clone();
418        let Some(used) = consume(&node.segments, input, &mut found) else {
419            continue;
420        };
421        if used == input.len() {
422            *params = found;
423            return Some(vec![node.clone()]);
424        }
425        if let Some(mut below) = match_nodes(&node.children, &input[used..], &mut found) {
426            *params = found;
427            below.insert(0, node.clone());
428            return Some(below);
429        }
430    }
431    None
432}
433
434// ---------------------------------------------------------------------------------------
435// The router
436// ---------------------------------------------------------------------------------------
437
438/// One page of the stack.
439#[derive(Clone)]
440struct Page {
441    /// What identifies it: a page reached again by `go` keeps it, and so keeps its state; one
442    /// that was pushed has its own.
443    key: u64,
444    node: Option<Rc<Node>>,
445    state: Rc<GoRouterState>,
446}
447
448struct Transition {
449    from: Page,
450    controller: AnimationController,
451    forward: bool,
452}
453
454struct Back {
455    progress: f32,
456    settle: Option<AnimationController>,
457    commit: bool,
458}
459
460#[derive(Default)]
461struct Stack {
462    pages: Vec<Page>,
463    transition: Option<Transition>,
464    back: Option<Back>,
465}
466
467struct Config {
468    nodes: Vec<Rc<Node>>,
469    names: HashMap<String, Rc<Node>>,
470    initial: String,
471    redirect: Option<Redirect>,
472    error: Option<RouteBuilder>,
473    limit: usize,
474    animate: bool,
475}
476
477struct Inner {
478    config: RefCell<Config>,
479    stack: RefCell<Stack>,
480    started: Cell<bool>,
481    /// Whether a tree has been built from this router yet: until then nobody has seen a page,
482    /// so there is nothing to move away from.
483    built: Cell<bool>,
484    /// Whether the last move swapped the page on top rather than adding or removing one.
485    replaced: Cell<bool>,
486    /// The transition the theme the router was last built under asks for: what the next
487    /// push or pop runs for (milestone 620). The zoom, the reference's fallback, until a
488    /// theme has said.
489    transitions: Cell<crate::PageTransitionsBuilder>,
490    unique: Cell<u64>,
491    refresh: RefCell<Option<Subscription>>,
492}
493
494/// The routes, and the stack of pages showing which of them the person has been through.
495///
496/// A cheap handle: clone it into a handler and navigate from there.
497#[derive(Clone)]
498pub struct GoRouter {
499    inner: Rc<Inner>,
500}
501
502#[derive(Clone, Copy, PartialEq)]
503enum Mode {
504    Go,
505    Push,
506    Replace,
507}
508
509fn hash_of(value: impl Hash) -> u64 {
510    let mut hasher = std::collections::hash_map::DefaultHasher::new();
511    value.hash(&mut hasher);
512    hasher.finish()
513}
514
515impl GoRouter {
516    /// A router over `routes`, starting at `/`.
517    pub fn new(routes: Vec<GoRoute>) -> Self {
518        let mut names = HashMap::new();
519        let nodes = routes
520            .into_iter()
521            .map(|route| build_node(route, None, &mut names))
522            .collect();
523        Self {
524            inner: Rc::new(Inner {
525                config: RefCell::new(Config {
526                    nodes,
527                    names,
528                    initial: "/".to_string(),
529                    redirect: None,
530                    error: None,
531                    limit: 5,
532                    animate: true,
533                }),
534                stack: RefCell::new(Stack::default()),
535                started: Cell::new(false),
536                built: Cell::new(false),
537                transitions: Cell::new(crate::PageTransitionsBuilder::Zoom),
538                replaced: Cell::new(false),
539                unique: Cell::new(0),
540                refresh: RefCell::new(None),
541            }),
542        }
543    }
544
545    /// Where the router starts. `/` by default.
546    pub fn initial_location(self, location: impl Into<String>) -> Self {
547        self.inner.config.borrow_mut().initial = location.into();
548        self
549    }
550
551    /// A redirect for every navigation: it is told where it is heading and may answer with
552    /// another location, or `None` to let it through.
553    ///
554    /// It closes over whatever it needs to decide — a handle to the signed-in state, say.
555    pub fn redirect(self, redirect: impl Fn(&GoRouterState) -> Option<String> + 'static) -> Self {
556        self.inner.config.borrow_mut().redirect = Some(Rc::new(redirect));
557        self
558    }
559
560    /// The page shown when no route answers to a location. A plain "not found" page by
561    /// default.
562    pub fn error_builder<W: Widget + 'static>(
563        self,
564        builder: impl Fn(&BuildContext, &GoRouterState) -> W + 'static,
565    ) -> Self {
566        self.inner.config.borrow_mut().error =
567            Some(Rc::new(move |cx, state| Box::new(builder(cx, state))));
568        self
569    }
570
571    /// How many redirects in a row are followed before the router gives up and shows the
572    /// error page. Five by default.
573    pub fn redirect_limit(self, limit: usize) -> Self {
574        self.inner.config.borrow_mut().limit = limit;
575        self
576    }
577
578    /// Whether a page slides in and out. On by default.
579    pub fn animate(self, animate: bool) -> Self {
580        self.inner.config.borrow_mut().animate = animate;
581        self
582    }
583
584    /// Runs the redirects again whenever `listenable` changes — the way a sign-in or sign-out
585    /// moves the person without anyone having navigated.
586    pub fn refresh_listenable(self, listenable: &impl Listenable) -> Self {
587        let weak: Weak<Inner> = Rc::downgrade(&self.inner);
588        let subscription = listenable.add_listener(move || {
589            if let Some(inner) = weak.upgrade() {
590                GoRouter { inner }.refresh();
591            }
592        });
593        *self.inner.refresh.borrow_mut() = Some(subscription);
594        self
595    }
596
597    // ---- reading -----------------------------------------------------------------------
598
599    /// The location of the page on top.
600    pub fn location(&self) -> String {
601        self.start();
602        self.inner
603            .stack
604            .borrow()
605            .pages
606            .last()
607            .map(|page| page.state.location.clone())
608            .unwrap_or_default()
609    }
610
611    /// The state of the page on top: what its location put in it.
612    pub fn state(&self) -> Option<GoRouterState> {
613        self.start();
614        self.inner
615            .stack
616            .borrow()
617            .pages
618            .last()
619            .map(|page| (*page.state).clone())
620    }
621
622    /// The location of every page in the stack, the one at the bottom first and the one on
623    /// top last.
624    pub fn locations(&self) -> Vec<String> {
625        self.start();
626        self.inner
627            .stack
628            .borrow()
629            .pages
630            .iter()
631            .map(|page| page.state.location.clone())
632            .collect()
633    }
634
635    /// Whether the last move swapped the page on top for another — [`replace`](Self::replace)
636    /// — rather than adding a page, removing one or rebuilding the stack. What an address bar
637    /// wants to know, to swap its entry too instead of adding one.
638    pub fn replaced(&self) -> bool {
639        self.inner.replaced.get()
640    }
641
642    /// How many pages the stack holds.
643    pub fn depth(&self) -> usize {
644        self.start();
645        self.inner.stack.borrow().pages.len()
646    }
647
648    /// Whether there is a page beneath the top to go back to.
649    pub fn can_pop(&self) -> bool {
650        self.depth() > 1
651    }
652
653    /// The location of the route called `name`, with its `:parameters` filled in and `query`
654    /// appended. `None` if there is no such route, or a parameter is missing.
655    pub fn location_for(
656        &self,
657        name: &str,
658        params: &[(&str, &str)],
659        query: &[(&str, &str)],
660    ) -> Option<String> {
661        let node = self.inner.config.borrow().names.get(name).cloned()?;
662        let mut path = String::new();
663        for segment in node.full_path.split('/').filter(|s| !s.is_empty()) {
664            path.push('/');
665            match segment.strip_prefix(':') {
666                Some(param) => {
667                    let (_, value) = params.iter().find(|(key, _)| *key == param)?;
668                    path.push_str(&encode(value));
669                }
670                None => path.push_str(segment),
671            }
672        }
673        if path.is_empty() {
674            path.push('/');
675        }
676        if !query.is_empty() {
677            let pairs: Vec<String> = query
678                .iter()
679                .map(|(key, value)| format!("{}={}", encode(key), encode(value)))
680                .collect();
681            path.push('?');
682            path.push_str(&pairs.join("&"));
683        }
684        Some(path)
685    }
686
687    // ---- moving ------------------------------------------------------------------------
688
689    /// Goes to `location`: the stack becomes the routes that location is made of.
690    pub fn go(&self, location: impl AsRef<str>) {
691        self.navigate(location.as_ref(), None, Mode::Go);
692    }
693
694    /// [`go`](Self::go), carrying an object the page can read with
695    /// [`GoRouterState::extra`].
696    pub fn go_with_extra(&self, location: impl AsRef<str>, extra: impl Any) {
697        self.navigate(location.as_ref(), Some(Rc::new(extra)), Mode::Go);
698    }
699
700    /// Goes to the route called `name`.
701    pub fn go_named(&self, name: &str, params: &[(&str, &str)], query: &[(&str, &str)]) {
702        match self.location_for(name, params, query) {
703            Some(location) => self.go(location),
704            None => debug_assert!(false, "no route called `{name}`, or a parameter is missing"),
705        }
706    }
707
708    /// Pushes the page for `location` on top of what is here.
709    pub fn push(&self, location: impl AsRef<str>) {
710        self.navigate(location.as_ref(), None, Mode::Push);
711    }
712
713    /// [`push`](Self::push), carrying an object the page can read with
714    /// [`GoRouterState::extra`].
715    pub fn push_with_extra(&self, location: impl AsRef<str>, extra: impl Any) {
716        self.navigate(location.as_ref(), Some(Rc::new(extra)), Mode::Push);
717    }
718
719    /// Pushes the route called `name`.
720    pub fn push_named(&self, name: &str, params: &[(&str, &str)], query: &[(&str, &str)]) {
721        match self.location_for(name, params, query) {
722            Some(location) => self.push(location),
723            None => debug_assert!(false, "no route called `{name}`, or a parameter is missing"),
724        }
725    }
726
727    /// Goes to a location that came from **outside** the application: an address typed, a
728    /// link followed, the browser's back button. Like [`go`](Self::go), except that a
729    /// location the router is already at is left alone, and that before anything has been
730    /// built there is no page to slide away from, so the router simply starts there.
731    ///
732    /// It is what the shell's `FrusApp` hands an address to.
733    pub fn open(&self, location: impl AsRef<str>) {
734        let location = location.as_ref();
735        if self.location() == location {
736            return;
737        }
738        self.go(location);
739        if !self.inner.built.get() {
740            self.inner.stack.borrow_mut().transition = None;
741        }
742    }
743
744    /// Replaces the page on top with the one for `location`, so going back skips the one it
745    /// replaced.
746    pub fn replace(&self, location: impl AsRef<str>) {
747        self.navigate(location.as_ref(), None, Mode::Replace);
748    }
749
750    /// Removes the page on top. Whether there was one to remove: the last page stays.
751    pub fn pop(&self) -> bool {
752        self.start();
753        let pages = {
754            let stack = self.inner.stack.borrow();
755            if stack.pages.len() <= 1 {
756                return false;
757            }
758            stack.pages[..stack.pages.len() - 1].to_vec()
759        };
760        self.commit(pages);
761        self.inner.replaced.set(false);
762        true
763    }
764
765    /// Asks the redirects again about where the top page is. What a
766    /// [`refresh listenable`](Self::refresh_listenable) does when it changes.
767    pub fn refresh(&self) {
768        self.start();
769        let target = self
770            .inner
771            .stack
772            .borrow()
773            .pages
774            .last()
775            .map(|page| (page.state.location.clone(), page.state.extra.clone()));
776        if let Some((location, extra)) = target {
777            self.navigate(&location, extra, Mode::Go);
778        }
779    }
780
781    // ---- the machinery -----------------------------------------------------------------
782
783    fn start(&self) {
784        if self.inner.started.replace(true) {
785            return;
786        }
787        let initial = self.inner.config.borrow().initial.clone();
788        let pages = self.pages_for(&initial, None, Mode::Go, &[]);
789        self.inner.stack.borrow_mut().pages = pages;
790    }
791
792    fn navigate(&self, location: &str, extra: Option<Rc<dyn Any>>, mode: Mode) {
793        self.start();
794        let current = self.inner.stack.borrow().pages.clone();
795        let pages = self.pages_for(location, extra, mode, &current);
796        self.commit(pages);
797        self.inner.replaced.set(mode == Mode::Replace);
798    }
799
800    /// The stack that results from `mode` to `location`, redirects followed.
801    fn pages_for(
802        &self,
803        location: &str,
804        extra: Option<Rc<dyn Any>>,
805        mode: Mode,
806        current: &[Page],
807    ) -> Vec<Page> {
808        let (nodes, redirect, limit) = {
809            let config = self.inner.config.borrow();
810            (config.nodes.clone(), config.redirect.clone(), config.limit)
811        };
812        let mut location = location.to_string();
813        let mut seen = 0usize;
814        loop {
815            let parts = split_location(&location);
816            let mut params = HashMap::new();
817            let chain = match_nodes(&nodes, &parts.segments, &mut params);
818            let query: HashMap<String, String> = parts.query.iter().cloned().collect();
819            let leaf = chain.as_ref().and_then(|chain| chain.last());
820            let state = GoRouterState {
821                location: location.clone(),
822                path: leaf.map(|n| n.full_path.clone()).unwrap_or_default(),
823                name: leaf.and_then(|n| n.name.clone()),
824                params: params.clone(),
825                query: query.clone(),
826                extra: extra.clone(),
827                entering: 1.0,
828            };
829
830            // Redirects: the router's own, then each route's, parent first.
831            let mut sent = redirect.as_ref().and_then(|r| r(&state));
832            if sent.is_none() {
833                if let Some(chain) = &chain {
834                    sent = chain
835                        .iter()
836                        .filter_map(|node| node.redirect.as_ref())
837                        .find_map(|r| r(&state));
838                }
839            }
840            if let Some(next) = sent.filter(|next| *next != location) {
841                seen += 1;
842                if seen > limit {
843                    let state = GoRouterState {
844                        location: location.clone(),
845                        path: String::new(),
846                        name: None,
847                        params: HashMap::new(),
848                        query: HashMap::new(),
849                        extra: None,
850                        entering: 1.0,
851                    };
852                    return self.error_pages(state, current, mode);
853                }
854                location = next;
855                continue;
856            }
857
858            let Some(chain) = chain else {
859                return self.error_pages(state, current, mode);
860            };
861            return match mode {
862                Mode::Go => {
863                    // One page per route of the chain, each keyed by the part of the location
864                    // it answered to, so a page that is still on the way keeps its state.
865                    let mut prefix = String::new();
866                    let mut used = 0;
867                    chain
868                        .iter()
869                        .enumerate()
870                        .map(|(at, node)| {
871                            let mut scratch = HashMap::new();
872                            let took =
873                                consume(&node.segments, &parts.segments[used..], &mut scratch)
874                                    .unwrap_or(0);
875                            for segment in &parts.segments[used..used + took] {
876                                prefix.push('/');
877                                prefix.push_str(segment);
878                            }
879                            used += took;
880                            let page_state = if at + 1 == chain.len() {
881                                state.clone()
882                            } else {
883                                // A page underneath stands for the part of the location it
884                                // answered — `/` under `/users/42` — so going back to it lands
885                                // on *its* location, not on where the person had been heading.
886                                GoRouterState {
887                                    location: if prefix.is_empty() {
888                                        "/".to_string()
889                                    } else {
890                                        prefix.clone()
891                                    },
892                                    path: node.full_path.clone(),
893                                    name: node.name.clone(),
894                                    ..state.clone()
895                                }
896                            };
897                            Page {
898                                key: hash_of(("go", prefix.clone(), node.full_path.clone())),
899                                node: Some(node.clone()),
900                                state: Rc::new(page_state),
901                            }
902                        })
903                        .collect()
904                }
905                Mode::Push | Mode::Replace => {
906                    let mut pages = current.to_vec();
907                    if mode == Mode::Replace {
908                        pages.pop();
909                    }
910                    let leaf = chain.last().expect("a match has a route").clone();
911                    let unique = self.inner.unique.get() + 1;
912                    self.inner.unique.set(unique);
913                    pages.push(Page {
914                        key: hash_of(("push", unique)),
915                        node: Some(leaf),
916                        state: Rc::new(state),
917                    });
918                    pages
919                }
920            };
921        }
922    }
923
924    fn error_pages(&self, state: GoRouterState, current: &[Page], mode: Mode) -> Vec<Page> {
925        let unique = self.inner.unique.get() + 1;
926        self.inner.unique.set(unique);
927        let page = Page {
928            key: hash_of(("error", unique)),
929            node: None,
930            state: Rc::new(state),
931        };
932        match mode {
933            Mode::Go => vec![page],
934            Mode::Push => {
935                let mut pages = current.to_vec();
936                pages.push(page);
937                pages
938            }
939            Mode::Replace => {
940                let mut pages = current.to_vec();
941                pages.pop();
942                pages.push(page);
943                pages
944            }
945        }
946    }
947
948    /// Makes `pages` the stack, starting the transition from the page that was on top.
949    fn commit(&self, pages: Vec<Page>) {
950        let animate = self.inner.config.borrow().animate;
951        let mut stack = self.inner.stack.borrow_mut();
952        let old_top = stack.pages.last().cloned();
953        let new_top_key = pages.last().map(|page| page.key);
954        if let (true, Some(old), Some(new_key)) = (animate, old_top, new_top_key) {
955            if old.key != new_key {
956                let forward = !stack.pages.iter().any(|page| page.key == new_key);
957                // **Linear, over the transition's own duration** (milestone 620): the
958                // reference's route animation runs at a constant pace, and each transition
959                // shapes it with its own curves. It was a spring, shaped by nobody.
960                let mut controller = AnimationController::unit();
961                controller.set_value(0.0);
962                controller.forward(
963                    self.inner.transitions.get().transition_duration(),
964                    Curve::Linear,
965                );
966                stack.transition = Some(Transition {
967                    from: old,
968                    controller,
969                    forward,
970                });
971            }
972        }
973        stack.pages = pages;
974        stack.back = None;
975        drop(stack);
976        request_rebuild();
977    }
978
979    // ---- driven by the shell -----------------------------------------------------------
980
981    /// Advances the page transition and a back gesture's settling by `dt` seconds. Whether
982    /// anything is still moving.
983    pub fn tick(&self, dt: f32) -> bool {
984        let mut animating = false;
985        let mut pop = false;
986        {
987            let mut stack = self.inner.stack.borrow_mut();
988            let arrived = match stack.transition.as_mut() {
989                Some(transition) => {
990                    if transition.controller.tick(dt) {
991                        animating = true;
992                        false
993                    } else {
994                        true
995                    }
996                }
997                None => false,
998            };
999            if arrived {
1000                stack.transition = None;
1001            }
1002            let mut settled = false;
1003            if let Some(back) = stack.back.as_mut() {
1004                if let Some(settle) = back.settle.as_mut() {
1005                    if settle.tick(dt) {
1006                        back.progress = settle.value();
1007                        animating = true;
1008                    } else {
1009                        pop = back.commit;
1010                        settled = true;
1011                    }
1012                }
1013            }
1014            if settled {
1015                stack.back = None;
1016            }
1017        }
1018        if pop {
1019            // The finger has already carried the page away: there is no transition left to
1020            // play, only the stack to catch up with.
1021            let pages = {
1022                let stack = self.inner.stack.borrow();
1023                stack.pages[..stack.pages.len().saturating_sub(1)].to_vec()
1024            };
1025            if !pages.is_empty() {
1026                self.inner.stack.borrow_mut().pages = pages;
1027            }
1028            request_rebuild();
1029        }
1030        animating
1031    }
1032
1033    /// Whether a back gesture can start: there is a page to go back to.
1034    pub fn can_go_back(&self) -> bool {
1035        self.can_pop()
1036    }
1037
1038    /// A back gesture is under way, `progress` of the way from `0` to `1`.
1039    pub fn back_gesture(&self, progress: f32) {
1040        let mut stack = self.inner.stack.borrow_mut();
1041        match stack.back.as_mut() {
1042            Some(back) => back.progress = progress,
1043            None => {
1044                stack.back = Some(Back {
1045                    progress,
1046                    settle: None,
1047                    commit: false,
1048                })
1049            }
1050        }
1051        drop(stack);
1052        request_rebuild();
1053    }
1054
1055    /// The finger lifted, moving at `velocity` (fractions per second): the page goes back or
1056    /// stays, whichever the momentum and the position say.
1057    pub fn back_gesture_end(&self, velocity: f32) {
1058        let mut stack = self.inner.stack.borrow_mut();
1059        let deep_enough = stack.pages.len() > 1;
1060        let cupertino = self.inner.transitions.get() == crate::PageTransitionsBuilder::Cupertino;
1061        if let Some(back) = stack.back.as_mut() {
1062            let mut settle = AnimationController::unit();
1063            settle.set_value(back.progress);
1064            let commit = if cupertino {
1065                // **The iOS page's own rules** (`route.dart:851`, milestone 621): moving
1066                // fast enough, its direction decides; otherwise, whether it was past
1067                // halfway. Then it finishes in 350 ms on its curve, wherever it was.
1068                let goes = if velocity.abs() >= CUPERTINO_FLING {
1069                    velocity > 0.0
1070                } else {
1071                    back.progress >= 0.5
1072                };
1073                let commit = goes && deep_enough;
1074                settle.animate_to(
1075                    if commit { 1.0 } else { 0.0 },
1076                    CUPERTINO_SETTLE,
1077                    Curve::fast_ease_in_to_slow_ease_out(),
1078                );
1079                commit
1080            } else {
1081                // Elsewhere the swipe is frus's own: where the finger's momentum would
1082                // carry the page, and a spring from there.
1083                let projected = back.progress + velocity * BACK_PROJECT;
1084                let commit = projected > BACK_COMMIT_POS && deep_enough;
1085                settle.spring_to(if commit { 1.0 } else { 0.0 }, spring(), velocity);
1086                commit
1087            };
1088            back.commit = commit;
1089            back.settle = Some(settle);
1090        }
1091        drop(stack);
1092        request_rebuild();
1093    }
1094
1095    // ---- building ----------------------------------------------------------------------
1096
1097    /// The widget for the router's pages: what an application whose root is a router
1098    /// builds. It also makes the router reachable from below, with [`BuildContext::router`].
1099    pub fn build(&self, cx: &BuildContext) -> Box<dyn Widget> {
1100        self.start();
1101        self.inner.built.set(true);
1102        let theme = cx.theme();
1103        self.inner
1104            .transitions
1105            .set(theme.page_transitions.builder_for(theme.platform));
1106        cx.runtime().states.provide(Rc::new(self.clone()));
1107        let error_builder = self.inner.config.borrow().error.clone();
1108        // What is on the stack is copied out, so that a page's builder can ask the router
1109        // things without finding it borrowed.
1110        let (pages, transition, back) = {
1111            let stack = self.inner.stack.borrow();
1112            (
1113                stack.pages.clone(),
1114                stack
1115                    .transition
1116                    .as_ref()
1117                    .map(|t| (t.from.clone(), t.controller.value(), t.forward)),
1118                stack.back.as_ref().map(|b| b.progress),
1119            )
1120        };
1121        let depth = pages.len();
1122        // A page is built told how far in it is: the transition's own number, seen from
1123        // this page — the one arriving has it as it is, the one leaving has it the other way.
1124        let make = |page: &Page, entering: f32| -> Box<dyn Widget> {
1125            let state = GoRouterState {
1126                entering,
1127                ..(*page.state).clone()
1128            };
1129            match &page.node {
1130                Some(node) => (node.builder)(cx, &state),
1131                None => match &error_builder {
1132                    Some(builder) => builder(cx, &state),
1133                    None => Box::new(crate::Text::new(format!(
1134                        "Page not found: {}",
1135                        state.location
1136                    ))),
1137                },
1138            }
1139        };
1140        let key_of = |page: &Page, depth: usize| (depth, page.key);
1141        let top = pages.last().expect("a router has at least one page");
1142
1143        // A back gesture previews the pop, driven by the finger.
1144        if let Some(progress) = back {
1145            if depth >= 2 {
1146                let below = &pages[depth - 2];
1147                let mut navigator = Navigator::new(key_of(below, depth - 2), make(below, progress))
1148                    .from(
1149                        key_of(top, depth - 1),
1150                        make(top, 1.0 - progress),
1151                        progress,
1152                        false,
1153                    )
1154                    // The finger drives it: the pages follow linearly.
1155                    .gesture(true);
1156                for (at, page) in pages[..depth - 2].iter().enumerate() {
1157                    navigator = navigator.retain(key_of(page, at), make(page, 1.0));
1158                }
1159                return Box::new(navigator);
1160            }
1161        }
1162
1163        match transition {
1164            Some((from, progress, forward)) => {
1165                // The page leaving is either still in the stack (a push left it one entry
1166                // down) or gone from it (a pop took it off the top).
1167                let from_depth = pages
1168                    .iter()
1169                    .position(|page| page.key == from.key)
1170                    .unwrap_or(depth);
1171                let mut navigator = Navigator::new(key_of(top, depth - 1), make(top, progress))
1172                    .from(
1173                        key_of(&from, from_depth),
1174                        make(&from, 1.0 - progress),
1175                        progress,
1176                        forward,
1177                    );
1178                for (at, page) in pages[..depth - 1].iter().enumerate() {
1179                    if page.key != from.key {
1180                        navigator = navigator.retain(key_of(page, at), make(page, 1.0));
1181                    }
1182                }
1183                Box::new(navigator)
1184            }
1185            None => {
1186                let mut navigator = Navigator::new(key_of(top, depth - 1), make(top, 1.0));
1187                for (at, page) in pages[..depth - 1].iter().enumerate() {
1188                    navigator = navigator.retain(key_of(page, at), make(page, 1.0));
1189                }
1190                Box::new(navigator)
1191            }
1192        }
1193    }
1194}
1195
1196impl BuildContext<'_> {
1197    /// The application's router. Handlers capture it and navigate from there:
1198    ///
1199    /// ```ignore
1200    /// let router = cx.router();
1201    /// button("Open", move || router.go("/settings"))
1202    /// ```
1203    ///
1204    /// Panics if this application has no router — one made with `FrusApp::router`.
1205    pub fn router(&self) -> GoRouter {
1206        match self.service::<GoRouter>() {
1207            Some(router) => (*router).clone(),
1208            None => {
1209                panic!("there is no router: this application was not made with `FrusApp::router`")
1210            }
1211        }
1212    }
1213}
1214
1215#[cfg(test)]
1216mod tests {
1217    use super::*;
1218    use crate::Container;
1219
1220    fn page(_: &BuildContext, _: &GoRouterState) -> Container {
1221        Container::new()
1222    }
1223
1224    fn routes() -> Vec<GoRoute> {
1225        vec![
1226            GoRoute::new("/", page).routes(vec![
1227                GoRoute::new("users/:id", page)
1228                    .name("user")
1229                    .routes(vec![GoRoute::new("posts/:post", page).name("post")]),
1230                GoRoute::new("files/*", page).name("files"),
1231            ]),
1232            GoRoute::new("/settings", page).name("settings"),
1233        ]
1234    }
1235
1236    fn router() -> GoRouter {
1237        GoRouter::new(routes()).animate(false)
1238    }
1239
1240    fn keys(router: &GoRouter) -> Vec<String> {
1241        router
1242            .inner
1243            .stack
1244            .borrow()
1245            .pages
1246            .iter()
1247            .map(|p| p.state.path.clone())
1248            .collect()
1249    }
1250
1251    #[test]
1252    fn it_starts_at_the_initial_location() {
1253        assert_eq!(router().location(), "/");
1254        assert_eq!(
1255            GoRouter::new(routes())
1256                .initial_location("/settings")
1257                .location(),
1258            "/settings"
1259        );
1260    }
1261
1262    #[test]
1263    fn go_makes_the_stack_the_chain_of_routes_the_location_is_made_of() {
1264        let r = router();
1265        r.go("/users/42/posts/7");
1266        assert_eq!(
1267            keys(&r),
1268            ["/", "/users/:id", "/users/:id/posts/:post"],
1269            "home, the user, then the post: something to go back to at each step"
1270        );
1271        let state = r.state().unwrap();
1272        assert_eq!(state.param("id"), Some("42"));
1273        assert_eq!(state.param("post"), Some("7"));
1274        assert_eq!(state.path(), "/users/:id/posts/:post");
1275        assert_eq!(state.name(), Some("post"));
1276    }
1277
1278    #[test]
1279    fn a_page_underneath_stands_for_its_own_location() {
1280        let r = router();
1281        r.go("/users/42/posts/7?x=1");
1282        let locations: Vec<String> = r
1283            .inner
1284            .stack
1285            .borrow()
1286            .pages
1287            .iter()
1288            .map(|p| p.state.location.clone())
1289            .collect();
1290        assert_eq!(
1291            locations,
1292            ["/", "/users/42", "/users/42/posts/7?x=1"],
1293            "each page is the part of the location it answered; the top one, all of it"
1294        );
1295        r.pop();
1296        assert_eq!(
1297            r.location(),
1298            "/users/42",
1299            "so going back lands where that page is"
1300        );
1301    }
1302
1303    #[test]
1304    fn a_page_underneath_knows_the_parameters_too() {
1305        let r = router();
1306        r.go("/users/42/posts/7");
1307        let below = r.inner.stack.borrow().pages[1].state.clone();
1308        assert_eq!(below.param("id"), Some("42"));
1309        assert_eq!(below.path(), "/users/:id");
1310    }
1311
1312    #[test]
1313    fn the_query_is_read_and_decoded() {
1314        let r = router();
1315        r.go("/users/1?tab=posts&q=a%20b+c&flag");
1316        let state = r.state().unwrap();
1317        assert_eq!(state.query("tab"), Some("posts"));
1318        assert_eq!(state.query("q"), Some("a b c"));
1319        assert_eq!(state.query("flag"), Some(""));
1320        assert_eq!(state.location(), "/users/1?tab=posts&q=a%20b+c&flag");
1321    }
1322
1323    #[test]
1324    fn a_parameter_is_percent_decoded() {
1325        let r = router();
1326        r.go("/users/a%2Fb");
1327        assert_eq!(r.state().unwrap().param("id"), Some("a/b"));
1328    }
1329
1330    #[test]
1331    fn a_wildcard_takes_what_is_left() {
1332        let r = router();
1333        r.go("/files/a/b/c.txt");
1334        assert_eq!(r.state().unwrap().param("*"), Some("a/b/c.txt"));
1335    }
1336
1337    #[test]
1338    fn a_location_no_route_answers_to_is_an_error_page() {
1339        let r = router();
1340        r.go("/nowhere");
1341        assert_eq!(r.depth(), 1);
1342        assert!(r.inner.stack.borrow().pages[0].node.is_none());
1343        assert_eq!(r.location(), "/nowhere");
1344    }
1345
1346    #[test]
1347    fn push_adds_a_page_and_pop_removes_it() {
1348        let r = router();
1349        r.push("/settings");
1350        assert_eq!((r.depth(), r.location()), (2, "/settings".to_string()));
1351        r.push("/settings");
1352        assert_eq!(r.depth(), 3, "the same page may be open twice");
1353        assert!(r.pop());
1354        assert!(r.pop());
1355        assert!(!r.pop(), "the last page stays");
1356        assert_eq!(r.location(), "/");
1357    }
1358
1359    #[test]
1360    fn replace_swaps_the_top_page() {
1361        let r = router();
1362        r.push("/settings");
1363        r.replace("/users/3");
1364        assert_eq!(keys(&r), ["/", "/users/:id"]);
1365    }
1366
1367    #[test]
1368    fn going_back_to_a_page_already_underneath_keeps_it() {
1369        let r = router();
1370        r.go("/users/1");
1371        let home = r.inner.stack.borrow().pages[0].key;
1372        r.go("/settings");
1373        r.go("/users/1");
1374        assert_eq!(
1375            r.inner.stack.borrow().pages[0].key,
1376            home,
1377            "the same place, the same page — and so the same state"
1378        );
1379    }
1380
1381    #[test]
1382    fn a_named_route_is_reached_by_its_name() {
1383        let r = router();
1384        assert_eq!(
1385            r.location_for("post", &[("id", "4 2"), ("post", "9")], &[("x", "a&b")]),
1386            Some("/users/4%202/posts/9?x=a%26b".to_string())
1387        );
1388        assert_eq!(
1389            r.location_for("settings", &[], &[]),
1390            Some("/settings".to_string())
1391        );
1392        assert_eq!(
1393            r.location_for("user", &[], &[]),
1394            None,
1395            "a parameter is missing"
1396        );
1397        assert_eq!(r.location_for("nope", &[], &[]), None);
1398        r.go_named("user", &[("id", "5")], &[]);
1399        assert_eq!(r.location(), "/users/5");
1400        r.push_named("settings", &[], &[]);
1401        assert_eq!(r.location(), "/settings");
1402    }
1403
1404    #[test]
1405    fn a_redirect_sends_the_navigation_elsewhere() {
1406        let signed_in = Rc::new(Cell::new(false));
1407        let gate = signed_in.clone();
1408        let r = GoRouter::new(vec![
1409            GoRoute::new("/", page),
1410            GoRoute::new("/login", page),
1411            GoRoute::new("/account", page),
1412        ])
1413        .animate(false)
1414        .redirect(move |state| {
1415            (state.location() == "/account" && !gate.get()).then(|| "/login".to_string())
1416        });
1417        r.go("/account");
1418        assert_eq!(r.location(), "/login");
1419        signed_in.set(true);
1420        r.go("/account");
1421        assert_eq!(r.location(), "/account");
1422    }
1423
1424    #[test]
1425    fn a_route_can_redirect_for_itself() {
1426        let r = GoRouter::new(vec![
1427            GoRoute::new("/", page),
1428            GoRoute::new("/old", page).redirect(|_| Some("/new".to_string())),
1429            GoRoute::new("/new", page),
1430        ])
1431        .animate(false);
1432        r.go("/old");
1433        assert_eq!(r.location(), "/new");
1434    }
1435
1436    #[test]
1437    fn a_redirect_loop_ends_in_the_error_page() {
1438        let r = GoRouter::new(vec![
1439            GoRoute::new("/", page),
1440            GoRoute::new("/a", page),
1441            GoRoute::new("/b", page),
1442        ])
1443        .animate(false)
1444        .redirect(|state| match state.location() {
1445            "/a" => Some("/b".to_string()),
1446            "/b" => Some("/a".to_string()),
1447            _ => None,
1448        });
1449        r.go("/a");
1450        assert!(r.inner.stack.borrow().pages[0].node.is_none());
1451    }
1452
1453    #[test]
1454    fn a_refresh_listenable_runs_the_redirects_again() {
1455        use crate::ValueNotifier;
1456        let auth = ValueNotifier::new(true);
1457        let watched = auth.clone();
1458        let r = GoRouter::new(vec![
1459            GoRoute::new("/", page),
1460            GoRoute::new("/login", page),
1461            GoRoute::new("/home", page),
1462        ])
1463        .animate(false)
1464        .redirect(move |state| {
1465            let signed_in = watched.get();
1466            match (state.location(), signed_in) {
1467                ("/home", false) => Some("/login".to_string()),
1468                ("/login", true) => Some("/home".to_string()),
1469                _ => None,
1470            }
1471        })
1472        .refresh_listenable(&auth);
1473        r.go("/home");
1474        assert_eq!(r.location(), "/home");
1475        auth.set(false);
1476        assert_eq!(
1477            r.location(),
1478            "/login",
1479            "signing out moves the page with nobody navigating"
1480        );
1481    }
1482
1483    #[test]
1484    fn extra_travels_with_the_navigation() {
1485        let r = router();
1486        r.go_with_extra("/settings", 41u32);
1487        assert_eq!(r.state().unwrap().extra::<u32>().as_deref(), Some(&41));
1488        assert!(r.state().unwrap().extra::<String>().is_none());
1489    }
1490
1491    #[test]
1492    fn a_push_slides_in_and_a_pop_slides_out() {
1493        let r = GoRouter::new(routes());
1494        r.go("/settings");
1495        assert!(
1496            r.inner.stack.borrow().transition.is_some(),
1497            "a page arrives"
1498        );
1499        assert!(r.inner.stack.borrow().transition.as_ref().unwrap().forward);
1500        let mut frames = 0;
1501        while r.tick(1.0 / 60.0) {
1502            frames += 1;
1503            assert!(frames < 600, "the spring settles");
1504        }
1505        assert!(r.inner.stack.borrow().transition.is_none());
1506
1507        r.push("/users/1");
1508        r.pop();
1509        assert!(
1510            !r.inner.stack.borrow().transition.as_ref().unwrap().forward,
1511            "going back"
1512        );
1513    }
1514
1515    /// **A push lasts as long as the theme's platform's transition** (milestone 620): the
1516    /// router learns the transition when it is built, and runs the next one at a constant
1517    /// pace for that long — 500 ms for iOS's slide, 300 for the desktops' zoom.
1518    #[test]
1519    fn a_push_lasts_as_long_as_the_platform_s_transition() {
1520        let frames = |platform: frus_core::TargetPlatform| {
1521            // Animated, unlike the helper's router.
1522            let r = GoRouter::new(routes());
1523            let runtime = crate::Runtime::default();
1524            let theme = crate::Theme::default().with_platform(platform);
1525            let _ = r.build(&BuildContext::for_test(&runtime, &theme));
1526            r.push("/settings");
1527            let mut frames = 0;
1528            while r.tick(0.01) {
1529                frames += 1;
1530                assert!(frames < 1000);
1531            }
1532            frames
1533        };
1534        let slide = frames(frus_core::TargetPlatform::Ios);
1535        let zoom = frames(frus_core::TargetPlatform::Linux);
1536        assert!(
1537            (49..=51).contains(&slide),
1538            "half a second: {slide} frames of 10 ms"
1539        );
1540        assert!(
1541            (29..=31).contains(&zoom),
1542            "three tenths: {zoom} frames of 10 ms"
1543        );
1544    }
1545
1546    /// **A back gesture's pages follow the finger**: the navigator it builds says so, so the
1547    /// transition is drawn linearly rather than on its curves.
1548    #[test]
1549    fn a_back_gesture_is_drawn_as_the_finger_moves() {
1550        let r = router();
1551        let runtime = crate::Runtime::default();
1552        let theme = crate::Theme::default();
1553        r.push("/settings");
1554        while r.tick(0.05) {}
1555        r.back_gesture(0.3);
1556        let tree = r.build(&BuildContext::for_test(&runtime, &theme));
1557        assert!(tree.navigator_gesture(), "under the finger");
1558        r.back_gesture_end(0.0);
1559        while r.tick(0.05) {}
1560        let tree = r.build(&BuildContext::for_test(&runtime, &theme));
1561        assert!(!tree.navigator_gesture(), "and not once it has let go");
1562    }
1563
1564    /// **An iOS page let go follows the iOS rules** (`route.dart:851`, milestone 621):
1565    /// moving at a screen width a second or more, its direction decides — a flick back
1566    /// pops it even from near the start, a flick the other way keeps it even from near
1567    /// the end; slower, half the way decides. And it takes 350 ms either way.
1568    #[test]
1569    fn an_ios_page_let_go_follows_the_ios_rules() {
1570        let released = |progress: f32, velocity: f32| {
1571            let r = router();
1572            let runtime = crate::Runtime::default();
1573            let theme = crate::Theme::default().with_platform(frus_core::TargetPlatform::Ios);
1574            let _ = r.build(&BuildContext::for_test(&runtime, &theme));
1575            r.push("/settings");
1576            r.back_gesture(progress);
1577            r.back_gesture_end(velocity);
1578            let mut frames = 0;
1579            while r.tick(0.01) {
1580                frames += 1;
1581                assert!(frames < 1000);
1582            }
1583            (r.depth(), frames)
1584        };
1585        assert_eq!(
1586            released(0.2, 1.2).0,
1587            1,
1588            "a flick back pops it from near the start"
1589        );
1590        assert_eq!(released(0.8, -1.2).0, 2, "a flick the other way keeps it");
1591        assert_eq!(released(0.6, 0.5).0, 1, "slow, past halfway: it goes");
1592        assert_eq!(released(0.4, 0.9).0, 2, "slow, not halfway: it stays");
1593        let (_, frames) = released(0.6, 0.0);
1594        assert!(
1595            (34..=36).contains(&frames),
1596            "350 ms: {frames} frames of 10 ms"
1597        );
1598        let (_, frames) = released(0.1, 0.0);
1599        assert!(
1600            (34..=36).contains(&frames),
1601            "from wherever it was: {frames}"
1602        );
1603    }
1604
1605    #[test]
1606    fn a_back_gesture_past_halfway_pops_and_a_short_one_does_not() {
1607        let r = router();
1608        r.push("/settings");
1609        assert!(r.can_go_back());
1610        r.back_gesture(0.2);
1611        r.back_gesture_end(0.0);
1612        while r.tick(1.0 / 60.0) {}
1613        assert_eq!(r.depth(), 2, "let go early: the page comes back");
1614
1615        r.back_gesture(0.8);
1616        r.back_gesture_end(0.0);
1617        while r.tick(1.0 / 60.0) {}
1618        assert_eq!(r.depth(), 1, "carried far enough: it goes");
1619    }
1620
1621    #[test]
1622    fn a_route_needs_a_slash_at_the_top_and_none_below() {
1623        let bad_top = std::panic::catch_unwind(|| GoRouter::new(vec![GoRoute::new("nope", page)]));
1624        assert!(bad_top.is_err());
1625        let bad_child = std::panic::catch_unwind(|| {
1626            GoRouter::new(vec![
1627                GoRoute::new("/", page).routes(vec![GoRoute::new("/x", page)])
1628            ])
1629        });
1630        assert!(bad_child.is_err());
1631    }
1632}
1633
1634#[cfg(test)]
1635mod animated_tests {
1636    use super::*;
1637    use crate::Container;
1638
1639    fn page(_: &BuildContext, _: &GoRouterState) -> Container {
1640        Container::new()
1641    }
1642
1643    /// The same moves with the slide on — the default — must land where they land with it off.
1644    #[test]
1645    fn the_slide_does_not_change_where_the_stack_ends_up() {
1646        let router = GoRouter::new(vec![
1647            GoRoute::new("/", page).routes(vec![GoRoute::new("users/:id", page)]),
1648            GoRoute::new("/settings", page),
1649        ]);
1650        router.go("/users/42?tab=posts");
1651        assert_eq!(router.location(), "/users/42?tab=posts");
1652        assert!(router.can_pop());
1653        assert!(router.pop());
1654        assert_eq!(router.location(), "/");
1655    }
1656}
1657
1658#[cfg(test)]
1659mod open_tests {
1660    use super::*;
1661    use crate::Container;
1662
1663    fn page(_: &BuildContext, _: &GoRouterState) -> Container {
1664        Container::new()
1665    }
1666
1667    fn router() -> GoRouter {
1668        GoRouter::new(vec![
1669            GoRoute::new("/", page).routes(vec![GoRoute::new("users/:id", page)]),
1670            GoRoute::new("/settings", page),
1671        ])
1672    }
1673
1674    fn sliding(router: &GoRouter) -> bool {
1675        router.inner.stack.borrow().transition.is_some()
1676    }
1677
1678    #[test]
1679    fn an_address_the_router_is_already_at_changes_nothing() {
1680        let r = router();
1681        r.open("/");
1682        assert_eq!((r.location(), r.depth()), ("/".to_string(), 1));
1683        assert!(!sliding(&r));
1684    }
1685
1686    #[test]
1687    fn before_anything_is_built_the_router_starts_at_the_address_without_a_slide() {
1688        let r = router();
1689        r.open("/users/42");
1690        assert_eq!(r.location(), "/users/42");
1691        assert_eq!(r.depth(), 2, "home underneath, as with any `go`");
1692        assert!(
1693            !sliding(&r),
1694            "nobody has seen a page to slide away from: an address is where it opens"
1695        );
1696    }
1697
1698    #[test]
1699    fn once_a_page_has_been_seen_an_address_slides_to_it_like_any_other_move() {
1700        let r = router();
1701        r.inner.built.set(true);
1702        r.open("/settings");
1703        assert_eq!(r.location(), "/settings");
1704        assert!(sliding(&r));
1705    }
1706
1707    #[test]
1708    fn the_locations_of_the_stack_run_from_the_bottom_to_the_top() {
1709        let r = router();
1710        r.go("/users/42?tab=posts");
1711        assert_eq!(r.locations(), ["/", "/users/42?tab=posts"]);
1712    }
1713
1714    #[test]
1715    fn only_a_replace_is_a_swap() {
1716        let r = router();
1717        assert!(!r.replaced());
1718        r.push("/settings");
1719        assert!(!r.replaced());
1720        r.replace("/users/1");
1721        assert!(r.replaced());
1722        r.go("/settings");
1723        assert!(!r.replaced(), "a go rebuilds the stack");
1724        r.push("/users/3");
1725        r.replace("/users/2");
1726        assert!(r.replaced());
1727        assert!(r.pop());
1728        assert!(!r.replaced(), "and a pop removes a page");
1729    }
1730
1731    #[test]
1732    fn an_address_that_is_a_page_underneath_goes_back_to_it() {
1733        let r = router();
1734        r.go("/users/42");
1735        r.inner.built.set(true);
1736        r.open("/");
1737        assert_eq!((r.location(), r.depth()), ("/".to_string(), 1));
1738        let stack = r.inner.stack.borrow();
1739        assert!(
1740            !stack.transition.as_ref().unwrap().forward,
1741            "the browser's back button slides the page out, not in"
1742        );
1743    }
1744}