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

nordisk/facett

facett-demo/src/scene_demo.rs

//! **The `scene` tab — facett-demo's shell, described DECLARATIVELY.** The first
//! consumer of facett-app's [`scene`](facett_app::scene) layer: instead of
//! hand-wiring panels, the whole shell is a [`Scene`] value (a menubar + a tree of
//! panes + routes) rendered by [`facett_app::scene::render`] through facett-app's OWN
//! primitives (Router / TabView / facett-menu). Authored here with the **Rust
//! builder**; the identical value round-trips through **RON** ([`Scene::to_ron`] /
//! [`Scene::from_ron`], byte-stable).
//!
//! **Interactions (all declarative):**
//! - **Draggable / resizable splits** — the master/detail [`Layout::Split`]s have real
//!   drag handles; dragging a divider re-apportions the live ratio (kept in `NavState`,
//!   observable in `state_json`).
//! - **Fold / unfold** — each content pane is a [`Fold::Foldable`]; folding draws only
//!   its header (contents leave the layout and the AccessKit tree).
//! - **Responsive desktop ↔ phone** — the whole shell renders on either: the top-level
//!   Split collapses to **Tabs** under 720pt, and the activity rail is a **drawer**
//!   that auto-folds when the width crosses into phone-class.
//!
//! The panes host REAL facett widgets through a [`SceneHost`] — notably the 🫧 fluid
//! **System Map** (a foldable canvas pane), plus a stat grid, an activity log, and a
//! throughput plot. Nothing is forked: the scene DRIVES existing components.

use facett::log::LogView;
use facett::plot::PlotView;
use facett::stat::StatGridView;
use facett_app::scene::{self, Action, Axis, Fold, Layout, MenuItem, NavState, Pane, Route, Scene, SceneHost, Target};
use facett::graph3d::SystemMap;
use facett_core::{Facet, FacetCaps};

/// The stable tab title — the deck/LEGEND key and the picker label.
pub const TITLE: &str = "scene";

/// The declarative shell demo: a [`Scene`] + its [`NavState`] + the widgets its slots
/// host. A canonical [`Facet`], so it drops into the deck like any other tab.
pub struct SceneDemo {
    scene: Scene,
    nav: NavState,
    // The slot widgets (real facett components the scene DRIVES).
    sysmap: SystemMap,
    stat: StatGridView,
    log: LogView,
    plot: PlotView,
    /// The last host command a menu action fired (observable).
    last_command: Option<String>,
    /// The last render's `state_json` (device class, slot states, pane rects).
    last_render: serde_json::Value,
}

impl Default for SceneDemo {
    fn default() -> Self {
        Self::new()
    }
}

impl SceneDemo {
    /// Build the shell scene + seed its runtime state and slot widgets.
    pub fn new() -> Self {
        let scene = shell_scene();
        // Fail loudly in debug if the addressability invariant is ever broken.
        debug_assert!(scene.validate().is_ok(), "scene names must be unique: {:?}", scene.validate());
        let nav = NavState::new(&scene);
        Self {
            scene,
            nav,
            sysmap: crate::demo_systemmap::demo_systemmap_liquid(),
            stat: StatGridView::local(),
            log: LogView::local(),
            plot: PlotView::line("throughput", crate::data::telemetry()),
            last_command: None,
            last_render: serde_json::Value::Null,
        }
    }

    /// The scene, as canonical RON (the on-disk authoring form). Exposed so a test /
    /// tool can assert the byte-stable round trip.
    pub fn to_ron(&self) -> String {
        self.scene.to_ron()
    }

    /// The names of every foldable pane in the shell (for the coverage assertions).
    pub fn foldable_panes(&self) -> Vec<String> {
        self.scene
            .pane_names()
            .into_iter()
            .filter(|n| matches!(self.scene.pane(n).map(|p| &p.fold), Some(Fold::Foldable { .. })))
            .collect()
    }

    /// The names of every `drawer` pane (auto-folds into a drawer on phone-class width).
    pub fn drawer_panes(&self) -> Vec<String> {
        self.scene
            .pane_names()
            .into_iter()
            .filter(|n| self.scene.pane(n).map(|p| p.responsive.drawer).unwrap_or(false))
            .collect()
    }

    /// The names of every DRAGGABLE (resizable) split pane in the shell.
    pub fn draggable_splits(&self) -> Vec<String> {
        self.scene
            .pane_names()
            .into_iter()
            .filter(|n| matches!(self.scene.pane(n).map(|p| &p.layout), Some(scene::Layout::Split { resizable: true, .. })))
            .collect()
    }

