day-core 0.6.1

Declarative app development API using native UI toolkits
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
// Copyright © The Daybrite Project
// SPDX-License-Identifier: MPL-2.0

//! Route registry (docs/navigation.md): every mounted `nav()` / `nav_stack()` host
//! register a controller here. Registrations form a stack so hosts can nest (e.g. a `tabs()`
//! inside a `nav()` route), and the stack order is the nesting order (outermost first).
//!
//! Two addressing modes (docs/navigation.md):
//!   * A single key (`navigate("inbox")`) is relative: tried innermost-first, falling through
//!     outward. A tab key selects the tab; a key the tabs host doesn't know still resolves
//!     against the enclosing surface.
//!   * A `/`-separated path (`navigate("mail/inbox/msg-42")`) is absolute: the first segment
//!     anchors at the outermost surface that recognizes it, every surface inside the anchor is
//!     reset to its root, and the remaining segments are consumed inward, including by
//!     surfaces that only mount as the outer switch takes effect (a pending queue hands each
//!     newly registered surface the next segment).
//!
//! A trailing `?name=value&…` query carries [`route_params`] to the destination builders.
//! [`current_route`] reports the full path (every mounted surface's contribution, outermost
//! to innermost), so persisting navigation is `save(current_route())` + `navigate(&saved)`.

use std::cell::{Cell, RefCell};
use std::rc::Rc;

/// A mounted host's control surface. Closures run user code (route builders), so the registry
/// Never holds a borrow across a call (§3.3 discipline: clone the `Rc` out, then call).
pub struct NavController {
    /// Push (or, in split/tab presentation, select) a registered route. False = unknown route.
    pub push: Box<dyn Fn(&str) -> bool>,
    /// Pop the top route. `already_popped` = the native side popped first (iOS back).
    /// False = nothing to pop (tabs hosts always return false: they have no stack).
    pub pop: Box<dyn Fn(bool) -> bool>,
    /// Current route path ("" while showing the root).
    pub current: Box<dyn Fn() -> String>,
    /// Consume one segment of an absolute path. Selectors/tabs accept a declared key (same as
    /// `push`); a `nav_stack` accepts any segment by pushing it (its destinations are open-ended).
    /// Distinct from `push` so a relative `navigate("key")` can still fall through a stack.
    pub enter: Box<dyn Fn(&str) -> bool>,
    /// This surface's contribution to the full route: `[]` at root, `[key]` for a nav host /
    /// tabs, the whole path for a stack.
    pub segments: Box<dyn Fn() -> Vec<String>>,
    /// How deeply this surface is nested: 0 for one mounted at the window root, 1 for one built
    /// inside another surface's page, and so on.
    ///
    /// An absolute path walks outermost-inward, and registration order is not that order: a
    /// nav host builds its pages before registering itself, so a stack inside a page registers
    /// first. Ordering the descent by depth instead of by position is what lets `section/detail`
    /// find the stack no matter which of the two registered first.
    pub depth: usize,
    /// Presentation order of the containing fullscreen cover; zero for ordinary pages.
    /// A cover and its descendants receive Back before navigation underneath it.
    pub layer: Box<dyn Fn() -> u64>,
    /// Whether this surface is on screen: false for one sitting inside a resident page its host
    /// is not currently showing.
    ///
    /// A tab bar or a rail keeps every page it has built alive (docs/navigation.md), so the nav
    /// surfaces inside the tabs the user is not looking at stay registered. Without this they
    /// contribute their segments to [`current_route`] just like the visible ones, and the route
    /// reads as the concatenation of every tab's state. Registration cannot answer the question
    /// once and for all (which page is showing changes after the build), so it is a predicate.
    pub active: Box<dyn Fn() -> bool>,
}

/// Opaque handle from [`register_nav`]; a nested host calls [`unregister_nav`] on dispose.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct NavToken(u64);

