Skip to main content

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            &params
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, &params),
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, &params);
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}