    /// Fold / unfold a pane by name (the robot-addressable toggle). Returns the new
    /// folded state.
    pub fn toggle_fold(&mut self, pane: &str) -> bool {
        self.nav.toggle_fold(pane)
    }
    /// Whether `pane` is currently folded.
    pub fn is_folded(&self, pane: &str) -> bool {
        self.nav.is_folded(pane)
    }
}

/// The slot host: draws each [`scene::Content::Slot`] as a real facett widget and
/// folds its `state_json` into the scene's observable state.
struct Host<'a> {
    sysmap: &'a mut SystemMap,
    stat: &'a mut StatGridView,
    log: &'a mut LogView,
    plot: &'a mut PlotView,
    last_command: &'a mut Option<String>,
}
impl SceneHost for Host<'_> {
    fn slot(&mut self, id: &str, ui: &mut egui::Ui) -> serde_json::Value {
        match id {
            "sysmap" => {
                self.sysmap.ui(ui);
                self.sysmap.state_json()
            }
            "stat" => {
                self.stat.ui(ui);
                self.stat.state_json()
            }
            "log" => {
                self.log.ui(ui);
                self.log.state_json()
            }
            "plot" => {
                self.plot.ui(ui);
                self.plot.state_json()
            }
            other => {
                ui.weak(format!("no widget for slot: {other}"));
                serde_json::Value::Null
            }
        }
    }
    fn command(&mut self, id: &str) {
        *self.last_command = Some(id.to_string());
    }
}

/// Build facett-demo's shell as a declarative [`Scene`] (the Rust-builder form).
///
/// Layout tree (splits are DRAGGABLE; every content pane foldable):
/// ```text
/// root  ─ Split(H, 0.22, draggable)   [phone → Tabs under 720pt]
/// ├─ activity  (Foldable ☰, drawer@720) slot:log   — rail; auto-folds to a phone drawer
/// └─ workspace ─ Split(V, 0.60, draggable)
///    ├─ canvas   (Foldable 🫧) slot:sysmap  — the fluid System Map (the star)
///    └─ panels ─ Tabs(active 0)
///       ├─ stats (Foldable 📊) slot:stat
///       └─ trend (Foldable 📈) slot:plot
/// ```
pub fn shell_scene() -> Scene {
    let panels = Pane::tabs(
        "panels",
        0,
        vec![
            Pane::slot("stats", "stat").title("Stats").foldable_icon(true, "📊"),
            Pane::slot("trend", "plot").title("Trend").foldable_icon(true, "📈"),
        ],
    )
    .title("Panels");

    let workspace = Pane::split(
        "workspace",
        Axis::Vertical,
        0.60,
        vec![
            Pane::slot("canvas", "sysmap").title("System Map").foldable_icon(true, "🫧"),
            panels,
        ],
    )
    .title("Workspace");

    // The activity rail: a foldable slot that also acts as a DRAWER — it auto-folds
    // when the width crosses into phone-class (and restores on the way back).
    let activity = Pane::slot("activity", "log").title("Activity").foldable_icon(true, "☰").drawer(720.0);

    let root = Pane::split(
        "root",
        Axis::Horizontal,
        0.22,
        vec![activity, workspace],
    )
    .title("facett-demo shell")
    // Responsive: on a phone-class width the master/detail split collapses to Tabs.
    .phone(Layout::Tabs { active: 1 }, 720.0);

    Scene::new("facett-demo", root)
        .menu(MenuItem::new("view", "View").items(vec![
            MenuItem::new("view.activity", "Toggle Activity").shortcut("Ctrl+1").action(Action::Toggle("activity".into())),
            MenuItem::new("view.canvas", "Toggle System Map").shortcut("Ctrl+2").action(Action::Toggle("canvas".into())),
            MenuItem::new("view.refresh", "Refresh").shortcut("Ctrl+R").action(Action::Command("refresh".into())),
        ]))
        .menu(MenuItem::new("go", "Go").items(vec![
            MenuItem::new("go.stats", "Stats").action(Action::NavigateTo("stats".into())),
            MenuItem::new("go.trend", "Trend").action(Action::NavigateTo("trend".into())),
        ]))
        // A declared route: focusing the canvas from a button trigger, over the Router.
        .route(Route::on_button("focus-canvas", Target::Focus("canvas".into())))
        .route(Route::on_menu("go.stats", Target::Show("stats".into())))
}