day_reactive::tls_slots! {
    nav;
    static NAV_STACK: RefCell<Vec<(NavToken, Rc<NavController>)>> =
        const { RefCell::new(Vec::new()) };
    static NEXT_TOKEN: Cell<u64> = const { Cell::new(1) };
    /// A launch deep link recorded by the platform entry before `launch_with` runs: the entry
    /// point for hosts with no process environment (web-dom seeds it from the page's URL hash). The
    /// `DAY_DEEPLINK` environment variable, where one exists, still wins.
    static LAUNCH_DEEPLINK: RefCell<Option<String>> = const { RefCell::new(None) };
    /// Query params of the most recent `navigate()` (empty between navigations). Destination
    /// builders read them via [`route_params`] while their route is being entered.
    static PARAMS: RefCell<Rc<Vec<(String, String)>>> = RefCell::new(Rc::new(Vec::new()));
    /// Absolute-path segments not yet consumed: surfaces that mount during the navigation
    /// cascade take the front segment(s) as they register (see [`register_nav`]).
    static PENDING: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
    /// The app-installed persistence sink a nav surface's `.restore` reads and writes through
    /// ([`set_nav_store`]). `None` = no store installed, so `.restore` is a no-op.
    static NAV_STORE: RefCell<Option<Rc<dyn NavStore>>> = const { RefCell::new(None) };

    static NAV_OBSERVER: RefCell<Option<Rc<NavObserver>>> = const { RefCell::new(None) };
    /// The last route the observer was told about; dedups the several call sites below (a single
    /// user navigation reaches `maybe_notify_route_change` from both the event-pump tail and the
    /// imperative `navigate` tail; only the first, changing, call fires).
    static LAST_ROUTE: RefCell<String> = const { RefCell::new(String::new()) };
}

/// Install a controller (innermost = last). Returns its token. The root `nav()` registers once
/// and never unregisters; nested hosts (`tabs()` in a route) unregister when their scope disposes.
///
/// If an absolute navigation left unconsumed segments, the new surface consumes as many leading
/// ones as it accepts; this is how `navigate("mail/inbox/msg-42")` reaches a stack that only
/// mounts once the "mail" switch has taken effect.
pub fn register_nav(ctrl: NavController) -> NavToken {
    let token = NEXT_TOKEN.with(|c| {
        let t = c.get();
        c.set(t + 1);
        NavToken(t)
    });
    let ctrl = Rc::new(ctrl);
    NAV_STACK.with(|s| s.borrow_mut().push((token, ctrl.clone())));
    // A newly mounted surface can retain the path signal from an earlier visit. An
    // absolute destination replaces that path just as it does for a resident surface;
    // otherwise register-time delivery appends before navigate_absolute can reset it.
    // With no pending destination, a bare reopen keeps the remembered path.
    if PENDING.with(|p| !p.borrow().is_empty()) {
        let _ = (ctrl.push)("");
    }
    // Feed pending absolute segments to the just-mounted surface (front-first, stop at the
    // first refusal, since deeper segments wait for deeper surfaces).
    while let Some(front) = PENDING.with(|p| p.borrow().first().cloned()) {
        if !(ctrl.enter)(&front) {
            break;
        }
        // `enter` may itself have consumed the segment: a push that mounts a surface
        // registers it, and `register_nav` takes pending segments as it does. Only drop
        // the head when it is still there.
        PENDING.with(|p| {
            let mut p = p.borrow_mut();
            if !p.is_empty() {
                p.remove(0);
            }
        });
    }
    token
}

/// Remove a controller whose host was disposed. No-op if already gone.
pub fn unregister_nav(token: NavToken) {
    NAV_STACK.with(|s| s.borrow_mut().retain(|(t, _)| *t != token));
}

/// Drop every controller: a fresh mount / test boot (called from tree install/uninstall).
pub fn clear_controllers() {
    NAV_STACK.with(|s| s.borrow_mut().clear());
    NEXT_TOKEN.with(|c| c.set(1));
    PENDING.with(|p| p.borrow_mut().clear());
    PARAMS.with(|p| *p.borrow_mut() = Rc::new(Vec::new()));
}

