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