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}