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

nordisk/facett

.nornir/scene-design.md

facett-app scene — the declarative UI-scene layer — design

Class: design · Function axis: f0 (an app's UI as data) · f1 schema + RON/builder · f2 NavState-over-Router · f3 render over facett-app primitives · f4 draggable splits + responsive · f5 facett-demo adoption · f6 per-pane WINDOW CHROME (minimise / maximise / restore) — ON BY DEFAULT. Strict: this file states only the shape we run. It lives in facett-app/src/scene.rs, re-exported flat from the crate root.

f0 — an app's whole UI as DATA

scene describes a whole app's shell as one serde value — a menubar, a tree of panes, and a set of nav routes — then renders it through facett-app's OWN primitives. It reinvents nothing: the Router is the nav engine, TabView draws tab strips, and facett_menu::menu_bar draws the menubar (the same surface AppShell uses). The scene is a thin description that render walks.

Scene { name, menubar: Vec<MenuItem>, root: Pane, routes: Vec<Route> }
  MenuItem { name(UNIQUE), label, shortcut?, action?, items: Vec<MenuItem> }   // nested
  Pane { name(UNIQUE), title, layout, fold, chrome, responsive, content }
    Layout  = Flow | Split{axis, ratios:Vec<f32>, resizable=true} | Grid{cols} | Tabs{active}
    Content = Children(Vec<Pane>) | Slot(String)                     // Slot(id) => host widget
    Fold    = Fixed | Foldable{default_open, icon?}
    Chrome  = Auto(DEFAULT) | Always | FoldOnly | None                // the window title bar
    Responsive { phone?: Layout, tablet?: Layout, collapse_below?: f32, drawer: bool }
  Route { trigger: Menu|Shortcut|Button|Event,
          target:  Show|Focus|Push|Pop|Toggle|Minimize|Maximize|Restore|ShowWith }

Every node derives Serialize + Deserialize + PartialEq. Unique pane names are the addressability invariant (Scene::validate): every pane is nav-, fold- and deep-link addressable by its name.

A Split carries one ratios fraction per child (relative weights, normalised to sum to 1) plus a resizable flag. This is the tiling-WM / egui_dock split-tree model: splits nest binary-or-N-way, tabs live at the leaves, and every divider is one float.

f1 — two authoring forms, ONE Scene value

Author a scene as RON or with the Rust builder — both are serde, both yield the same Scene:

f2 — NavState over the Router (no new nav engine)

NavState wraps the Router with the per-scene runtime the renderer reads and the triggers mutate:

NavState { router, active_tab: Map<name,usize>, folded: Map<name,bool>,
           minimized: Map<name,bool>, maximized: Option<name>, last_chrome: Option<String>,
           split_ratio: Map<name,Vec<f32>>, focus, arg, last_device }

NavState::new(&scene) seeds folded from each Foldable.default_open (folded = !default_open), active_tab from each Tabs.active, and split_ratio from each Split.ratios (normalised) — so the LIVE ratio is the source of truth (a drag mutates it; the renderer reads it). The router starts at the root pane. apply(&Target) maps every target onto the router / fold state:

Target effect
Show(p) / Push(p) router.navigate(p) (records back-history)
Focus(p) set focus + navigate
Pop router.back()
Toggle(p) flip the pane's fold
Minimize(p) flip the pane's minimise (collapse to its title bar, slot handed back)
Maximize(p) flip the pane's maximise (whole scene area, siblings zeroed)
Restore(p) un-minimise + drop the maximise; the ratios were never written, so exact
ShowWith{pane,arg} record arg + navigate

fire(&scene, trigger) runs the first matching declared route; deep_link(&scene, "app#pane") resolves a pane by unique name through Router::route_from_hash (back = pop, deep-link by name). set_split_ratios(p, …) sets a split's live ratio programmatically (normalised).

f3 — render over facett-app primitives

scene::render(&scene, &mut nav, ui, host: &mut dyn SceneHost) -> state_json walks the tree:

Draggable / resizable splits

draw_split lays the children along the axis at the LIVE ratios from NavState. When resizable, a real drag handle sits between each adjacent pair: hovering shows a resize cursor, and a drag re-apportions ONLY the two ratios it sits between (clamped to a SPLIT_MIN), writing the result back to NavState. Because the ratios live in NavState, a drag is fully observable in state_json (and a robot can drag a handle by the reported separators rect). Handle rects and the live ratios are reported per split pane.

Fold / unfold

A Foldable pane wraps its content in an egui::CollapsingHeader driven by .open(Some(!folded)) — mirroring facett-graphnav's ⚙ fold (e399041). When folded, the body closure's content is not laid out ⇒ omitted from the layout AND the AccessKit tree; only the header (with the title) remains. A header click calls nav.toggle_fold.

render emits state_json:

{ scene, device, width, route, focus, folded, minimized, maximized, active_tab,
  panes { name -> { foldable, folded, minimized, maximized, layout, content,
                    rect,          // what the pane DREW
                    alloc, area,   // what the parent ALLOTTED it  <- the geometry oracle
                    content_rect, content_height,   // what its BODY occupied (0 when hidden)
                    hidden_by?,    // "maximize" for a sibling allotted nothing
                    chrome? { spec{bar,fold,minimize,maximize}, minimized, maximized,
                              bar_rect, bar_height, buttons{fold,minimize,maximize} },
                    ratios?, resizable?, separators? } },
  slots { id -> state },
  nav { ..., folded, minimized, maximized, last_chrome, split_ratio } }

— the whole shell as observable data for robots, and deliberately as geometry: a chrome control that flips a flag without moving a rect fails the tests in f6.

It also carries devid — facett_core::devid::gate_json(), the dev-id chip's gate as { enabled, env, debug_assertions, source } — so a headless oracle can say why there are no chips without anyone opening a window. source names who decided: "env" ($FACETT_DEVID was set and understood), "profile" (the build profile did), or "forced" (a test called devid::force_gate). The third value earns its place: a forced gate reporting "profile" would be a dump asserting this build's profile said something it did not, which is the confident-wrong-answer this field exists to prevent. force_gate exists because the chip is off by default in a release build, and facett-app/tests/devid_scene_slots.rs asks a question about the CONTAINER — does scene::render paint a chip per pane — whose answer is the same in both profiles. Without it the file went red under cargo test --release (4 of 5 tests, 2026-08-31) for a reason that had nothing to do with the scene. It changes no default: the profile rule keeps its own guards in facett_core::devid::resolve_enabled and in facett-app/tests/devid_release_profile.rs, which builds a real --release binary and proves the chip stays off until the env hatch opens it.

A tabbed pane (Layout::Tabs) additionally publishes the header geometry a robot driver needs to resolve and click a tab: workspace_tabs (the tab labels/ids), tab_rects (one clickable header rect per body-tab), and sep_rects (the tab separators). tab_rects is additive (no behaviour change to the shipped scene) — it was added so dwarves-demo's visible robot-watch mode can click tab headers by rect the same way it already clicks separators.

f4 — responsive (desktop ↔ phone)

render picks a Device (phone < 600pt, tablet < 900pt, else desktop) from ui.available_width(), honouring a pane's collapse_below override (below it ⇒ phone). Two collapse behaviours, both declarative overrides that re-interpret the ONE canonical tree (no second layout):

f6 — per-pane WINDOW CHROME (minimise · maximise · restore) — ON BY DEFAULT

Why it is on by default (the decision, stated plainly)

Every pane in a scene except the scene root draws a chrome title bar carrying a fold toggle, a minimise control and a maximise/restore control. No host opts in; a pane that must stay bare opts out with Pane::no_chrome().

That default is the whole point. Between the split ratios, the folds and the responsive overrides, the scene layer already had the machinery for a real tiling-window shell — and none of it reached an operator's screen, because reaching it needed a host to know a flag existed and set it. A window affordance that ships default-off is a window affordance nobody ever discovers. So the resolution is inverted: capability is default-ON, and suppressing it is the deliberate, named act.

The cost is bounded and paid for: Chrome::Auto is #[serde(skip_serializing_if)], so no existing scene's RON changed by a byte, and an opted-out Foldable pane falls back to the exact pre-chrome CollapsingHeader.

Chrome title bar fold minimise maximise
Auto (default) every pane but the scene root if Foldable ✓ ✓
Always always, root included if Foldable ✓ ✓
FoldOnly every pane but the root if Foldable — —
None — (falls back to the plain CollapsingHeader) — — —

Fold vs minimise — one header, two collapses (L5: extend, never twin)

scene already had a fold. Chrome does not grow a second header beside it: the chrome bar hosts the fold toggle, so a Foldable pane still has exactly ONE header row. What chrome adds is the collapse the fold could not express:

body the pane's slot in the layout
fold (authored, Fold::Foldable) hidden kept — the section shrinks in place
minimise (runtime, chrome) hidden handed back — siblings take the space

Both are visible in one number: content_height goes to 0.0 for either, while alloc stays at the pane's full share for a fold and drops to CHROME_BAR for a minimise.

The geometry — chrome moves rects, it never rewrites ratios

split_extents(nav, kids, ratios, total, max_child) is a pure function and carries the whole model:

Robot addressing (L4)

Each control publishes both addressings, so neither a label change nor a layout change can strand a robot:

nav.last_chrome reads back which control fired ("maximize:body"), and Scene::affordances() is the adoption audit — {chrome:[…], draggable_splits:[…], foldable:[…]} — a host test asserts against instead of re-deriving.

Draggable dividers, made the default path

The handle existed but was easy to miss twice over: hand-authored RON had to spell resizable: true, and the painted hairline did not read as grabbable. Both fixed: Layout::Split.resizable now defaults to true in serde (Pane::split_fixed is the deliberate, named way to be static), and the handle paints a three-dot grip at the centre of its gutter over a brighter line.

f5 — the facett-demo adoption recipe (holger / nornir / dwarves / korp follow this)

facett-demo/src/scene_demo.rs is the reference consumer — a canonical Facet (title = "scene") that owns a Scene + NavState + its slot widgets:

  1. Describe the shell with the builder (shell_scene()): a horizontal draggable Split (a .drawer(720) activity rail | a vertical draggable Split of a foldable System Map canvas over a Tabs of foldable stats/trend), .phone(Tabs, 720), a View/Go menubar, two routes.
  2. Host each slot: implement SceneHost::slot(id, ui) to draw a real component (sysmap → the 🫧 real-liquid SystemMap (demo_systemmap_liquid()), stat → StatGridView, log → LogView, plot → PlotView) and return its state_json. command(id) handles menu commands.
  3. Render in Facet::ui: split the struct's fields (disjoint borrows), build the host over the slot widgets, call scene::render(&scene, &mut nav, ui, &mut host), cache the returned state_json.
  4. Observe in Facet::state_json: the pane tree, which panes are foldable / drawers / draggable splits, the live folded + split_ratio maps, and the last render facts.

The existing System Map deck tab is untouched (L2 additive); the scene's foldable canvas hosts its OWN live SystemMap.

Web-research provenance (ideas taken, and why)

The interaction model was cross-checked against egui_dock, Dear ImGui docking, VS Code, Golden Layout, FlexLayout, dockview and i3/sway. Adopted, kept deliberately simple:

Deliberately AVOIDED as over-complex for an immediate-mode data layer: floating windows / free docking + drag-to-dock (Dear ImGui / dockview), pixel + min/max constraint solving (Golden Layout), and Dear ImGui's imperative DockBuilder + INI persistence.

Bench (L3) — was default-ON affordable?

facett.scene_chrome_frame (bench/examples/nornir-bench.rs) renders the SAME balanced split tree twice per size — once with the default chrome, once with every pane .no_chrome() — and reports both, plus the share of a 60fps frame budget (16666667 ns) the scene walk costs. Host: oden, NVIDIA RTX 4090 / 32 physical cores / SMT off / 499 GB RAM, --release, contended (sibling agents benching concurrently) — the Loki bencher is the authoritative run.

size panes chrome ON ns chrome OFF ns ns/pane overhead % of a 60fps frame
shell (the real shape) 31 456035 210409 14711 116.7% 2.7%
deep (tripwire) 1023 29390012 10018404 28729 193.4% 176.3%

The honest reading: chrome roughly doubles the scene walk, because every pane now draws two or three real buttons and a label it did not draw before. At the size a shell actually is, that walk is 2.7% of a frame — the feature is affordable and the default stands. At 1023 panes it is not affordable, and it should not be: that tree is 1023 title bars, and the answer there is Chrome::None on the container panes.

Three allocations were cut on the way to those numbers (they had put the shell figure at more than twice this): the per-pane Visuals clone (a large struct, cloned once per pane per frame for two colours), the eager format! behind every control's AccessKit label (now built lazily inside the widget_info closure, so a frame with no AccessKit consumer allocates nothing), and the title's format! when the pane has no fold icon.

Tests (L4, recorded matrix rows)