Skip to main content

lumen_core/
nav.rs

1//! File-based-pages navigation primitive - the ONE surface every embedding
2//! reaches through.
3//!
4//! Navigation in Lumen is not a per-language script builtin: it is a command
5//! carried on the shared external-signal bus. A script host (Rhai now, candela
6//! later), the Rust SDK, a C-ABI plugin, and the future Python / C# SDKs all
7//! reach navigation by writing the reserved [`REQUEST_SIGNAL`] cell through
8//! [`request`] (which routes through
9//! [`crate::signals::push_external_signal`] -> [`crate::property_store::PropertyStore`]).
10//! The runtime's `apply_navigation` system is the single resolver: it reads
11//! the request cell, resolves the target path against the registered pages
12//! (longest existing-file prefix - the framework never pattern-matches
13//! `:id` segments), and writes the reserved [`PATH_SIGNAL`] / [`SEGMENT_SIGNAL`]
14//! cells that `<if>` page gates, `bind-*`, and derivations react to.
15//!
16//! This mirrors real-HTML navigation semantics (an `<a href>` click and a
17//! programmatic `history.pushState` both end at one URL that the view reacts
18//! to) and Next.js / SvelteKit file-based routing (a page == a file), while
19//! staying candela-neutral: nothing here is Rhai-specific.
20//!
21//! ## Wire format
22//!
23//! The request cell carries a single opaque string so a repeated identical
24//! op (two `back()`s in a row) still edge-triggers: `"<seq>\u{1f}<kind>\u{1f}<arg>"`
25//! where `seq` is a process-monotonic nonce, `kind` is one of `{nav, back, forward}`,
26//! and `arg` is the target path for `nav` (empty otherwise). Producers build
27//! it with [`encode_request`]; the resolver parses it with [`parse_request`].
28
29use std::sync::atomic::{AtomicU64, Ordering};
30use std::sync::{Mutex, OnceLock};
31
32/// Reserved global signal the navigation resolver reads. Producers write it
33/// via [`request`]; it is not meant to be bound in markup.
34pub const REQUEST_SIGNAL: &str = "route.request";
35
36/// Reserved global signal holding the active page key (the resolved
37/// `.lmn` filename stem). `<if eq="settings">` page gates compare against it;
38/// `bind-text="route.path"` and derivations may read it.
39pub const PATH_SIGNAL: &str = "route.path";
40
41/// Reserved global signal holding the leftover path after the matched page
42/// prefix (e.g. navigating `/user/7` when only `user.lmn` exists leaves
43/// `/7` here). The framework never parses this into typed params - the
44/// page's own code does.
45pub const SEGMENT_SIGNAL: &str = "route.segment";
46
47/// A navigation operation. Host-neutral: every surface produces one of these.
48#[derive(Clone, Debug, PartialEq, Eq)]
49pub enum NavOp {
50    /// Navigate to a target path (`"settings"`, `"/user/7"`, `"/"`).
51    Navigate(String),
52    /// Step one entry back in the in-memory history stack.
53    Back,
54    /// Step one entry forward in the in-memory history stack.
55    Forward,
56}
57
58/// ASCII unit separator; will not appear in a page path or op token.
59const SEP: char = '\u{1f}';
60
61static SEQ: AtomicU64 = AtomicU64::new(1);
62
63fn next_seq() -> u64 {
64    SEQ.fetch_add(1, Ordering::Relaxed)
65}
66
67/// Encode `op` into the reserved-request wire string with a fresh nonce so an
68/// immediately-repeated op still registers as a change.
69pub fn encode_request(op: &NavOp) -> String {
70    let seq = next_seq();
71    match op {
72        NavOp::Navigate(path) => format!("{seq}{SEP}nav{SEP}{path}"),
73        NavOp::Back => format!("{seq}{SEP}back{SEP}"),
74        NavOp::Forward => format!("{seq}{SEP}forward{SEP}"),
75    }
76}
77
78/// Parse a reserved-request wire string back into `(seq, op)`. Returns `None`
79/// for an unrecognised / malformed value.
80pub fn parse_request(raw: &str) -> Option<(u64, NavOp)> {
81    let mut parts = raw.splitn(3, SEP);
82    let seq: u64 = parts.next()?.parse().ok()?;
83    let kind = parts.next()?;
84    let arg = parts.next().unwrap_or("");
85    let op = match kind {
86        "nav" => NavOp::Navigate(arg.to_string()),
87        "back" => NavOp::Back,
88        "forward" => NavOp::Forward,
89        _ => return None,
90    };
91    Some((seq, op))
92}
93
94/// Request a navigation from ANY thread / ANY surface. Writes the reserved
95/// request cell through the external-signal bus; the runtime's
96/// `apply_navigation` system resolves it on the next tick.
97///
98/// Returns `false` only when the external bus has been torn down.
99pub fn request(op: NavOp) -> bool {
100    crate::signals::push_external_signal(REQUEST_SIGNAL, encode_request(&op))
101}
102
103/// Convenience: navigate to `path` (equivalent to `request(NavOp::Navigate(..))`).
104pub fn navigate(path: impl Into<String>) -> bool {
105    request(NavOp::Navigate(path.into()))
106}
107
108/// Convenience: step back in history.
109pub fn back() -> bool {
110    request(NavOp::Back)
111}
112
113/// Convenience: step forward in history.
114pub fn forward() -> bool {
115    request(NavOp::Forward)
116}
117
118// -- current-page mirror -----------------------------------------------------
119//
120// The resolver publishes the resolved active page key here so a no-arg
121// `page()` read is answerable from any surface (Rhai `page()`, the Rust SDK,
122// the C-ABI `lumen_current_page`) without threading the running `App`'s world
123// across the boundary. Updated once per resolved navigation; lags the
124// PropertyStore cell by at most one tick.
125
126static CURRENT: OnceLock<Mutex<String>> = OnceLock::new();
127
128fn current_cell() -> &'static Mutex<String> {
129    CURRENT.get_or_init(|| Mutex::new(String::new()))
130}
131
132/// Publish the resolved active page key. Called by the runtime resolver.
133pub fn set_current(page: &str) {
134    if let Ok(mut g) = current_cell().lock() {
135        *g = page.to_string();
136    }
137}
138
139/// Read the current active page key. Empty before the first page mounts.
140pub fn current() -> String {
141    current_cell().lock().map(|g| g.clone()).unwrap_or_default()
142}
143
144// -- page-path resolution (longest existing-file prefix) ---------------------
145
146/// Resolve a requested `path` against the set of known page `keys` (each a
147/// `.lmn` filename stem), returning `(page_key, segment)`.
148///
149/// Algorithm - the framework does not pattern-match segments:
150/// 1. Strip a leading `/`. An empty path resolves to `entry` (the home page).
151/// 2. Try the full path as a page key; if absent, walk up one segment at a
152///    time to the longest existing prefix (`/user/7` -> `user` when only
153///    `user.lmn` exists).
154/// 3. The leftover tail after the matched prefix becomes the `segment`
155///    (`/7`), for the page's own code to parse. A whole-path miss falls back
156///    to `entry` with the normalised path as the segment.
157pub fn resolve_path(path: &str, keys: &[String], entry: &str) -> (String, String) {
158    let norm = path.trim_start_matches('/').trim_end_matches('/');
159    if norm.is_empty() {
160        return (entry.to_string(), String::new());
161    }
162    let segs: Vec<&str> = norm.split('/').filter(|s| !s.is_empty()).collect();
163    for i in (1..=segs.len()).rev() {
164        let candidate = segs[..i].join("/");
165        if keys.iter().any(|k| k == &candidate) {
166            let leftover = segs[i..].join("/");
167            let segment = if leftover.is_empty() {
168                String::new()
169            } else {
170                format!("/{leftover}")
171            };
172            return (candidate, segment);
173        }
174    }
175    // Nothing matched - fall back to the entry page, exposing the whole
176    // requested path as the segment so the app can render its own 404.
177    (entry.to_string(), format!("/{norm}"))
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn round_trip_ops() {
186        for op in [
187            NavOp::Navigate("settings".into()),
188            NavOp::Navigate("/user/7".into()),
189            NavOp::Back,
190            NavOp::Forward,
191        ] {
192            let (_, parsed) = parse_request(&encode_request(&op)).unwrap();
193            assert_eq!(parsed, op);
194        }
195    }
196
197    #[test]
198    fn nonce_makes_repeats_distinct() {
199        let a = encode_request(&NavOp::Back);
200        let b = encode_request(&NavOp::Back);
201        assert_ne!(a, b, "repeated op must differ so it edge-triggers");
202    }
203
204    #[test]
205    fn resolves_exact_and_prefix_and_root() {
206        let keys = vec![
207            "index".to_string(),
208            "settings".to_string(),
209            "user".to_string(),
210        ];
211        assert_eq!(
212            resolve_path("/", &keys, "index"),
213            ("index".into(), "".into())
214        );
215        assert_eq!(
216            resolve_path("settings", &keys, "index"),
217            ("settings".into(), "".into())
218        );
219        assert_eq!(
220            resolve_path("/user/7", &keys, "index"),
221            ("user".into(), "/7".into())
222        );
223        assert_eq!(
224            resolve_path("/user/7/edit", &keys, "index"),
225            ("user".into(), "/7/edit".into())
226        );
227        // Whole-path miss -> entry + full path as segment.
228        assert_eq!(
229            resolve_path("/nope", &keys, "index"),
230            ("index".into(), "/nope".into())
231        );
232    }
233}