use std::cell::{Cell, RefCell};
use std::rc::Rc;
pub struct NavController {
pub push: Box<dyn Fn(&str) -> bool>,
pub pop: Box<dyn Fn(bool) -> bool>,
pub current: Box<dyn Fn() -> String>,
pub enter: Box<dyn Fn(&str) -> bool>,
pub segments: Box<dyn Fn() -> Vec<String>>,
pub depth: usize,
pub layer: Box<dyn Fn() -> u64>,
pub active: Box<dyn Fn() -> bool>,
}
#[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) };
static LAUNCH_DEEPLINK: RefCell<Option<String>> = const { RefCell::new(None) };
static PARAMS: RefCell<Rc<Vec<(String, String)>>> = RefCell::new(Rc::new(Vec::new()));
static PENDING: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
static NAV_STORE: RefCell<Option<Rc<dyn NavStore>>> = const { RefCell::new(None) };
static NAV_OBSERVER: RefCell<Option<Rc<NavObserver>>> = const { RefCell::new(None) };
static LAST_ROUTE: RefCell<String> = const { RefCell::new(String::new()) };
}
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())));
if PENDING.with(|p| !p.borrow().is_empty()) {
let _ = (ctrl.push)("");
}
while let Some(front) = PENDING.with(|p| p.borrow().first().cloned()) {
if !(ctrl.enter)(&front) {
break;
}
PENDING.with(|p| {
let mut p = p.borrow_mut();
if !p.is_empty() {
p.remove(0);
}
});
}
token
}
pub fn unregister_nav(token: NavToken) {
NAV_STACK.with(|s| s.borrow_mut().retain(|(t, _)| *t != token));
}
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()));
}
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
}
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)
}
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(
¶ms
.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
}
pub fn route_params() -> Rc<Vec<(String, String)>> {
PARAMS.with(|p| p.borrow().clone())
}
pub fn route_param(name: &str) -> Option<String> {
route_params()
.iter()
.find(|(k, _)| k == name)
.map(|(_, v)| v.clone())
}
pub fn set_launch_deeplink(route: &str) {
LAUNCH_DEEPLINK.with(|l| *l.borrow_mut() = Some(route.to_string()));
}
static REQUESTED_ROUTE: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
fn requested_slot() -> std::sync::MutexGuard<'static, Option<String>> {
REQUESTED_ROUTE.lock().unwrap_or_else(|e| e.into_inner())
}
pub fn request_route(route: &str) {
if route.is_empty() {
return;
}
*requested_slot() = Some(route.to_string());
if day_reactive::has_main_poster() {
day_reactive::on_main(|| {
if let Some(route) = take_requested_route() {
apply_route_request(&route);
}
});
}
}
pub(crate) fn take_requested_route() -> Option<String> {
requested_slot().take()
}
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");
}
}
pub fn has_launch_deeplink() -> bool {
launch_deeplink().is_some()
}
pub trait NavStore {
fn load(&self, key: &str) -> Option<String>;
fn save(&self, key: &str, value: &str);
}
pub fn set_nav_store(store: Rc<dyn NavStore>) {
NAV_STORE.with(|s| *s.borrow_mut() = Some(store));
}
pub fn has_nav_store() -> bool {
NAV_STORE.with(|s| s.borrow().is_some())
}
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
}
}
}
fn warn_no_nav_store(key: &str) {
if !cfg!(debug_assertions) {
return;
}
thread_local! {
#[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)."
);
}
});
}
pub fn nav_store_save(key: &str, value: &str) {
if let Some(st) = NAV_STORE.with(|s| s.borrow().clone()) {
st.save(key, value);
}
}
pub(crate) fn launch_deeplink() -> Option<String> {
std::env::var("DAY_DEEPLINK")
.ok()
.filter(|r| !r.is_empty())
.or_else(|| requested_slot().clone())
.or_else(|| LAUNCH_DEEPLINK.with(|l| l.borrow().clone()))
.filter(|r| !r.is_empty())
}
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
}
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()
}
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];
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;
}
PENDING.with(|p| {
let mut p = p.borrow_mut();
if !p.is_empty() {
p.remove(0);
}
});
}
}
}
return true;
}
PENDING.with(|p| *p.borrow_mut() = segments[1..].to_vec());
for c in controllers.iter() {
if (c.push)(first) {
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();
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;
}
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
}
pub fn nav_back() -> bool {
let ok = dispatch(|nav| (nav.pop)(false));
maybe_notify_route_change();
ok
}
pub type NavObserver = dyn Fn(&str, Option<&str>);
pub fn set_nav_observer(observer: Option<Box<NavObserver>>) {
NAV_OBSERVER.with(|o| *o.borrow_mut() = observer.map(Rc::from));
LAST_ROUTE.with(|r| *r.borrow_mut() = current_route().unwrap_or_default());
}
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);
}
}
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 {
obs(&now, None);
}
}
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;
}
let mut ordered = controllers;
ordered.sort_by_key(|c| c.depth);
let mut parts: Vec<String> = Vec::new();
for c in ordered {
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, ¶ms),
"mail/inbox/msg-42?hint=linked&x=1"
);
let segs = vec!["a/b".to_string()];
let params = vec![("q".to_string(), "1&2=3".to_string())];
let encoded = encode_route(&segs, ¶ms);
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"]); assert_eq!(
parse_route("?flag").1,
vec![("flag".to_string(), String::new())]
);
}
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
}
#[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),
);
}
#[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",
);
route_test(|| {
assert_eq!(launch_deeplink(), None);
request_route("clock/timer");
assert_eq!(launch_deeplink().as_deref(), Some("clock/timer"));
assert!(has_launch_deeplink());
assert_eq!(take_requested_route().as_deref(), Some("clock/timer"));
assert_eq!(take_requested_route(), None);
});
route_test(|| {
request_route("mail/inbox");
request_route("clock/alarm");
assert_eq!(take_requested_route().as_deref(), Some("clock/alarm"));
});
route_test(|| {
request_route("");
assert_eq!(take_requested_route(), None);
});
route_test(|| {
request_route("some/route"); let _ = take_requested_route();
});
}
}