/// Dispatch innermost→outermost; the first controller that returns true wins. Controllers are
/// `Rc`-cloned out of the stack before the call, so their closures (which re-enter the tree and
/// may register/unregister hosts) never run while the stack is borrowed (§3.3).
///
/// Presented covers and their descendants come before the covered window. Within a presentation
/// layer, "innermost" is decided by [`NavController::depth`], not by registration order: the mirror of
/// [`nested_after`]'s outermost-first rule, and for the same reason: a host registers after
/// building its pages, so the registry places it after the surfaces those pages contain. Asked in
/// reverse-registration order, a tab bar would answer a back before the stack pushed inside its
/// page ever heard it (which is how a phone's `nav_back` cleared the tab selection and stranded
/// the pushed detail in the tree). The sort is stable over the reversed list, so equal depths
/// keep newest-registration-first, which is what sibling surfaces want.
fn dispatch(f: impl Fn(&NavController) -> bool) -> bool {
    let mut controllers: Vec<Rc<NavController>> =
        NAV_STACK.with(|s| s.borrow().iter().rev().map(|(_, c)| c.clone()).collect());
    controllers.sort_by_key(|c| std::cmp::Reverse(((c.layer)(), c.depth)));
    for c in controllers {
        if (c.active)() && f(&c) {
            return true;
        }
    }
    false
}

// ---------------------------------------------------------------------------
// Route strings (docs/navigation.md): `seg/seg/seg?name=value&name2=value2`
// ---------------------------------------------------------------------------

/// Split a route string into its path segments and query params. Segments and param
/// names/values are percent-decoded (`%2F` → `/`, …); everything else is taken literally.
pub fn parse_route(route: &str) -> (Vec<String>, Vec<(String, String)>) {
    let (path, query) = match route.split_once('?') {
        Some((p, q)) => (p, q),
        None => (route, ""),
    };
    let segments: Vec<String> = path
        .split('/')
        .filter(|s| !s.is_empty())
        .map(percent_decode)
        .collect();
    let params: Vec<(String, String)> = query
        .split('&')
        .filter(|s| !s.is_empty())
        .map(|pair| match pair.split_once('=') {
            Some((k, v)) => (percent_decode(k), percent_decode(v)),
            None => (percent_decode(pair), String::new()),
        })
        .collect();
    (segments, params)
}

/// Assemble a route string from segments and params, the inverse of [`parse_route`].
/// Reserved characters (`/`, `?`, `&`, `=`, `%`) in segments and params are percent-encoded.
pub fn encode_route(segments: &[String], params: &[(String, String)]) -> String {
    let mut out = segments
        .iter()
        .map(|s| percent_encode(s))
        .collect::<Vec<_>>()
        .join("/");
    if !params.is_empty() {
        out.push('?');
        out.push_str(
            &params
                .iter()
                .map(|(k, v)| format!("{}={}", percent_encode(k), percent_encode(v)))
                .collect::<Vec<_>>()
                .join("&"),
        );
    }
    out
}

fn percent_decode(s: &str) -> String {
    let bytes = s.as_bytes();
    let mut out = Vec::with_capacity(bytes.len());
    let mut i = 0;
    while i < bytes.len() {
        if bytes[i] == b'%'
            && i + 2 < bytes.len() + 1
            && let (Some(h), Some(l)) = (
                bytes.get(i + 1).and_then(|b| (*b as char).to_digit(16)),
                bytes.get(i + 2).and_then(|b| (*b as char).to_digit(16)),
            )
        {
            out.push((h * 16 + l) as u8);
            i += 3;
        } else {
            out.push(bytes[i]);
            i += 1;
        }
    }
    String::from_utf8_lossy(&out).into_owned()
}

fn percent_encode(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for b in s.bytes() {
        match b {
            b'/' | b'?' | b'&' | b'=' | b'%' => out.push_str(&format!("%{b:02X}")),
            _ => out.push(b as char),
        }
    }
    out
}

/// The query params carried by the most recent [`navigate`] call (`?name=value&…`). Read them
/// inside a destination builder: `route_param("id")`. They describe the navigation in flight;
/// a push you perform by writing a path signal directly carries its data in your own state
/// instead (docs/navigation.md).
pub fn route_params() -> Rc<Vec<(String, String)>> {
    PARAMS.with(|p| p.borrow().clone())
}

/// The value of one query param of the most recent [`navigate`] (`None` = not present).
pub fn route_param(name: &str) -> Option<String> {
    route_params()
        .iter()
        .find(|(k, _)| k == name)
        .map(|(_, v)| v.clone())
}

/// Record the host's launch deep link before `launch_with` runs (docs/navigation.md).
/// Platform glue only; apps navigate with [`navigate`]. `DAY_DEEPLINK`, where a process
/// environment exists, takes precedence.
///
/// UI thread only: the slot is thread-local, because the web host that seeds it runs on the one
/// thread there is. Glue that may be called from another thread (a notification tap arriving on a
/// JNI or delegate thread) wants [`request_route`], which is thread-safe and works at any
/// lifecycle stage.
pub fn set_launch_deeplink(route: &str) {
    LAUNCH_DEEPLINK.with(|l| *l.borrow_mut() = Some(route.to_string()));
}

