facett-app/src/appshell.rs
//! **`AppShell` — compose a whole app from facett components.** The Fas 2 payoff:
//! a topbar (app title + back/forward + breadcrumb) over a left [`Sidebar`]
//! ([`NavModel`]-driven) + a central content area that hosts a **real
//! [`Facet`] per route** + a statusbar. Where [`NavShell`](crate::NavShell) shows
//! body text, `AppShell` mounts actual components — so the active route renders a
//! grid, a chart, a 3D model, … swapped by navigation.
//!
//! Itself a [`Facet`] (so it drops into a `FacetDeck` / the demo), but designed to
//! be the **whole window** in a real app. Immediate-mode, headless-addressable
//! (`navigate`), fully observable (`state_json` folds in the active pane's state).
use crate::nav::NavModel;
use crate::palette::NavPalette;
use crate::router::Router;
use crate::sidebar::Sidebar;
use facett_core::{Facet, FacetCaps, theme};
use std::collections::BTreeMap;
/// An app shell: topbar + sidebar + routed Facet panes + statusbar.
pub struct AppShell {
pub title: String,
model: NavModel,
router: Router,
sidebar: Sidebar,
/// route → the component mounted on it.
panes: BTreeMap<String, Box<dyn Facet>>,
/// route → fallback body text (for routes without a pane).
bodies: BTreeMap<String, String>,
status: String,
/// Optional Ctrl-K fuzzy palette over the nav model.
palette: Option<NavPalette>,
/// Show a facett-menu menubar in the topbar, driven by the SAME nav model.
menubar: bool,
}
impl AppShell {
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(),
panes: BTreeMap::new(),
bodies: BTreeMap::new(),
status: String::new(),
palette: None,
menubar: false,
}
}
/// Enable a Ctrl-K fuzzy command palette over the nav model.
pub fn with_command_palette(mut self) -> Self {
self.palette = Some(NavPalette::new());
self
}
/// Show a facett-menu menubar in the topbar, built from the SAME nav model
/// (the NavModel→Command bridge) — nav as a classic dropdown menu.
pub fn with_menubar(mut self) -> Self {
self.menubar = true;
self
}
/// Mount a component on a route — the app-composition primitive.
pub fn with_pane(mut self, route: impl Into<String>, pane: Box<dyn Facet>) -> Self {
self.panes.insert(route.into(), pane);
self
}
/// Fallback body text for a route without a mounted pane.
pub fn with_body(mut self, route: impl Into<String>, body: impl Into<String>) -> Self {
self.bodies.insert(route.into(), body.into());
self
}
pub fn with_status(mut self, status: impl Into<String>) -> Self {
self.status = status.into();
self
}
/// Headless navigation (CLI/robot/test parity).
pub fn navigate(&mut self, route: impl Into<String>) -> bool {
self.router.navigate(route)
}
pub fn current_route(&self) -> &str {
self.router.current()
}
pub fn breadcrumb(&self) -> Vec<String> {
self.model.breadcrumb(self.router.current())
}
/// The component mounted on the active route, if any.
pub fn active_pane(&self) -> Option<&dyn Facet> {
self.panes.get(self.router.current()).map(|b| b.as_ref())
}
}
impl Facet for AppShell {
fn title(&self) -> &str {
&self.title
}
fn ui(&mut self, ui: &mut egui::Ui) {
let _th = theme(ui);
// Disjoint field borrows so the panel closures don't capture all of self.
let app_title = self.title.clone();
let ctx = ui.ctx().clone();
let model = &self.model;
let bodies = &self.bodies;
let status = &self.status;
let router = &mut self.router;
let sidebar = &mut self.sidebar;
let panes = &mut self.panes;
let palette = &mut self.palette;
let menubar = self.menubar;
let active = router.current().to_string();
let mut nav_to: Option<String> = None;
// ── Topbar: app title + back/forward + clickable breadcrumb ──────────
egui::Panel::top(ui.id().with("shell_top")).show(ui, |ui| {
ui.horizontal(|ui| {
ui.strong(&app_title);
ui.separator();
// A menubar over the SAME nav model (the NavModel→Command bridge).
if menubar {
if let Some(route) = facett_menu::menu_bar(ui, &crate::menu::nav_commands(model)) {
nav_to = Some(route);
}
ui.separator();
}
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());
}
}
});
});
// ── Statusbar (bottom) ───────────────────────────────────────────────
egui::Panel::bottom(ui.id().with("shell_status")).show(ui, |ui| {
ui.horizontal(|ui| {
ui.weak(if status.is_empty() { "ready" } else { status.as_str() });
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.weak(format!("route: {active}"));
});
});
});
// ── Left nav rail ────────────────────────────────────────────────────
egui::Panel::left(ui.id().with("shell_rail")).resizable(true).default_size(190.0).show(ui, |ui| {
if let Some(r) = sidebar.show(ui, model, &active) {
nav_to = Some(r);
}
});
// ── Central: the mounted Facet for this route (the payoff) ───────────
if let Some(pane) = panes.get_mut(&active) {
pane.ui(ui);
} else if let Some(body) = bodies.get(&active) {
ui.heading(model.find_route(&active).map(|i| i.label.clone()).unwrap_or_else(|| active.clone()));
ui.label(body);
} else {
ui.weak(format!("no pane mounted on route: {active}"));
}
// Ctrl-K fuzzy palette over the same nav model (overlay, on top).
if let Some(p) = palette {
p.handle_hotkeys(&ctx);
if let Some(route) = p.show(ui, model) {
nav_to = Some(route);
}
}
if let Some(r) = nav_to {
router.navigate(r);
}
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-app::AppShell::ui",
"ui_render",
!self.router.current().is_empty(),
&format!("route={} panes={}", self.router.current(), self.panes.len()),
);
}
fn state_json(&self) -> serde_json::Value {
let active = self.router.current();
serde_json::json!({
"title": self.title,
"route": active,
"breadcrumb": self.breadcrumb(),
"status": self.status,
"router": self.router.state_json(),
"panes": self.panes.keys().collect::<Vec<_>>(),
// Fold in the ACTIVE pane's own observable state (whole-app introspection).
"active_pane": self.panes.get(active).map(|p| serde_json::json!({
"title": p.title(),
"state": p.state_json(),
})),
"palette": self.palette.as_ref().map(|p| p.state_json()),
"nav": self.model.state_json(),
})
}
fn caps(&self) -> FacetCaps {
FacetCaps::NONE.themeable().resizable()
}
}