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");
}
}