facett-app/src/router.rs
//! **`Router` — route → view, with history.** Generalises the deck's
//! `set_active_by_title` + the wasm `#TabName` deep-link into a route stack with
//! back/forward, usable native AND on wasm. A route is an opaque string id
//! (matches a [`NavItem`](crate::NavItem) route / a deck tab title / a path).
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
/// Active route + back/forward history.
///
/// **Serde-round-trippable** — that is what makes a whole shell's chrome persistable
/// across a restart; before it was, every minimise, ratio and fold died with the
/// process.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Router {
current: String,
back: Vec<String>,
fwd: Vec<String>,
}
impl Router {
pub fn new(initial: impl Into<String>) -> Self {
Self { current: initial.into(), back: Vec::new(), fwd: Vec::new() }
}
pub fn current(&self) -> &str {
&self.current
}
/// Go to `route`. Pushes the previous route onto the back stack and clears the
/// forward stack (standard browser semantics). No-op (returns false) if already
/// on that route.
pub fn navigate(&mut self, route: impl Into<String>) -> bool {
let route = route.into();
if route == self.current {
return false;
}
self.back.push(std::mem::replace(&mut self.current, route));
self.fwd.clear();
true
}
pub fn can_back(&self) -> bool {
!self.back.is_empty()
}
pub fn can_forward(&self) -> bool {
!self.fwd.is_empty()
}
/// Pop one step back; the current route moves to the forward stack.
pub fn back(&mut self) -> bool {
match self.back.pop() {
Some(prev) => {
self.fwd.push(std::mem::replace(&mut self.current, prev));
true
}
None => false,
}
}
/// Re-apply one forward step; the current route moves to the back stack.
pub fn forward(&mut self) -> bool {
match self.fwd.pop() {
Some(next) => {
self.back.push(std::mem::replace(&mut self.current, next));
true
}
None => false,
}
}
/// The depth of the back history (for a "← (n)" affordance).
pub fn back_depth(&self) -> usize {
self.back.len()
}
/// The current route as a URL hash fragment (`#route`, spaces → `%20`) — the
/// deep-link the host writes to `window.location.hash` (wasm) or a CLI arg.
pub fn to_hash(&self) -> String {
format!("#{}", self.current.replace(' ', "%20"))
}
/// Parse a URL hash fragment (`#route`) into a route string, decoding `%20`/`+`
/// to spaces. Returns `None` for an empty fragment. Pure (native + wasm).
pub fn route_from_hash(hash: &str) -> Option<String> {
let r = hash.trim_start_matches('#').replace("%20", " ").replace('+', " ");
let r = r.trim();
(!r.is_empty()).then(|| r.to_string())
}
/// Apply a hash fragment as a navigation (deep-link restore). Returns true if
/// it changed the route. Unknown/empty fragments are ignored.
pub fn sync_from_hash(&mut self, hash: &str) -> bool {
match Self::route_from_hash(hash) {
Some(route) => self.navigate(route),
None => false,
}
}
pub fn state_json(&self) -> Value {
json!({
"current": self.current,
"hash": self.to_hash(),
"can_back": self.can_back(),
"can_forward": self.can_forward(),
"back_depth": self.back.len(),
"forward_depth": self.fwd.len(),
})
}
}