/// A route requested from outside the reactive turn, at any lifecycle stage, from any thread:
/// the rail a notification tap navigates through (docs/notify.md).
///
/// This is process-global rather than thread-local ([`LAUNCH_DEEPLINK`] is the latter) because the
/// caller is usually not on the UI thread: Android delivers a tap on a JNI thread and Apple on a
/// delegate callback, and both can arrive before `launch_with` has run at all.
static REQUESTED_ROUTE: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);

fn requested_slot() -> std::sync::MutexGuard<'static, Option<String>> {
    // A panic while holding this lock would otherwise wedge every later navigation; the buffer is
    // one Option<String>, so recovering the value is always safe.
    REQUESTED_ROUTE.lock().unwrap_or_else(|e| e.into_inner())
}

/// Ask the app to navigate, from anywhere, at any time.
///
/// Cold start (a tap that launches the process) and warm tap (the app is already running) are the
/// same call: before launch the route is buffered and `launch_with` applies it after the first
/// mount, so it lands once routes actually exist; after launch it is applied on the UI thread.
/// An empty route is ignored, and the newest request wins: a user who taps twice gets the second
/// destination.
pub fn request_route(route: &str) {
    if route.is_empty() {
        return;
    }
    *requested_slot() = Some(route.to_string());
    // Before a backend installs the poster there is no UI thread to post to; the launch drain
    // picks the buffer up instead.
    if day_reactive::has_main_poster() {
        day_reactive::on_main(|| {
            if let Some(route) = take_requested_route() {
                apply_route_request(&route);
            }
        });
    }
}

/// Take the buffered route, if any. `launch_with` drains it after the first mount.
pub(crate) fn take_requested_route() -> Option<String> {
    requested_slot().take()
}

/// Apply a route the backend or a part asked for. Echoes of our own `set_route` match the current
/// route and are dropped.
pub(crate) fn apply_route_request(route: &str) {
    if current_route().as_deref() != Some(route) && !navigate(route) {
        log::warn!("requested route {route:?} did not match");
    }
}

/// Whether a launch deep link is pending (`DAY_DEEPLINK` or a platform hint). A nav surface's
/// `.restore` reads this so a deep link wins over restored state (docs/navigation.md).
pub fn has_launch_deeplink() -> bool {
    launch_deeplink().is_some()
}

/// A key/value sink a nav surface's `.restore` persists its state through, so navigation
/// survives a relaunch or an Android process death (docs/navigation.md). `day::launch` installs
/// the preferences store (`day_part_preferences::install_nav_store`) unless the app installed one
/// first; an app marks the surfaces it wants remembered with `.restore(key)`. With no store installed,
/// `.restore` is a silent no-op, so a surface's `.restore` call never fails to build.
///
/// Keys are opaque to day-core and namespaced by the surface's `.restore(key)` argument; the
/// store implementation is responsible for keeping them clear of the app's own data.
pub trait NavStore {
    /// The last value saved under `key`, or `None` if nothing was stored (first launch).
    fn load(&self, key: &str) -> Option<String>;
    /// Persist `value` under `key`, replacing any prior value.
    fn save(&self, key: &str, value: &str);
}

/// Install the app's navigation persistence store (docs/navigation.md). Call once at startup,
/// before the UI mounts, so a `.restore` surface reads it on first build. A later call replaces
/// the store.
pub fn set_nav_store(store: Rc<dyn NavStore>) {
    NAV_STORE.with(|s| *s.borrow_mut() = Some(store));
}

/// Whether a [`NavStore`] is installed. `day::launch` installs the preferences store when the
/// app has not installed one of its own.
pub fn has_nav_store() -> bool {
    NAV_STORE.with(|s| s.borrow().is_some())
}

/// Read a `.restore` key through the installed [`NavStore`] (`None` if none is installed or the
/// key was never saved).
pub fn nav_store_load(key: &str) -> Option<String> {
    match NAV_STORE.with(|s| s.borrow().clone()) {
        Some(st) => st.load(key),
        None => {
            warn_no_nav_store(key);
            None
        }
    }
}

