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}