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/shell.rs

//! **`NavShell` — sidebar + router + content, as one [`Facet`].** The first
//! app-shell slice: a left nav rail ([`Sidebar`]) drives a [`Router`]; the content
//! area shows back/forward + a breadcrumb + the routed body. A precursor to the
//! full `AppShell` (Fas 2), and the demoable proof of the nav backbone.

use crate::dropdown::Dropdown;
use crate::nav::NavModel;
use crate::router::Router;
use crate::sidebar::Sidebar;
use facett_core::{Facet, FacetCaps, theme};
use std::collections::BTreeMap;

/// A nav-driven shell: rail (left) + routed content (right).
pub struct NavShell {
    pub title: String,
    model: NavModel,
    router: Router,
    sidebar: Sidebar,
    /// Optional per-route body text (demo content; a real host renders a Facet here).
    bodies: BTreeMap<String, String>,
    /// Optional `(route, label, dropdown)` — a control shown on one route (e.g. a
    /// theme picker on "settings"). Demonstrates the [`Dropdown`] primitive inside
    /// the shell; a real host wires controls per route.
    route_dropdown: Option<(String, String, Dropdown)>,
}

impl NavShell {
    /// Build over a [`NavModel`]; the router starts at the model's first route.
    pub fn new(title: impl Into<String>, model: NavModel) -> Self {
        let start = model.first_route().unwrap_or_default().to_string();
        Self {
            title: title.into(),
            model,
            router: Router::new(start),
            sidebar: Sidebar::new(),
            bodies: BTreeMap::new(),
            route_dropdown: None,
        }
    }

    /// Attach demo body text for a route.
    pub fn with_body(mut self, route: impl Into<String>, body: impl Into<String>) -> Self {
        self.bodies.insert(route.into(), body.into());
        self
    }

    /// Show a labelled [`Dropdown`] control on `route` (e.g. a theme picker on the
    /// settings route).
    pub fn with_route_dropdown(mut self, route: impl Into<String>, label: impl Into<String>, dropdown: Dropdown) -> Self {
        self.route_dropdown = Some((route.into(), label.into(), dropdown));
        self
    }

    /// The dropdown's currently-selected option id, if a dropdown is attached.
    pub fn dropdown_selection(&self) -> Option<&str> {
        self.route_dropdown.as_ref().and_then(|(_, _, d)| d.selected())
    }

    /// Headless navigation (CLI/robot/test parity): go to `route`.
    pub fn navigate(&mut self, route: impl Into<String>) -> bool {
        self.router.navigate(route)
    }
    pub fn back(&mut self) -> bool {
        self.router.back()
    }
    pub fn current_route(&self) -> &str {
        self.router.current()
    }
    pub fn breadcrumb(&self) -> Vec<String> {
        self.model.breadcrumb(self.router.current())
    }
    pub fn model(&self) -> &NavModel {
        &self.model
    }
}

/// Draw a breadcrumb trail (ancestors as dim links, current bold). Returns the
/// clicked ancestor index, if any. Reusable beyond `NavShell`.
pub fn breadcrumb(ui: &mut egui::Ui, labels: &[String]) -> Option<usize> {
    let mut clicked = None;
    ui.horizontal(|ui| {
        for (i, l) in labels.iter().enumerate() {
            if i > 0 {
                ui.weak("›");
            }
            if i + 1 == labels.len() {
                ui.strong(l);
            } else if ui.link(l).clicked() {
                clicked = Some(i);
            }
        }
    });
    clicked
}

impl Facet for NavShell {
    fn title(&self) -> &str {
        &self.title
    }

    fn ui(&mut self, ui: &mut egui::Ui) {
        let _th = theme(ui);
        let rail_id = ui.id().with("nav_rail");

        // Disjoint field borrows so the SidePanel closure doesn't capture all of self.
        let model = &self.model;
        let bodies = &self.bodies;
        let router = &mut self.router;
        let sidebar = &mut self.sidebar;
        let route_dropdown = &mut self.route_dropdown;

        let active = router.current().to_string();
        let mut nav_to: Option<String> = None;

        egui::Panel::left(rail_id).resizable(true).default_size(190.0).show(ui, |ui| {
            if let Some(r) = sidebar.show(ui, model, &active) {
                nav_to = Some(r);
            }
        });

        // Content area (remaining ui): back/forward + a CLICKABLE breadcrumb.
        ui.horizontal(|ui| {
            if ui.add_enabled(router.can_back(), egui::Button::new("←")).clicked() {
                router.back();
            }
            if ui.add_enabled(router.can_forward(), egui::Button::new("→")).clicked() {
                router.forward();
            }
            ui.separator();
            let crumb = model.breadcrumb_items(&active);
            for (i, (label, route)) in crumb.iter().enumerate() {
                if i > 0 {
                    ui.weak("›");
                }
                if i + 1 == crumb.len() {
                    ui.strong(label);
                } else if ui.link(label).clicked() {
                    nav_to = Some(route.clone());
                }
            }
        });
        ui.separator();

        let heading = model.find_route(&active).map(|i| i.label.clone()).unwrap_or_else(|| active.clone());
        ui.heading(heading);
        if let Some(body) = bodies.get(&active) {
            ui.label(body);
        } else {
            ui.weak(format!("route: {active}"));
        }

        // A per-route control (e.g. a theme dropdown on the settings route).
        if let Some((route, label, dd)) = route_dropdown
            && *route == active
        {
            ui.add_space(8.0);
            ui.horizontal(|ui| {
                ui.label(label.as_str());
                dd.show(ui);
            });
        }

        if let Some(r) = nav_to {
            router.navigate(r);
        }

        #[cfg(feature = "testmatrix")]
        facett_core::testmatrix::emit(
            "facett-app::NavShell::ui",
            "ui_render",
            !self.router.current().is_empty(),
            &format!("route={} crumb_depth={}", self.router.current(), self.model.breadcrumb(self.router.current()).len()),
        );
    }

    fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "title": self.title,
            "route": self.router.current(),
            "breadcrumb": self.breadcrumb(),
            "collapsed": self.sidebar.collapsed,
            "router": self.router.state_json(),
            "nav": self.model.state_json(),
            "dropdown": self.route_dropdown.as_ref().map(|(route, _, d)| {
                serde_json::json!({ "route": route, "state": d.state_json() })
            }),
        })
    }

    fn caps(&self) -> FacetCaps {
        FacetCaps::NONE.themeable().resizable()
    }
}