/// Debug-build diagnostic, once per process: a surface asked to `.restore(key)` while no
/// [`NavStore`] is installed persists nothing, and does so silently: the surface still builds,
/// the app still runs, and the setting never survives a relaunch. That follows from the
/// design (`.restore` must never fail to build), but it reads as a bug, so say it out loud where
/// a developer will see it.
fn warn_no_nav_store(key: &str) {
    if !cfg!(debug_assertions) {
        return;
    }
    thread_local! {
        // Android-only false positive, as with day-spec's `APP_TEMP_DIR`: already a `const` block.
        #[allow(clippy::missing_const_for_thread_local)]
        static WARNED: Cell<bool> = const { Cell::new(false) };
    }
    WARNED.with(|w| {
        if !w.replace(true) {
            log::warn!(
                ".restore({key:?}) has no NavStore installed — navigation state will not \
                 persist. `day::launch` installs the preferences store unless the `day` \
                 crate's `prefs` feature is off; otherwise call `day_core::set_nav_store` \
                 before the UI mounts (docs/navigation.md)."
            );
        }
    });
}

/// Write a `.restore` key through the installed [`NavStore`] (a no-op if none is installed).
pub fn nav_store_save(key: &str, value: &str) {
    if let Some(st) = NAV_STORE.with(|s| s.borrow().clone()) {
        st.save(key, value);
    }
}

/// The launch deep link: the `DAY_DEEPLINK` environment variable, else the platform's
/// [`set_launch_deeplink`] hint. Consumed by `launch_with` after the first mount.
pub(crate) fn launch_deeplink() -> Option<String> {
    std::env::var("DAY_DEEPLINK")
        .ok()
        .filter(|r| !r.is_empty())
        // A route buffered by `request_route` before launch: a notification tap that cold-started
        // the process. Peeked, not taken, so `has_launch_deeplink()` keeps answering true for a
        // nav surface's `.restore` (a tap must beat restored state); `launch_with` takes it.
        .or_else(|| requested_slot().clone())
        .or_else(|| LAUNCH_DEEPLINK.with(|l| l.borrow().clone()))
        .filter(|r| !r.is_empty())
}

/// Navigate to a route (docs/navigation.md).
///
/// * `""`: pop the innermost stack to its root (falls through outward).
/// * A single key is relative: innermost surface first, falling through outward.
/// * `a/b/c` is absolute: anchor at the outermost surface that knows `a`, reset every surface
///   inside the anchor to its root, then feed `b`, `c`, … inward (surfaces that mount during
///   the cascade consume the rest as they register).
/// * A trailing `?name=value&…` carries [`route_params`] to the destination builders.
///
/// False = no mounted surface recognized the (first) segment.
pub fn navigate(route: &str) -> bool {
    let (segments, params) = parse_route(route);
    PENDING.with(|p| p.borrow_mut().clear());
    PARAMS.with(|p| *p.borrow_mut() = Rc::new(params));
    let ok = match segments.len() {
        0 => dispatch(|nav| (nav.push)("")),
        1 => dispatch(|nav| (nav.push)(&segments[0])),
        _ => navigate_absolute(&segments),
    };
    maybe_notify_route_change();
    ok
}

/// The surfaces inside `list[anchor]`, outermost first.
///
/// Ordered by [`NavController::depth`] rather than by registration, because a host that builds its
/// pages before registering itself lands in the registry after what those pages contain, so an
/// absolute `section/detail` would otherwise anchor on the section and find nothing beyond it.
/// Depth only orders here, never filters: a subtree rebuilt outside its original build scope (a
/// `when` arm re-derived on a size-class change) registers with a depth of zero, and equal depths
/// keep registration order, which is the right answer in that case.
fn nested_after(list: &[Rc<NavController>], anchor: usize) -> Vec<Rc<NavController>> {
    let mut order: Vec<usize> = (0..list.len()).collect();
    order.sort_by_key(|i| list[*i].depth);
    let at = order.iter().position(|i| *i == anchor).unwrap_or(0);
    order[at + 1..].iter().map(|i| list[*i].clone()).collect()
}