impl Facet for SceneDemo {
    fn title(&self) -> &str {
        TITLE
    }

    fn ui(&mut self, ui: &mut egui::Ui) {
        // Disjoint field borrows so the host can hold the slot widgets while `render`
        // reads the scene + mutates the nav state.
        let Self { scene, nav, sysmap, stat, log, plot, last_command, last_render } = self;
        let mut host = Host { sysmap, stat, log, plot, last_command };
        *last_render = scene::render(scene, nav, ui, &mut host);

        #[cfg(feature = "testmatrix")]
        facett_core::testmatrix::emit(
            "facett-demo::SceneDemo::ui",
            "scene_render",
            last_render["scene"] == "facett-demo",
            &format!("device={} panes={}", last_render["device"], scene.pane_names().len()),
        );
    }

    fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "component": "SceneDemo",
            "scene": self.scene.name,
            // The declared shell as data: the pane tree + which panes are foldable.
            "panes": self.scene.pane_names(),
            "foldable": self.foldable_panes(),
            "drawers": self.drawer_panes(),
            "draggable_splits": self.draggable_splits(),
            "folded": self.nav.folded,
            "split_ratio": self.nav.split_ratio,
            "routes": self.scene.routes.len(),
            "menus": self.scene.menubar.len(),
            "last_command": self.last_command,
            // The last render's observable facts (device class, slot states, rects).
            "render": self.last_render,
        })
    }

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

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn shell_scene_is_valid_and_all_content_panes_are_foldable() {
        let d = SceneDemo::new();
        d.scene.validate().expect("unique pane + menu names");
        // Every LEAF (slot) pane is foldable — the fold-only interaction covers them all.
        let foldable = d.foldable_panes();
        for want in ["activity", "canvas", "stats", "trend"] {
            assert!(foldable.contains(&want.to_string()), "the `{want}` pane is foldable");
        }
    }

    #[test]
    fn shell_scene_ron_round_trips_byte_stable() {
        let d = SceneDemo::new();
        let ron1 = d.to_ron();
        let reparsed = Scene::from_ron(&ron1).expect("the demo shell parses back from its own RON");
        assert_eq!(ron1, reparsed.to_ron(), "the demo shell's RON is byte-stable");
        assert_eq!(d.scene, reparsed, "RON and the builder yield the SAME shell Scene");
    }

    #[test]
    fn folding_a_pane_is_observable() {
        let mut d = SceneDemo::new();
        assert!(!d.is_folded("canvas"), "the System Map canvas starts unfolded");
        assert!(d.toggle_fold("canvas"), "fold it");
        assert!(d.is_folded("canvas"));
        let j = d.state_json();
        assert_eq!(j["folded"]["canvas"], true, "the fold state is observable in state_json");
    }

    #[test]
    fn the_master_detail_splits_are_draggable_with_live_ratios() {
        let d = SceneDemo::new();
        // Both the root (H) and workspace (V) splits are DRAGGABLE (not static ratios).
        let splits = d.draggable_splits();
        assert!(splits.contains(&"root".to_string()), "the root master/detail split is draggable");
        assert!(splits.contains(&"workspace".to_string()), "the workspace canvas/panels split is draggable");
        // Their live ratios are seeded and observable.
        let root = d.nav.split_ratios("root").expect("root has a live ratio");
        assert!((root[0] - 0.22).abs() < 1e-5, "root seeds the 0.22 master fraction, got {root:?}");
        let ws = d.nav.split_ratios("workspace").expect("workspace has a live ratio");
        assert!((ws[0] - 0.60).abs() < 1e-5, "workspace seeds the 0.60 canvas fraction, got {ws:?}");
        assert_eq!(d.state_json()["draggable_splits"].as_array().unwrap().len(), 2, "two draggable splits, observable");
    }

    #[test]
    fn the_activity_rail_is_a_drawer() {
        let d = SceneDemo::new();
        assert!(d.drawer_panes().contains(&"activity".to_string()), "the activity rail is a drawer");
        assert_eq!(
            d.scene.pane("activity").unwrap().responsive.collapse_below,
            Some(720.0),
            "the drawer collapses at the same 720pt breakpoint the root split does"
        );
        assert_eq!(d.state_json()["drawers"][0], "activity", "the drawer is observable in state_json");
    }
}