Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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(),
        })
    }
}