/// Anchor + descend for a multi-segment path. See [`navigate`].
///
/// Signal writes may propagate synchronously (an un-batched set cascades immediately), so the
/// surfaces an anchor switch mounts can register, and must find their segments waiting,
/// before the anchoring `push` even returns. Hence: queue the tail first, then anchor.
fn navigate_absolute(segments: &[String]) -> bool {
    let snapshot = || -> Vec<Rc<NavController>> {
        NAV_STACK.with(|s| s.borrow().iter().map(|(_, c)| c.clone()).collect())
    };
    let controllers = snapshot();
    let first = &segments[0];

    // Already anchored: some surface is showing `first`. Reset everything inside it to its
    // root (innermost-first, so stacks pop cleanly), then feed the remaining segments to the
    // surviving inner surfaces in nesting order. Consult the live registry after the resets,
    // because a reset can dispose deeper surfaces (a popped page takes its sub-surfaces with it).
    if let Some(anchor) = controllers.iter().position(|c| (c.current)() == *first) {
        PENDING.with(|p| *p.borrow_mut() = segments[1..].to_vec());
        for c in nested_after(&controllers, anchor).iter().rev() {
            let _ = (c.push)("");
        }
        let live = snapshot();
        if let Some(anchor) = live.iter().position(|c| (c.current)() == *first) {
            for c in nested_after(&live, anchor) {
                while let Some(front) = PENDING.with(|p| p.borrow().first().cloned()) {
                    if !(c.enter)(&front) {
                        break;
                    }
                    // `enter` may itself have consumed the segment: a push that mounts a surface
                    // registers it, and `register_nav` takes pending segments as it does. Only drop
                    // the head when it is still there.
                    PENDING.with(|p| {
                        let mut p = p.borrow_mut();
                        if !p.is_empty() {
                            p.remove(0);
                        }
                    });
                }
            }
        }
        return true;
    }

    // Switching: queue the tail so surfaces that mount during the (possibly synchronous)
    // cascade consume it as they register, then anchor at the outermost surface that accepts
    // the first segment.
    PENDING.with(|p| *p.borrow_mut() = segments[1..].to_vec());
    for c in controllers.iter() {
        if (c.push)(first) {
            // Tabs may have already mounted the destination's nested surfaces. They will not
            // register again on selection, so deliver the remaining segments here as well.
            // Lazily mounted surfaces still consume the tail in `register_nav`.
            let live = snapshot();
            if let Some(anchor) = live.iter().position(|nav| Rc::ptr_eq(nav, c)) {
                let nested: Vec<Rc<NavController>> = nested_after(&live, anchor)
                    .into_iter()
                    .filter(|nav| (nav.active)())
                    .collect();
                // An explicit deeper path is the nested surfaces' whole state (set-semantics,
                // as in the anchored branch above): a resident stack that still holds the
                // pages of an earlier visit pops to its root before the tail lands, so
                // `settings/general` followed later by `settings/privacy` reads
                // `settings/privacy`, not `settings/general/privacy`. A bare section switch
                // leaves what the user had open.
                if PENDING.with(|p| !p.borrow().is_empty()) {
                    for child in nested.iter().rev() {
                        let _ = (child.push)("");
                    }
                }
                for child in nested {
                    while let Some(front) = PENDING.with(|p| p.borrow().first().cloned()) {
                        if !(child.enter)(&front) {
                            break;
                        }
                        // `enter` may itself have consumed the segment: a push that mounts a surface
                        // registers it, and `register_nav` takes pending segments as it does. Only drop
                        // the head when it is still there.
                        PENDING.with(|p| {
                            let mut p = p.borrow_mut();
                            if !p.is_empty() {
                                p.remove(0);
                            }
                        });
                    }
                }
            }
            return true;
        }
    }
    PENDING.with(|p| p.borrow_mut().clear());
    false
}

/// Pop one level, day-initiated (the toolkit presents the pop). Native-initiated pops arrive as
/// `Event::NavBack` and go through the owning host's `pop` directly.
pub fn nav_back() -> bool {
    let ok = dispatch(|nav| (nav.pop)(false));
    maybe_notify_route_change();
    ok
}

/// A navigation observer (§14.6): called with the new full route each time it changes, from any
/// source: imperative `navigate`/`nav_back`, a nav_link or sidebar row (which navigate from an
/// event handler, bypassing the event observer), a stack push, or a native back gesture. `""` is
/// the root. Installed by the recorder in day-script; day-core only fires it.
pub type NavObserver = dyn Fn(&str, Option<&str>);

