Skip to main content

frust_widgets/nav/
route_state.rs

1//! Route-state observable: a signal-free snapshot of the navigator's page
2//! stack as route identities, published by
3//! [`NavigatorWidget::publish_state`](super::navigator::NavigatorWidget) after
4//! every *committed* stack mutation.
5//!
6//! # R-B1
7//!
8//! A page's route identity is *given at push time*
9//! ([`PushOptions::route`](super::navigator::PushOptions::route) /
10//! [`NavigatorView::root_route`](super::navigator::NavigatorView::root_route))
11//! and *published by the navigator* — nothing derives it from depth, and no
12//! consumer keeps a second copy of the stack.
13//!
14//! # `None` entries
15//!
16//! A page pushed as a bare builder (an overlay/dialog — no
17//! [`PushOptions::route`](super::navigator::PushOptions::route) call)
18//! publishes `None`: [`RouteStack::current`] reports it (the raw top), but
19//! [`RouteStack::current_route`] skips it — the seam chrome reads so a
20//! transparent overlay never retitles the bar.
21//!
22//! # Direction is derived, never recorded
23//!
24//! [`NavChange`] is computed by diffing the freshly computed entries against
25//! the last-published set, not stamped at each mutation site: a stack is
26//! *state*, and `publish_state` sees it after every mutation (including one a
27//! per-site recorder would miss, like a `request_back` pop or an
28//! interactive-swipe pop finalized well after the steal), so a missed call
29//! site is inexpressible. The derivation:
30//!
31//! ```text
32//! prev empty                             -> Initial
33//! len+1 & prev is a prefix of next       -> Push
34//! len-1 & next is a prefix of prev       -> Pop
35//! same len, only the top entry differs   -> Replace
36//! anything else                          -> Reset
37//! ```
38//!
39//! # Staleness contract
40//!
41//! - **Authoritative-at-publish.** After `apply_ops` the published
42//!   [`RouteStack`] *is* the stack — the guarantee
43//!   `NavigatorController::depth` explicitly disclaims as merely advisory.
44//! - **A queued-but-undrained op is not in it.** The op queue is *intent*
45//!   (`RouteNavigator::location`, the router's own request queue); this
46//!   observable is *fact*. Both stay — the split is deliberate.
47//! - **One bounded staleness window: an in-flight interactive edge swipe —
48//!   and the two halves diverge, not agree.** `begin_interactive_pop` pops
49//!   the page out of `self.pages` immediately, at steal, before the drag
50//!   paints a single frame. `NavigatorView::rebuild`'s trailing
51//!   `publish_state()` call is UNCONDITIONAL, so every drag frame
52//!   republishes `depth()`/`can_pop()`/`back_interest()` against that
53//!   already-popped count — EAGER, ahead of the commit. This observable's
54//!   own publish (`publish_route_stack`, called from inside `publish_state`)
55//!   is gated on `self.transition` being `Some` and `interactive` and
56//!   returns early while that holds, so it keeps reporting the *pre-swipe*
57//!   stack — CONSERVATIVE, behind the commit — until the settle-frame
58//!   publish (after `finalize_transition` clears the flag). Depth leads, the
59//!   stack lags, for the whole drag — unbounded in wall time because a
60//!   finger can hold. A cancelled swipe makes the lagging observable read
61//!   retroactively correct rather than something to retract.
62//!   **Consequence**: at depth 2, a held swipe already publishes
63//!   `compute_back_interest(1, Pop) == false` — a back press read mid-drag
64//!   claims no interest and escapes to the platform (activity finish on
65//!   Android), even though releasing below the commit point restores the
66//!   page. Frame-accurate drag chrome already has
67//!   `NavigatorController::transition()` (`is_pop`/`progress`/`interactive`)
68//!   for exactly this window — this observable is not it.
69
70use super::path::Location;
71
72/// How the page stack changed between the last two published [`RouteStack`]s.
73/// DERIVED from the diff of consecutive published stacks — never recorded at
74/// a mutation site (see the [module docs](self)).
75#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
76pub enum NavChange {
77    /// The first publish this navigator ever made (no previous stack to diff
78    /// against).
79    #[default]
80    Initial,
81    /// The new stack is the previous one plus exactly one more entry on top.
82    Push,
83    /// The new stack is a strict prefix of the previous one (exactly one
84    /// entry removed from the top).
85    Pop,
86    /// Same depth as the previous stack; only the top entry differs.
87    Replace,
88    /// Anything else — e.g. a router `go()` that swaps the whole chain in one
89    /// batch, or a multi-page pop that isn't a strict-prefix shrink.
90    Reset,
91}
92
93/// The page stack as route identities, bottom→top. `None` at an index means
94/// that page was pushed as a bare builder (an overlay/dialog): it carries no
95/// location, and it must never retitle route-derived chrome — see the [module
96/// docs](self).
97///
98/// Cloned out of [`NavigatorController::route_stack`](super::navigator::NavigatorController::route_stack)
99/// — a snapshot, not a live handle. See the [module docs](self)' staleness
100/// contract for what "authoritative-at-publish" does and does not promise.
101#[derive(Clone, Debug, Default, PartialEq, Eq)]
102pub struct RouteStack {
103    entries: Vec<Option<Location>>,
104    generation: u64,
105    change: NavChange,
106}
107
108impl RouteStack {
109    /// Every page's route identity, bottom→top. `None` at an index is a
110    /// route-less (overlay/dialog) page — see the [module docs](self).
111    pub fn entries(&self) -> &[Option<Location>] {
112        &self.entries
113    }
114
115    /// The stack depth (page count) as of this publish.
116    pub fn depth(&self) -> usize {
117        self.entries.len()
118    }
119
120    /// The topmost page's route identity, `None` if it has none (an
121    /// overlay/dialog on top). Chrome that needs to skip a route-less top
122    /// wants [`current_route`](Self::current_route) instead.
123    pub fn current(&self) -> Option<&Location> {
124        self.entries.last().and_then(Option::as_ref)
125    }
126
127    /// The topmost page that **has** a route, skipping any route-less
128    /// (overlay/dialog) pages above it — the read chrome (an app bar's title,
129    /// e.g.) wants, so a transparent overlay never retitles it. `None` only
130    /// if no page in the stack carries a route at all.
131    pub fn current_route(&self) -> Option<&Location> {
132        self.entries.iter().rev().find_map(Option::as_ref)
133    }
134
135    /// A monotonically increasing counter, bumped once per actual publish (an
136    /// unchanged stack across N rebuilds bumps it zero times) — an O(1) change
137    /// gate for a consumer that only wants to know "did anything happen since
138    /// I last looked?" without comparing entries itself.
139    pub fn generation(&self) -> u64 {
140        self.generation
141    }
142
143    /// A diff label for **this** generation — not a running direction; see
144    /// [`NavChange`].
145    pub fn change(&self) -> NavChange {
146        self.change
147    }
148
149    /// Overwrite the stack with `entries`, bump the generation, and derive
150    /// [`change`](Self::change) from the diff against the previous entries.
151    ///
152    /// Always applies — the caller
153    /// ([`NavigatorWidget::publish_route_stack`](super::navigator::NavigatorWidget))
154    /// is responsible for skipping this call entirely when `entries` would be
155    /// unchanged, which is what keeps an untouched stack's publish
156    /// allocation-free (see the [module docs](self)' derivation note): the
157    /// comparison against the live stack happens directly against
158    /// `entries()`, before any `Vec` is built.
159    pub(super) fn set(&mut self, entries: Vec<Option<Location>>) {
160        self.change = derive_nav_change(&self.entries, &entries);
161        self.entries = entries;
162        self.generation += 1;
163    }
164}
165
166/// The pure diff derivation the [module docs](self) table describes. Free
167/// (not a method) and `pub(super)` so it is directly unit-testable, mirroring
168/// `compute_back_interest`'s (`super::navigator`) shape.
169pub(super) fn derive_nav_change(prev: &[Option<Location>], next: &[Option<Location>]) -> NavChange {
170    if prev.is_empty() {
171        return NavChange::Initial;
172    }
173    if next.len() == prev.len() + 1 && next[..prev.len()] == *prev {
174        return NavChange::Push;
175    }
176    if prev.len() == next.len() + 1 && prev[..next.len()] == *next {
177        return NavChange::Pop;
178    }
179    if prev.len() == next.len() && prev[..prev.len() - 1] == next[..next.len() - 1] {
180        return NavChange::Replace;
181    }
182    NavChange::Reset
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    fn loc(path: &str) -> Option<Location> {
190        Some(Location::parse(path))
191    }
192
193    #[test]
194    fn empty_prev_is_always_initial() {
195        assert_eq!(derive_nav_change(&[], &[loc("/a")]), NavChange::Initial);
196        assert_eq!(derive_nav_change(&[], &[]), NavChange::Initial);
197    }
198
199    #[test]
200    fn an_exact_one_entry_extension_is_push() {
201        let prev = [loc("/a")];
202        let next = [loc("/a"), loc("/b")];
203        assert_eq!(derive_nav_change(&prev, &next), NavChange::Push);
204    }
205
206    #[test]
207    fn an_exact_one_entry_prefix_shrink_is_pop() {
208        let prev = [loc("/a"), loc("/b")];
209        let next = [loc("/a")];
210        assert_eq!(derive_nav_change(&prev, &next), NavChange::Pop);
211    }
212
213    #[test]
214    fn same_len_top_only_differs_is_replace() {
215        let prev = [loc("/a"), loc("/b")];
216        let next = [loc("/a"), loc("/c")];
217        assert_eq!(derive_nav_change(&prev, &next), NavChange::Replace);
218    }
219
220    #[test]
221    fn same_len_single_entry_differs_is_replace() {
222        // depth 1 -> depth 1, the empty-prefix edge case.
223        let prev = [loc("/a")];
224        let next = [loc("/b")];
225        assert_eq!(derive_nav_change(&prev, &next), NavChange::Replace);
226    }
227
228    #[test]
229    fn a_whole_chain_swap_is_reset() {
230        // A router `go()` that replaces the root AND pushes more in one
231        // batch: neither a strict extension, shrink, nor top-only swap.
232        let prev = [loc("/a")];
233        let next = [loc("/b"), loc("/c")];
234        assert_eq!(derive_nav_change(&prev, &next), NavChange::Reset);
235    }
236
237    #[test]
238    fn a_multi_page_pop_is_reset_not_pop() {
239        let prev = [loc("/a"), loc("/b"), loc("/c")];
240        let next = [loc("/a")];
241        assert_eq!(derive_nav_change(&prev, &next), NavChange::Reset);
242    }
243
244    #[test]
245    fn none_entries_participate_in_the_diff_like_any_other() {
246        let prev = [loc("/a"), None];
247        let next = [loc("/a"), None, loc("/c")];
248        assert_eq!(derive_nav_change(&prev, &next), NavChange::Push);
249    }
250
251    #[test]
252    fn current_reports_the_raw_top_including_none() {
253        let mut stack = RouteStack::default();
254        stack.set(vec![loc("/a"), None]);
255        assert_eq!(stack.current(), None);
256        assert_eq!(stack.current_route().map(|l| l.path.as_str()), Some("/a"));
257    }
258
259    #[test]
260    fn current_route_skips_every_routeless_page_on_top() {
261        let mut stack = RouteStack::default();
262        stack.set(vec![None, None]);
263        assert_eq!(
264            stack.current_route(),
265            None,
266            "no page in the stack carries a route"
267        );
268    }
269
270    #[test]
271    fn set_bumps_generation_and_replaces_entries() {
272        let mut stack = RouteStack::default();
273        assert_eq!(stack.generation(), 0);
274        assert_eq!(stack.change(), NavChange::Initial);
275
276        stack.set(vec![loc("/a")]);
277        assert_eq!(stack.generation(), 1);
278        assert_eq!(stack.change(), NavChange::Initial);
279        assert_eq!(stack.depth(), 1);
280
281        stack.set(vec![loc("/a"), loc("/b")]);
282        assert_eq!(stack.generation(), 2);
283        assert_eq!(stack.change(), NavChange::Push);
284        assert_eq!(stack.depth(), 2);
285    }
286}