facett-app/src/nav.rs
//! **`NavModel` — one navigation model, many surfaces.** A hierarchical, id-keyed
//! tree of [`NavItem`]s that drives every nav surface (sidebar, breadcrumb,
//! menubar, command palette, …) the same way `facett-menu`'s one `Command` set
//! drives its three surfaces today. Pure data + headless-addressable by id.
use serde_json::{Value, json};
/// One navigation entry. A leaf is a route target; a node with `children` is a
/// section/group. `id` is stable + headless-addressable; `route` defaults to `id`.
#[derive(Clone, Debug, PartialEq)]
pub struct NavItem {
pub id: String,
pub label: String,
/// Optional leading glyph (e.g. "🏠", "⚙").
pub icon: Option<String>,
/// Route this item navigates to. `None` ⇒ the route IS the `id`.
pub route: Option<String>,
/// Disabled items are dimmed, unclickable, never the active route target.
pub enabled: bool,
/// Optional trailing badge (e.g. a count "3").
pub badge: Option<String>,
pub children: Vec<NavItem>,
}
impl NavItem {
pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
Self { id: id.into(), label: label.into(), icon: None, route: None, enabled: true, badge: None, children: Vec::new() }
}
pub fn icon(mut self, icon: impl Into<String>) -> Self {
self.icon = Some(icon.into());
self
}
pub fn route(mut self, route: impl Into<String>) -> Self {
self.route = Some(route.into());
self
}
pub fn badge(mut self, badge: impl Into<String>) -> Self {
self.badge = Some(badge.into());
self
}
pub fn disabled(mut self) -> Self {
self.enabled = false;
self
}
/// Add a child (turns this item into a section).
pub fn child(mut self, item: NavItem) -> Self {
self.children.push(item);
self
}
pub fn children(mut self, items: impl IntoIterator<Item = NavItem>) -> Self {
self.children.extend(items);
self
}
pub fn is_section(&self) -> bool {
!self.children.is_empty()
}
/// The route this item targets (its `route`, else its `id`).
pub fn route_or_id(&self) -> &str {
self.route.as_deref().unwrap_or(&self.id)
}
}
/// The whole navigation tree.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct NavModel {
pub items: Vec<NavItem>,
}
impl NavModel {
pub fn new(items: impl IntoIterator<Item = NavItem>) -> Self {
Self { items: items.into_iter().collect() }
}
/// Pre-order walk of every item (sections + leaves).
pub fn flat(&self) -> Vec<&NavItem> {
let mut out = Vec::new();
fn walk<'a>(items: &'a [NavItem], out: &mut Vec<&'a NavItem>) {
for it in items {
out.push(it);
walk(&it.children, out);
}
}
walk(&self.items, &mut out);
out
}
/// Every navigable (enabled, leaf-or-routable) route id, in order.
pub fn routes(&self) -> Vec<&str> {
self.flat().into_iter().filter(|i| i.enabled).map(|i| i.route_or_id()).collect()
}
/// The first navigable route (sidebar default / router start). `None` if empty.
pub fn first_route(&self) -> Option<&str> {
self.flat().into_iter().find(|i| i.enabled && !i.is_section()).map(|i| i.route_or_id())
}
/// Find an item by id (recursive).
pub fn find(&self, id: &str) -> Option<&NavItem> {
self.flat().into_iter().find(|i| i.id == id)
}
/// Find the item that targets `route` (recursive).
pub fn find_route(&self, route: &str) -> Option<&NavItem> {
self.flat().into_iter().find(|i| i.route_or_id() == route)
}
/// The label path (root→item) to the item whose route matches `route` — the
/// breadcrumb trail. Empty if no match.
pub fn breadcrumb(&self, route: &str) -> Vec<String> {
fn dive(items: &[NavItem], route: &str, trail: &mut Vec<String>) -> bool {
for it in items {
trail.push(it.label.clone());
if it.route_or_id() == route || dive(&it.children, route, trail) {
return true;
}
trail.pop();
}
false
}
let mut trail = Vec::new();
dive(&self.items, route, &mut trail);
trail
}
/// Like [`breadcrumb`](Self::breadcrumb) but `(label, route)` per crumb, so a
/// host can render a **clickable** trail (click an ancestor → navigate to its
/// route). Empty if no match.
pub fn breadcrumb_items(&self, route: &str) -> Vec<(String, String)> {
fn dive(items: &[NavItem], route: &str, trail: &mut Vec<(String, String)>) -> bool {
for it in items {
trail.push((it.label.clone(), it.route_or_id().to_string()));
if it.route_or_id() == route || dive(&it.children, route, trail) {
return true;
}
trail.pop();
}
false
}
let mut trail = Vec::new();
dive(&self.items, route, &mut trail);
trail
}
pub fn state_json(&self) -> Value {
fn dump(it: &NavItem) -> Value {
json!({
"id": it.id,
"label": it.label,
"route": it.route_or_id(),
"enabled": it.enabled,
"badge": it.badge,
"children": it.children.iter().map(dump).collect::<Vec<_>>(),
})
}
json!({
"items": self.items.iter().map(dump).collect::<Vec<_>>(),
"routes": self.routes(),
})
}
}