/// Install (or clear, with `None`) the navigation observer. Resets the dedup baseline so the next
/// change fires regardless of where recording started.
pub fn set_nav_observer(observer: Option<Box<NavObserver>>) {
    NAV_OBSERVER.with(|o| *o.borrow_mut() = observer.map(Rc::from));
    // Seed the baseline with the normalized route (an unmounted surface is `None` == `""`), so the
    // first real navigation, not the None→"" transition at install, is what fires.
    LAST_ROUTE.with(|r| *r.borrow_mut() = current_route().unwrap_or_default());
}

/// Announce a navigation to `route` from its source: a nav host (the sidebar nav host) calls
/// this the instant it changes the bound selection, so the observer sees it synchronously instead
/// of waiting for the route to settle into `NAV_STACK` a frame later (which `maybe_notify_route_change`
/// reads). Deduped like the pump-boundary path. `route` is the host's local key, which replays as a
/// relative `navigate` (innermost-first), correct for both a top-level sidebar and a nested one.
pub fn note_navigation(route: &str, label: Option<&str>) {
    let changed = LAST_ROUTE.with(|r| {
        let mut r = r.borrow_mut();
        if *r == route {
            false
        } else {
            *r = route.to_string();
            true
        }
    });
    if !changed {
        return;
    }
    let observer = NAV_OBSERVER.with(|o| o.borrow().clone());
    if let Some(obs) = observer {
        obs(route, label);
    }
}

/// Fire the navigation observer if the full route changed since it was last told. Called at the
/// tail of the event pump and of the imperative nav helpers; the dedup makes the redundant call a
/// cheap no-op. The observer handle is cloned out first so it may re-enter (read the route, stop
/// recording) without holding a borrow (mirrors the event observer in tree.rs).
pub(crate) fn maybe_notify_route_change() {
    let now = current_route().unwrap_or_default();
    let changed = LAST_ROUTE.with(|r| {
        let mut r = r.borrow_mut();
        if *r == now {
            false
        } else {
            *r = now.clone();
            true
        }
    });
    if !changed {
        return;
    }
    let observer = NAV_OBSERVER.with(|o| o.borrow().clone());
    if let Some(obs) = observer {
        // No control context here (this is the settled-route fallback path); the source-notifying
        // `note_navigation` supplies a label when a nav host has one.
        obs(&now, None);
    }
}

