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