day_core/nav.rs
1// Copyright © The Daybrite Project
2// SPDX-License-Identifier: MPL-2.0
3
4//! Route registry (docs/navigation.md): every mounted `nav()` / `nav_stack()` host
5//! register a controller here. Registrations form a stack so hosts can nest (e.g. a `tabs()`
6//! inside a `nav()` route), and the stack order is the nesting order (outermost first).
7//!
8//! Two addressing modes (docs/navigation.md):
9//! * A single key (`navigate("inbox")`) is relative: tried innermost-first, falling through
10//! outward. A tab key selects the tab; a key the tabs host doesn't know still resolves
11//! against the enclosing surface.
12//! * A `/`-separated path (`navigate("mail/inbox/msg-42")`) is absolute: the first segment
13//! anchors at the outermost surface that recognizes it, every surface inside the anchor is
14//! reset to its root, and the remaining segments are consumed inward, including by
15//! surfaces that only mount as the outer switch takes effect (a pending queue hands each
16//! newly registered surface the next segment).
17//!
18//! A trailing `?name=value&…` query carries [`route_params`] to the destination builders.
19//! [`current_route`] reports the full path (every mounted surface's contribution, outermost
20//! to innermost), so persisting navigation is `save(current_route())` + `navigate(&saved)`.
21
22use std::cell::{Cell, RefCell};
23use std::rc::Rc;
24
25/// A mounted host's control surface. Closures run user code (route builders), so the registry
26/// Never holds a borrow across a call (§3.3 discipline: clone the `Rc` out, then call).
27pub struct NavController {
28 /// Push (or, in split/tab presentation, select) a registered route. False = unknown route.
29 pub push: Box<dyn Fn(&str) -> bool>,
30 /// Pop the top route. `already_popped` = the native side popped first (iOS back).
31 /// False = nothing to pop (tabs hosts always return false: they have no stack).
32 pub pop: Box<dyn Fn(bool) -> bool>,
33 /// Current route path ("" while showing the root).
34 pub current: Box<dyn Fn() -> String>,
35 /// Consume one segment of an absolute path. Selectors/tabs accept a declared key (same as
36 /// `push`); a `nav_stack` accepts any segment by pushing it (its destinations are open-ended).
37 /// Distinct from `push` so a relative `navigate("key")` can still fall through a stack.
38 pub enter: Box<dyn Fn(&str) -> bool>,
39 /// This surface's contribution to the full route: `[]` at root, `[key]` for a nav host /
40 /// tabs, the whole path for a stack.
41 pub segments: Box<dyn Fn() -> Vec<String>>,
42 /// How deeply this surface is nested: 0 for one mounted at the window root, 1 for one built
43 /// inside another surface's page, and so on.
44 ///
45 /// An absolute path walks outermost-inward, and registration order is not that order: a
46 /// nav host builds its pages before registering itself, so a stack inside a page registers
47 /// first. Ordering the descent by depth instead of by position is what lets `section/detail`
48 /// find the stack no matter which of the two registered first.
49 pub depth: usize,
50 /// Presentation order of the containing fullscreen cover; zero for ordinary pages.
51 /// A cover and its descendants receive Back before navigation underneath it.
52 pub layer: Box<dyn Fn() -> u64>,
53 /// Whether this surface is on screen: false for one sitting inside a resident page its host
54 /// is not currently showing.
55 ///
56 /// A tab bar or a rail keeps every page it has built alive (docs/navigation.md), so the nav
57 /// surfaces inside the tabs the user is not looking at stay registered. Without this they
58 /// contribute their segments to [`current_route`] just like the visible ones, and the route
59 /// reads as the concatenation of every tab's state. Registration cannot answer the question
60 /// once and for all (which page is showing changes after the build), so it is a predicate.
61 pub active: Box<dyn Fn() -> bool>,
62}
63
64/// Opaque handle from [`register_nav`]; a nested host calls [`unregister_nav`] on dispose.
65#[derive(Clone, Copy, PartialEq, Eq, Debug)]
66pub struct NavToken(u64);
67
68day_reactive::tls_slots! {
69 nav;
70 static NAV_STACK: RefCell<Vec<(NavToken, Rc<NavController>)>> =
71 const { RefCell::new(Vec::new()) };
72 static NEXT_TOKEN: Cell<u64> = const { Cell::new(1) };
73 /// A launch deep link recorded by the platform entry before `launch_with` runs: the entry
74 /// point for hosts with no process environment (web-dom seeds it from the page's URL hash). The
75 /// `DAY_DEEPLINK` environment variable, where one exists, still wins.
76 static LAUNCH_DEEPLINK: RefCell<Option<String>> = const { RefCell::new(None) };
77 /// Query params of the most recent `navigate()` (empty between navigations). Destination
78 /// builders read them via [`route_params`] while their route is being entered.
79 static PARAMS: RefCell<Rc<Vec<(String, String)>>> = RefCell::new(Rc::new(Vec::new()));
80 /// Absolute-path segments not yet consumed: surfaces that mount during the navigation
81 /// cascade take the front segment(s) as they register (see [`register_nav`]).
82 static PENDING: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
83 /// The app-installed persistence sink a nav surface's `.restore` reads and writes through
84 /// ([`set_nav_store`]). `None` = no store installed, so `.restore` is a no-op.
85 static NAV_STORE: RefCell<Option<Rc<dyn NavStore>>> = const { RefCell::new(None) };
86
87 static NAV_OBSERVER: RefCell<Option<Rc<NavObserver>>> = const { RefCell::new(None) };
88 /// The last route the observer was told about; dedups the several call sites below (a single
89 /// user navigation reaches `maybe_notify_route_change` from both the event-pump tail and the
90 /// imperative `navigate` tail; only the first, changing, call fires).
91 static LAST_ROUTE: RefCell<String> = const { RefCell::new(String::new()) };
92}
93
94/// Install a controller (innermost = last). Returns its token. The root `nav()` registers once
95/// and never unregisters; nested hosts (`tabs()` in a route) unregister when their scope disposes.
96///
97/// If an absolute navigation left unconsumed segments, the new surface consumes as many leading
98/// ones as it accepts; this is how `navigate("mail/inbox/msg-42")` reaches a stack that only
99/// mounts once the "mail" switch has taken effect.
100pub fn register_nav(ctrl: NavController) -> NavToken {
101 let token = NEXT_TOKEN.with(|c| {
102 let t = c.get();
103 c.set(t + 1);
104 NavToken(t)
105 });
106 let ctrl = Rc::new(ctrl);
107 NAV_STACK.with(|s| s.borrow_mut().push((token, ctrl.clone())));
108 // A newly mounted surface can retain the path signal from an earlier visit. An
109 // absolute destination replaces that path just as it does for a resident surface;
110 // otherwise register-time delivery appends before navigate_absolute can reset it.
111 // With no pending destination, a bare reopen keeps the remembered path.
112 if PENDING.with(|p| !p.borrow().is_empty()) {
113 let _ = (ctrl.push)("");
114 }
115 // Feed pending absolute segments to the just-mounted surface (front-first, stop at the
116 // first refusal, since deeper segments wait for deeper surfaces).
117 while let Some(front) = PENDING.with(|p| p.borrow().first().cloned()) {
118 if !(ctrl.enter)(&front) {
119 break;
120 }
121 // `enter` may itself have consumed the segment: a push that mounts a surface
122 // registers it, and `register_nav` takes pending segments as it does. Only drop
123 // the head when it is still there.
124 PENDING.with(|p| {
125 let mut p = p.borrow_mut();
126 if !p.is_empty() {
127 p.remove(0);
128 }
129 });
130 }
131 token
132}
133
134/// Remove a controller whose host was disposed. No-op if already gone.
135pub fn unregister_nav(token: NavToken) {
136 NAV_STACK.with(|s| s.borrow_mut().retain(|(t, _)| *t != token));
137}
138
139/// Drop every controller: a fresh mount / test boot (called from tree install/uninstall).
140pub fn clear_controllers() {
141 NAV_STACK.with(|s| s.borrow_mut().clear());
142 NEXT_TOKEN.with(|c| c.set(1));
143 PENDING.with(|p| p.borrow_mut().clear());
144 PARAMS.with(|p| *p.borrow_mut() = Rc::new(Vec::new()));
145}
146
147/// Dispatch innermost→outermost; the first controller that returns true wins. Controllers are
148/// `Rc`-cloned out of the stack before the call, so their closures (which re-enter the tree and
149/// may register/unregister hosts) never run while the stack is borrowed (§3.3).
150///
151/// Presented covers and their descendants come before the covered window. Within a presentation
152/// layer, "innermost" is decided by [`NavController::depth`], not by registration order: the mirror of
153/// [`nested_after`]'s outermost-first rule, and for the same reason: a host registers after
154/// building its pages, so the registry places it after the surfaces those pages contain. Asked in
155/// reverse-registration order, a tab bar would answer a back before the stack pushed inside its
156/// page ever heard it (which is how a phone's `nav_back` cleared the tab selection and stranded
157/// the pushed detail in the tree). The sort is stable over the reversed list, so equal depths
158/// keep newest-registration-first, which is what sibling surfaces want.
159fn dispatch(f: impl Fn(&NavController) -> bool) -> bool {
160 let mut controllers: Vec<Rc<NavController>> =
161 NAV_STACK.with(|s| s.borrow().iter().rev().map(|(_, c)| c.clone()).collect());
162 controllers.sort_by_key(|c| std::cmp::Reverse(((c.layer)(), c.depth)));
163 for c in controllers {
164 if (c.active)() && f(&c) {
165 return true;
166 }
167 }
168 false
169}
170
171// ---------------------------------------------------------------------------
172// Route strings (docs/navigation.md): `seg/seg/seg?name=value&name2=value2`
173// ---------------------------------------------------------------------------
174
175/// Split a route string into its path segments and query params. Segments and param
176/// names/values are percent-decoded (`%2F` → `/`, …); everything else is taken literally.
177pub fn parse_route(route: &str) -> (Vec<String>, Vec<(String, String)>) {
178 let (path, query) = match route.split_once('?') {
179 Some((p, q)) => (p, q),
180 None => (route, ""),
181 };
182 let segments: Vec<String> = path
183 .split('/')
184 .filter(|s| !s.is_empty())
185 .map(percent_decode)
186 .collect();
187 let params: Vec<(String, String)> = query
188 .split('&')
189 .filter(|s| !s.is_empty())
190 .map(|pair| match pair.split_once('=') {
191 Some((k, v)) => (percent_decode(k), percent_decode(v)),
192 None => (percent_decode(pair), String::new()),
193 })
194 .collect();
195 (segments, params)
196}
197
198/// Assemble a route string from segments and params, the inverse of [`parse_route`].
199/// Reserved characters (`/`, `?`, `&`, `=`, `%`) in segments and params are percent-encoded.
200pub fn encode_route(segments: &[String], params: &[(String, String)]) -> String {
201 let mut out = segments
202 .iter()
203 .map(|s| percent_encode(s))
204 .collect::<Vec<_>>()
205 .join("/");
206 if !params.is_empty() {
207 out.push('?');
208 out.push_str(
209 ¶ms
210 .iter()
211 .map(|(k, v)| format!("{}={}", percent_encode(k), percent_encode(v)))
212 .collect::<Vec<_>>()
213 .join("&"),
214 );
215 }
216 out
217}
218
219fn percent_decode(s: &str) -> String {
220 let bytes = s.as_bytes();
221 let mut out = Vec::with_capacity(bytes.len());
222 let mut i = 0;
223 while i < bytes.len() {
224 if bytes[i] == b'%'
225 && i + 2 < bytes.len() + 1
226 && let (Some(h), Some(l)) = (
227 bytes.get(i + 1).and_then(|b| (*b as char).to_digit(16)),
228 bytes.get(i + 2).and_then(|b| (*b as char).to_digit(16)),
229 )
230 {
231 out.push((h * 16 + l) as u8);
232 i += 3;
233 } else {
234 out.push(bytes[i]);
235 i += 1;
236 }
237 }
238 String::from_utf8_lossy(&out).into_owned()
239}
240
241fn percent_encode(s: &str) -> String {
242 let mut out = String::with_capacity(s.len());
243 for b in s.bytes() {
244 match b {
245 b'/' | b'?' | b'&' | b'=' | b'%' => out.push_str(&format!("%{b:02X}")),
246 _ => out.push(b as char),
247 }
248 }
249 out
250}
251
252/// The query params carried by the most recent [`navigate`] call (`?name=value&…`). Read them
253/// inside a destination builder: `route_param("id")`. They describe the navigation in flight;
254/// a push you perform by writing a path signal directly carries its data in your own state
255/// instead (docs/navigation.md).
256pub fn route_params() -> Rc<Vec<(String, String)>> {
257 PARAMS.with(|p| p.borrow().clone())
258}
259
260/// The value of one query param of the most recent [`navigate`] (`None` = not present).
261pub fn route_param(name: &str) -> Option<String> {
262 route_params()
263 .iter()
264 .find(|(k, _)| k == name)
265 .map(|(_, v)| v.clone())
266}
267
268/// Record the host's launch deep link before `launch_with` runs (docs/navigation.md).
269/// Platform glue only; apps navigate with [`navigate`]. `DAY_DEEPLINK`, where a process
270/// environment exists, takes precedence.
271///
272/// UI thread only: the slot is thread-local, because the web host that seeds it runs on the one
273/// thread there is. Glue that may be called from another thread (a notification tap arriving on a
274/// JNI or delegate thread) wants [`request_route`], which is thread-safe and works at any
275/// lifecycle stage.
276pub fn set_launch_deeplink(route: &str) {
277 LAUNCH_DEEPLINK.with(|l| *l.borrow_mut() = Some(route.to_string()));
278}
279
280/// A route requested from outside the reactive turn, at any lifecycle stage, from any thread:
281/// the rail a notification tap navigates through (docs/notify.md).
282///
283/// This is process-global rather than thread-local ([`LAUNCH_DEEPLINK`] is the latter) because the
284/// caller is usually not on the UI thread: Android delivers a tap on a JNI thread and Apple on a
285/// delegate callback, and both can arrive before `launch_with` has run at all.
286static REQUESTED_ROUTE: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
287
288fn requested_slot() -> std::sync::MutexGuard<'static, Option<String>> {
289 // A panic while holding this lock would otherwise wedge every later navigation; the buffer is
290 // one Option<String>, so recovering the value is always safe.
291 REQUESTED_ROUTE.lock().unwrap_or_else(|e| e.into_inner())
292}
293
294/// Ask the app to navigate, from anywhere, at any time.
295///
296/// Cold start (a tap that launches the process) and warm tap (the app is already running) are the
297/// same call: before launch the route is buffered and `launch_with` applies it after the first
298/// mount, so it lands once routes actually exist; after launch it is applied on the UI thread.
299/// An empty route is ignored, and the newest request wins: a user who taps twice gets the second
300/// destination.
301pub fn request_route(route: &str) {
302 if route.is_empty() {
303 return;
304 }
305 *requested_slot() = Some(route.to_string());
306 // Before a backend installs the poster there is no UI thread to post to; the launch drain
307 // picks the buffer up instead.
308 if day_reactive::has_main_poster() {
309 day_reactive::on_main(|| {
310 if let Some(route) = take_requested_route() {
311 apply_route_request(&route);
312 }
313 });
314 }
315}
316
317/// Take the buffered route, if any. `launch_with` drains it after the first mount.
318pub(crate) fn take_requested_route() -> Option<String> {
319 requested_slot().take()
320}
321
322/// Apply a route the backend or a part asked for. Echoes of our own `set_route` match the current
323/// route and are dropped.
324pub(crate) fn apply_route_request(route: &str) {
325 if current_route().as_deref() != Some(route) && !navigate(route) {
326 log::warn!("requested route {route:?} did not match");
327 }
328}
329
330/// Whether a launch deep link is pending (`DAY_DEEPLINK` or a platform hint). A nav surface's
331/// `.restore` reads this so a deep link wins over restored state (docs/navigation.md).
332pub fn has_launch_deeplink() -> bool {
333 launch_deeplink().is_some()
334}
335
336/// A key/value sink a nav surface's `.restore` persists its state through, so navigation
337/// survives a relaunch or an Android process death (docs/navigation.md). `day::launch` installs
338/// the preferences store (`day_part_preferences::install_nav_store`) unless the app installed one
339/// first; an app marks the surfaces it wants remembered with `.restore(key)`. With no store installed,
340/// `.restore` is a silent no-op, so a surface's `.restore` call never fails to build.
341///
342/// Keys are opaque to day-core and namespaced by the surface's `.restore(key)` argument; the
343/// store implementation is responsible for keeping them clear of the app's own data.
344pub trait NavStore {
345 /// The last value saved under `key`, or `None` if nothing was stored (first launch).
346 fn load(&self, key: &str) -> Option<String>;
347 /// Persist `value` under `key`, replacing any prior value.
348 fn save(&self, key: &str, value: &str);
349}
350
351/// Install the app's navigation persistence store (docs/navigation.md). Call once at startup,
352/// before the UI mounts, so a `.restore` surface reads it on first build. A later call replaces
353/// the store.
354pub fn set_nav_store(store: Rc<dyn NavStore>) {
355 NAV_STORE.with(|s| *s.borrow_mut() = Some(store));
356}
357
358/// Whether a [`NavStore`] is installed. `day::launch` installs the preferences store when the
359/// app has not installed one of its own.
360pub fn has_nav_store() -> bool {
361 NAV_STORE.with(|s| s.borrow().is_some())
362}
363
364/// Read a `.restore` key through the installed [`NavStore`] (`None` if none is installed or the
365/// key was never saved).
366pub fn nav_store_load(key: &str) -> Option<String> {
367 match NAV_STORE.with(|s| s.borrow().clone()) {
368 Some(st) => st.load(key),
369 None => {
370 warn_no_nav_store(key);
371 None
372 }
373 }
374}
375
376/// Debug-build diagnostic, once per process: a surface asked to `.restore(key)` while no
377/// [`NavStore`] is installed persists nothing, and does so silently: the surface still builds,
378/// the app still runs, and the setting never survives a relaunch. That follows from the
379/// design (`.restore` must never fail to build), but it reads as a bug, so say it out loud where
380/// a developer will see it.
381fn warn_no_nav_store(key: &str) {
382 if !cfg!(debug_assertions) {
383 return;
384 }
385 thread_local! {
386 // Android-only false positive, as with day-spec's `APP_TEMP_DIR`: already a `const` block.
387 #[allow(clippy::missing_const_for_thread_local)]
388 static WARNED: Cell<bool> = const { Cell::new(false) };
389 }
390 WARNED.with(|w| {
391 if !w.replace(true) {
392 log::warn!(
393 ".restore({key:?}) has no NavStore installed — navigation state will not \
394 persist. `day::launch` installs the preferences store unless the `day` \
395 crate's `prefs` feature is off; otherwise call `day_core::set_nav_store` \
396 before the UI mounts (docs/navigation.md)."
397 );
398 }
399 });
400}
401
402/// Write a `.restore` key through the installed [`NavStore`] (a no-op if none is installed).
403pub fn nav_store_save(key: &str, value: &str) {
404 if let Some(st) = NAV_STORE.with(|s| s.borrow().clone()) {
405 st.save(key, value);
406 }
407}
408
409/// The launch deep link: the `DAY_DEEPLINK` environment variable, else the platform's
410/// [`set_launch_deeplink`] hint. Consumed by `launch_with` after the first mount.
411pub(crate) fn launch_deeplink() -> Option<String> {
412 std::env::var("DAY_DEEPLINK")
413 .ok()
414 .filter(|r| !r.is_empty())
415 // A route buffered by `request_route` before launch: a notification tap that cold-started
416 // the process. Peeked, not taken, so `has_launch_deeplink()` keeps answering true for a
417 // nav surface's `.restore` (a tap must beat restored state); `launch_with` takes it.
418 .or_else(|| requested_slot().clone())
419 .or_else(|| LAUNCH_DEEPLINK.with(|l| l.borrow().clone()))
420 .filter(|r| !r.is_empty())
421}
422
423/// Navigate to a route (docs/navigation.md).
424///
425/// * `""`: pop the innermost stack to its root (falls through outward).
426/// * A single key is relative: innermost surface first, falling through outward.
427/// * `a/b/c` is absolute: anchor at the outermost surface that knows `a`, reset every surface
428/// inside the anchor to its root, then feed `b`, `c`, … inward (surfaces that mount during
429/// the cascade consume the rest as they register).
430/// * A trailing `?name=value&…` carries [`route_params`] to the destination builders.
431///
432/// False = no mounted surface recognized the (first) segment.
433pub fn navigate(route: &str) -> bool {
434 let (segments, params) = parse_route(route);
435 PENDING.with(|p| p.borrow_mut().clear());
436 PARAMS.with(|p| *p.borrow_mut() = Rc::new(params));
437 let ok = match segments.len() {
438 0 => dispatch(|nav| (nav.push)("")),
439 1 => dispatch(|nav| (nav.push)(&segments[0])),
440 _ => navigate_absolute(&segments),
441 };
442 maybe_notify_route_change();
443 ok
444}
445
446/// The surfaces inside `list[anchor]`, outermost first.
447///
448/// Ordered by [`NavController::depth`] rather than by registration, because a host that builds its
449/// pages before registering itself lands in the registry after what those pages contain, so an
450/// absolute `section/detail` would otherwise anchor on the section and find nothing beyond it.
451/// Depth only orders here, never filters: a subtree rebuilt outside its original build scope (a
452/// `when` arm re-derived on a size-class change) registers with a depth of zero, and equal depths
453/// keep registration order, which is the right answer in that case.
454fn nested_after(list: &[Rc<NavController>], anchor: usize) -> Vec<Rc<NavController>> {
455 let mut order: Vec<usize> = (0..list.len()).collect();
456 order.sort_by_key(|i| list[*i].depth);
457 let at = order.iter().position(|i| *i == anchor).unwrap_or(0);
458 order[at + 1..].iter().map(|i| list[*i].clone()).collect()
459}
460
461/// Anchor + descend for a multi-segment path. See [`navigate`].
462///
463/// Signal writes may propagate synchronously (an un-batched set cascades immediately), so the
464/// surfaces an anchor switch mounts can register, and must find their segments waiting,
465/// before the anchoring `push` even returns. Hence: queue the tail first, then anchor.
466fn navigate_absolute(segments: &[String]) -> bool {
467 let snapshot = || -> Vec<Rc<NavController>> {
468 NAV_STACK.with(|s| s.borrow().iter().map(|(_, c)| c.clone()).collect())
469 };
470 let controllers = snapshot();
471 let first = &segments[0];
472
473 // Already anchored: some surface is showing `first`. Reset everything inside it to its
474 // root (innermost-first, so stacks pop cleanly), then feed the remaining segments to the
475 // surviving inner surfaces in nesting order. Consult the live registry after the resets,
476 // because a reset can dispose deeper surfaces (a popped page takes its sub-surfaces with it).
477 if let Some(anchor) = controllers.iter().position(|c| (c.current)() == *first) {
478 PENDING.with(|p| *p.borrow_mut() = segments[1..].to_vec());
479 for c in nested_after(&controllers, anchor).iter().rev() {
480 let _ = (c.push)("");
481 }
482 let live = snapshot();
483 if let Some(anchor) = live.iter().position(|c| (c.current)() == *first) {
484 for c in nested_after(&live, anchor) {
485 while let Some(front) = PENDING.with(|p| p.borrow().first().cloned()) {
486 if !(c.enter)(&front) {
487 break;
488 }
489 // `enter` may itself have consumed the segment: a push that mounts a surface
490 // registers it, and `register_nav` takes pending segments as it does. Only drop
491 // the head when it is still there.
492 PENDING.with(|p| {
493 let mut p = p.borrow_mut();
494 if !p.is_empty() {
495 p.remove(0);
496 }
497 });
498 }
499 }
500 }
501 return true;
502 }
503
504 // Switching: queue the tail so surfaces that mount during the (possibly synchronous)
505 // cascade consume it as they register, then anchor at the outermost surface that accepts
506 // the first segment.
507 PENDING.with(|p| *p.borrow_mut() = segments[1..].to_vec());
508 for c in controllers.iter() {
509 if (c.push)(first) {
510 // Tabs may have already mounted the destination's nested surfaces. They will not
511 // register again on selection, so deliver the remaining segments here as well.
512 // Lazily mounted surfaces still consume the tail in `register_nav`.
513 let live = snapshot();
514 if let Some(anchor) = live.iter().position(|nav| Rc::ptr_eq(nav, c)) {
515 let nested: Vec<Rc<NavController>> = nested_after(&live, anchor)
516 .into_iter()
517 .filter(|nav| (nav.active)())
518 .collect();
519 // An explicit deeper path is the nested surfaces' whole state (set-semantics,
520 // as in the anchored branch above): a resident stack that still holds the
521 // pages of an earlier visit pops to its root before the tail lands, so
522 // `settings/general` followed later by `settings/privacy` reads
523 // `settings/privacy`, not `settings/general/privacy`. A bare section switch
524 // leaves what the user had open.
525 if PENDING.with(|p| !p.borrow().is_empty()) {
526 for child in nested.iter().rev() {
527 let _ = (child.push)("");
528 }
529 }
530 for child in nested {
531 while let Some(front) = PENDING.with(|p| p.borrow().first().cloned()) {
532 if !(child.enter)(&front) {
533 break;
534 }
535 // `enter` may itself have consumed the segment: a push that mounts a surface
536 // registers it, and `register_nav` takes pending segments as it does. Only drop
537 // the head when it is still there.
538 PENDING.with(|p| {
539 let mut p = p.borrow_mut();
540 if !p.is_empty() {
541 p.remove(0);
542 }
543 });
544 }
545 }
546 }
547 return true;
548 }
549 }
550 PENDING.with(|p| p.borrow_mut().clear());
551 false
552}
553
554/// Pop one level, day-initiated (the toolkit presents the pop). Native-initiated pops arrive as
555/// `Event::NavBack` and go through the owning host's `pop` directly.
556pub fn nav_back() -> bool {
557 let ok = dispatch(|nav| (nav.pop)(false));
558 maybe_notify_route_change();
559 ok
560}
561
562/// A navigation observer (§14.6): called with the new full route each time it changes, from any
563/// source: imperative `navigate`/`nav_back`, a nav_link or sidebar row (which navigate from an
564/// event handler, bypassing the event observer), a stack push, or a native back gesture. `""` is
565/// the root. Installed by the recorder in day-script; day-core only fires it.
566pub type NavObserver = dyn Fn(&str, Option<&str>);
567
568/// Install (or clear, with `None`) the navigation observer. Resets the dedup baseline so the next
569/// change fires regardless of where recording started.
570pub fn set_nav_observer(observer: Option<Box<NavObserver>>) {
571 NAV_OBSERVER.with(|o| *o.borrow_mut() = observer.map(Rc::from));
572 // Seed the baseline with the normalized route (an unmounted surface is `None` == `""`), so the
573 // first real navigation, not the None→"" transition at install, is what fires.
574 LAST_ROUTE.with(|r| *r.borrow_mut() = current_route().unwrap_or_default());
575}
576
577/// Announce a navigation to `route` from its source: a nav host (the sidebar nav host) calls
578/// this the instant it changes the bound selection, so the observer sees it synchronously instead
579/// of waiting for the route to settle into `NAV_STACK` a frame later (which `maybe_notify_route_change`
580/// reads). Deduped like the pump-boundary path. `route` is the host's local key, which replays as a
581/// relative `navigate` (innermost-first), correct for both a top-level sidebar and a nested one.
582pub fn note_navigation(route: &str, label: Option<&str>) {
583 let changed = LAST_ROUTE.with(|r| {
584 let mut r = r.borrow_mut();
585 if *r == route {
586 false
587 } else {
588 *r = route.to_string();
589 true
590 }
591 });
592 if !changed {
593 return;
594 }
595 let observer = NAV_OBSERVER.with(|o| o.borrow().clone());
596 if let Some(obs) = observer {
597 obs(route, label);
598 }
599}
600
601/// Fire the navigation observer if the full route changed since it was last told. Called at the
602/// tail of the event pump and of the imperative nav helpers; the dedup makes the redundant call a
603/// cheap no-op. The observer handle is cloned out first so it may re-enter (read the route, stop
604/// recording) without holding a borrow (mirrors the event observer in tree.rs).
605pub(crate) fn maybe_notify_route_change() {
606 let now = current_route().unwrap_or_default();
607 let changed = LAST_ROUTE.with(|r| {
608 let mut r = r.borrow_mut();
609 if *r == now {
610 false
611 } else {
612 *r = now.clone();
613 true
614 }
615 });
616 if !changed {
617 return;
618 }
619 let observer = NAV_OBSERVER.with(|o| o.borrow().clone());
620 if let Some(obs) = observer {
621 // No control context here (this is the settled-route fallback path); the source-notifying
622 // `note_navigation` supplies a label when a nav host has one.
623 obs(&now, None);
624 }
625}
626
627/// The full current route: every mounted surface's contribution, outermost to innermost,
628/// `/`-joined (docs/navigation.md). `None` = no surface mounted; `Some("")` = everything at
629/// its root. Round-trips through [`navigate`], so persisting navigation state is
630/// `save(current_route())` on the way out and `navigate(&saved)` on the way back in.
631pub fn current_route() -> Option<String> {
632 let controllers: Vec<Rc<NavController>> =
633 NAV_STACK.with(|s| s.borrow().iter().map(|(_, c)| c.clone()).collect());
634 if controllers.is_empty() {
635 return None;
636 }
637 // Outermost first, which is nesting order rather than registration order: a host that builds
638 // its pages before registering itself lands after what those pages contain
639 // (`NavController::depth`). Ties keep registration order, which is what siblings want.
640 let mut ordered = controllers;
641 ordered.sort_by_key(|c| c.depth);
642 let mut parts: Vec<String> = Vec::new();
643 for c in ordered {
644 // Only what is on screen. A resident page the host is not showing keeps its surfaces
645 // registered, and joining those in would append every unselected tab's state to the route.
646 if !(c.active)() {
647 continue;
648 }
649 parts.extend((c.segments)());
650 }
651 Some(encode_route(&parts, &[]))
652}
653
654#[cfg(test)]
655mod tests {
656 use super::*;
657
658 #[test]
659 fn route_parsing_round_trips() {
660 let (segs, params) = parse_route("mail/inbox/msg-42?hint=linked&x=1");
661 assert_eq!(segs, vec!["mail", "inbox", "msg-42"]);
662 assert_eq!(
663 params,
664 vec![("hint".into(), "linked".into()), ("x".into(), "1".into())]
665 );
666 assert_eq!(
667 encode_route(&segs, ¶ms),
668 "mail/inbox/msg-42?hint=linked&x=1"
669 );
670
671 // Reserved characters survive a round trip.
672 let segs = vec!["a/b".to_string()];
673 let params = vec![("q".to_string(), "1&2=3".to_string())];
674 let encoded = encode_route(&segs, ¶ms);
675 let (s2, p2) = parse_route(&encoded);
676 assert_eq!(s2, segs);
677 assert_eq!(p2, params);
678 }
679
680 #[test]
681 fn route_parsing_edge_cases() {
682 assert_eq!(parse_route(""), (vec![], vec![]));
683 assert_eq!(parse_route("a"), (vec!["a".to_string()], vec![]));
684 assert_eq!(parse_route("a//b").0, vec!["a", "b"]); // empty segments dropped
685 assert_eq!(
686 parse_route("?flag").1,
687 vec![("flag".to_string(), String::new())]
688 );
689 }
690
691 /// `REQUESTED_ROUTE` is process-global (it must be; see `request_route`), so the tests that
692 /// touch it cannot run in parallel with each other. Serialize them rather than making the
693 /// production type thread-local, which would defeat the purpose of the buffer.
694 static ROUTE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
695
696 fn route_test<T>(f: impl FnOnce() -> T) -> T {
697 let _guard = ROUTE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
698 let _ = take_requested_route();
699 let out = f();
700 let _ = take_requested_route();
701 out
702 }
703
704 /// The route-buffering assertions below describe the state a cold-start notification tap
705 /// arrives in: a process where NO backend has installed the main poster yet. They cannot run
706 /// beside the rest of this binary's tests.
707 ///
708 /// `day_reactive::install_main_poster` is a process-wide `OnceLock` that is never cleared, and
709 /// `present::task_tests` installs one: its executor wakers re-poll through `on_main`, so those
710 /// tests need it. Once installed, `request_route` takes its posting branch and the
711 /// inline poster runs `apply_route_request` immediately, draining the very buffer these
712 /// assertions are about. libtest gives no ordering guarantee between the two groups, so this
713 /// was a coin flip: the same commit passed on linux-aarch64 and failed on linux-x86_64.
714 ///
715 /// So they run in a child process. This test re-executes the test binary selecting only the
716 /// ignored test below, which is the whole set of assertions; nothing else runs there, so no
717 /// poster exists and the precondition holds by construction rather than by luck.
718 #[test]
719 fn route_buffering_before_launch() {
720 let exe = std::env::current_exe().expect("the running test binary has a path");
721 let out = std::process::Command::new(exe)
722 .args([
723 "--exact",
724 "--ignored",
725 "--nocapture",
726 "--test-threads=1",
727 "nav::tests::route_buffering_in_a_poster_free_process",
728 ])
729 .output()
730 .expect("re-run this test binary for the child case");
731 assert!(
732 out.status.success(),
733 "the poster-free route assertions failed in the child process:\n{}{}",
734 String::from_utf8_lossy(&out.stdout),
735 String::from_utf8_lossy(&out.stderr),
736 );
737 }
738
739 /// Every assertion that depends on no backend having started. Ignored so it runs only in the
740 /// child process [`route_buffering_before_launch`] spawns; see that test for why.
741 #[test]
742 #[ignore = "needs a process with no main poster; run by route_buffering_before_launch"]
743 fn route_buffering_in_a_poster_free_process() {
744 assert!(
745 !day_reactive::has_main_poster(),
746 "this test must run in a process where no backend installed the poster; \
747 it is meaningless otherwise, so failing loudly beats passing vacuously",
748 );
749
750 // A tap that cold-starts the process buffers a route, and the launch path sees it.
751 // Without this, `launch_deeplink()` would miss it and the app would open on its default
752 // screen.
753 route_test(|| {
754 assert_eq!(launch_deeplink(), None);
755 request_route("clock/timer");
756 assert_eq!(launch_deeplink().as_deref(), Some("clock/timer"));
757 // Peeked, not taken: a nav surface's `.restore` asks this so a tap beats restored state.
758 assert!(has_launch_deeplink());
759 assert_eq!(take_requested_route().as_deref(), Some("clock/timer"));
760 assert_eq!(take_requested_route(), None);
761 });
762
763 // The newest tap wins: a user who taps a second notification before launch completes gets
764 // the second destination, not the first.
765 route_test(|| {
766 request_route("mail/inbox");
767 request_route("clock/alarm");
768 assert_eq!(take_requested_route().as_deref(), Some("clock/alarm"));
769 });
770
771 // An empty route is not a navigation request: `navigate("")` means "pop to root", which a
772 // missing intent extra must never trigger.
773 route_test(|| {
774 request_route("");
775 assert_eq!(take_requested_route(), None);
776 });
777
778 // `request_route` must not panic when no backend has started, which is exactly the state a
779 // cold-start notification tap arrives in (`on_main` panics without a poster).
780 route_test(|| {
781 request_route("some/route"); // would panic if it posted unconditionally
782 let _ = take_requested_route();
783 });
784 }
785}