/// The full current route: every mounted surface's contribution, outermost to innermost,
/// `/`-joined (docs/navigation.md). `None` = no surface mounted; `Some("")` = everything at
/// its root. Round-trips through [`navigate`], so persisting navigation state is
/// `save(current_route())` on the way out and `navigate(&saved)` on the way back in.
pub fn current_route() -> Option<String> {
    let controllers: Vec<Rc<NavController>> =
        NAV_STACK.with(|s| s.borrow().iter().map(|(_, c)| c.clone()).collect());
    if controllers.is_empty() {
        return None;
    }
    // Outermost first, which is nesting order rather than registration order: a host that builds
    // its pages before registering itself lands after what those pages contain
    // (`NavController::depth`). Ties keep registration order, which is what siblings want.
    let mut ordered = controllers;
    ordered.sort_by_key(|c| c.depth);
    let mut parts: Vec<String> = Vec::new();
    for c in ordered {
        // Only what is on screen. A resident page the host is not showing keeps its surfaces
        // registered, and joining those in would append every unselected tab's state to the route.
        if !(c.active)() {
            continue;
        }
        parts.extend((c.segments)());
    }
    Some(encode_route(&parts, &[]))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn route_parsing_round_trips() {
        let (segs, params) = parse_route("mail/inbox/msg-42?hint=linked&x=1");
        assert_eq!(segs, vec!["mail", "inbox", "msg-42"]);
        assert_eq!(
            params,
            vec![("hint".into(), "linked".into()), ("x".into(), "1".into())]
        );
        assert_eq!(
            encode_route(&segs, &params),
            "mail/inbox/msg-42?hint=linked&x=1"
        );

        // Reserved characters survive a round trip.
        let segs = vec!["a/b".to_string()];
        let params = vec![("q".to_string(), "1&2=3".to_string())];
        let encoded = encode_route(&segs, &params);
        let (s2, p2) = parse_route(&encoded);
        assert_eq!(s2, segs);
        assert_eq!(p2, params);
    }

    #[test]
    fn route_parsing_edge_cases() {
        assert_eq!(parse_route(""), (vec![], vec![]));
        assert_eq!(parse_route("a"), (vec!["a".to_string()], vec![]));
        assert_eq!(parse_route("a//b").0, vec!["a", "b"]); // empty segments dropped
        assert_eq!(
            parse_route("?flag").1,
            vec![("flag".to_string(), String::new())]
        );
    }

    /// `REQUESTED_ROUTE` is process-global (it must be; see `request_route`), so the tests that
    /// touch it cannot run in parallel with each other. Serialize them rather than making the
    /// production type thread-local, which would defeat the purpose of the buffer.
    static ROUTE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());

    fn route_test<T>(f: impl FnOnce() -> T) -> T {
        let _guard = ROUTE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        let _ = take_requested_route();
        let out = f();
        let _ = take_requested_route();
        out
    }

    /// The route-buffering assertions below describe the state a cold-start notification tap
    /// arrives in: a process where NO backend has installed the main poster yet. They cannot run
    /// beside the rest of this binary's tests.
    ///
    /// `day_reactive::install_main_poster` is a process-wide `OnceLock` that is never cleared, and
    /// `present::task_tests` installs one: its executor wakers re-poll through `on_main`, so those
    /// tests need it. Once installed, `request_route` takes its posting branch and the
    /// inline poster runs `apply_route_request` immediately, draining the very buffer these
    /// assertions are about. libtest gives no ordering guarantee between the two groups, so this
    /// was a coin flip: the same commit passed on linux-aarch64 and failed on linux-x86_64.
    ///
    /// So they run in a child process. This test re-executes the test binary selecting only the
    /// ignored test below, which is the whole set of assertions; nothing else runs there, so no
    /// poster exists and the precondition holds by construction rather than by luck.
    #[test]
    fn route_buffering_before_launch() {
        let exe = std::env::current_exe().expect("the running test binary has a path");
        let out = std::process::Command::new(exe)
            .args([
                "--exact",
                "--ignored",
                "--nocapture",
                "--test-threads=1",
                "nav::tests::route_buffering_in_a_poster_free_process",
            ])
            .output()
            .expect("re-run this test binary for the child case");
        assert!(
            out.status.success(),
            "the poster-free route assertions failed in the child process:\n{}{}",
            String::from_utf8_lossy(&out.stdout),
            String::from_utf8_lossy(&out.stderr),
        );
    }

    /// Every assertion that depends on no backend having started. Ignored so it runs only in the
    /// child process [`route_buffering_before_launch`] spawns; see that test for why.
    #[test]
    #[ignore = "needs a process with no main poster; run by route_buffering_before_launch"]
    fn route_buffering_in_a_poster_free_process() {
        assert!(
            !day_reactive::has_main_poster(),
            "this test must run in a process where no backend installed the poster; \
             it is meaningless otherwise, so failing loudly beats passing vacuously",
        );

        // A tap that cold-starts the process buffers a route, and the launch path sees it.
        // Without this, `launch_deeplink()` would miss it and the app would open on its default
        // screen.
        route_test(|| {
            assert_eq!(launch_deeplink(), None);
            request_route("clock/timer");
            assert_eq!(launch_deeplink().as_deref(), Some("clock/timer"));
            // Peeked, not taken: a nav surface's `.restore` asks this so a tap beats restored state.
            assert!(has_launch_deeplink());
            assert_eq!(take_requested_route().as_deref(), Some("clock/timer"));
            assert_eq!(take_requested_route(), None);
        });

        // The newest tap wins: a user who taps a second notification before launch completes gets
        // the second destination, not the first.
        route_test(|| {
            request_route("mail/inbox");
            request_route("clock/alarm");
            assert_eq!(take_requested_route().as_deref(), Some("clock/alarm"));
        });

        // An empty route is not a navigation request: `navigate("")` means "pop to root", which a
        // missing intent extra must never trigger.
        route_test(|| {
            request_route("");
            assert_eq!(take_requested_route(), None);
        });

        // `request_route` must not panic when no backend has started, which is exactly the state a
        // cold-start notification tap arrives in (`on_main` panics without a poster).
        route_test(|| {
            request_route("some/route"); // would panic if it posted unconditionally
            let _ = take_requested_route();
        });
    }
}