facett-app scene — the declarative UI-scene layer — design
Class:
design· Function axis:f0(an app's UI as data) ·f1schema + RON/builder ·f2NavState-over-Router ·f3render over facett-app primitives ·f4draggable splits + responsive ·f5facett-demo adoption ·f6per-pane WINDOW CHROME (minimise / maximise / restore) — ON BY DEFAULT. Strict: this file states only the shape we run. It lives infacett-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:
- RON:
Scene::from_ron(text)/Scene::to_ron().to_ronuses a FIXEDPrettyConfig, so parse ∘ serialize is a byte-stable fixed point (the RED-when-brokenron_round_trips_byte_stabletest). - Builder:
Pane::flow/split/split_fixed/split_n/grid/tabs/slot(...)+.title().foldable_icon(...).resizable(bool).phone(...).drawer(cb),MenuItem::new().action().items(),Scene::new(name, root).menu(...).route(...).Pane::splitis draggable by default (split_fixedfor a static divider,split_nfor N children).
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:
- menubar →
facett_menu::menu_bar(the nestedMenuItemtree flattens to facett-menuCommands: top-level label = group, leaf name = id). A click fires the matching menu route, else the item's ownaction. - each Layout maps to a facett-app / egui primitive:
Flow→horizontal_wrapped,Grid→egui::Grid,Tabs→ facett-appTabView(the active index lives inNavState, a header click mutates it), andSplit→ a custom N-way splitter (below). - each
Slot(id)→host.slot(id, ui): the host draws a real widget and returns itsstate_json, folded into the scene state.
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):
- Layout swap. Per pane the effective layout is
responsive.phone/tabletif set, else the pane's ownlayout. So aSplitwith.phone(Layout::Tabs{..}, 720.0)renders as Tabs under 720pt (split_with_phone_override_renders_as_tabs_when_narrow). - Rail → drawer. A
Foldablepane marked.drawer(cb)auto-folds when the width crosses INTO phone-class and restores (to itsdefault_open) crossing back — applied only on a device-class transition (tracked byNavState.last_device), so the user can still open/close the drawer while on a phone.
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:
- maximise — at most ONE pane scene-wide (
NavState.maximized, window-manager semantics).renderresolves its ancestor chain once per frame; at every split the child on that chain takes the entire extent and every sibling is allotted exactly0.0(recorded withhidden_by: "maximize"and a real zero-extent rect, so it is measurable, not inferred). The dividers stand down — nothing is left to apportion. InsideTabs, maximising a pane on an inactive tab brings that tab to the front. - minimise — the child is allotted exactly
CHROME_BAR(22pt) along the axis; the remainder is re-apportioned among the rest in their unchanged ratios. In a horizontal split the collapsed child becomes a narrow rail drawing only its restore control, so a minimised pane is always recoverable. - restore — because neither operation ever writes
split_ratio, restoring returns the identical rects, not merely similar ones.
Robot addressing (L4)
Each control publishes both addressings, so neither a label change nor a layout change can strand a robot:
- a rect, at
panes.<name>.chrome.buttons.{fold,minimize,maximize}— click its centre; - a unique AccessKit label —
"minimize body","maximize body","restore body","fold body"/"unfold body"(set viaResponse::widget_info, so the visible glyph stays a glyph).
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:
- Describe the shell with the builder (
shell_scene()): a horizontal draggableSplit(a.drawer(720)activity rail | a vertical draggableSplitof a foldable System Map canvas over aTabsof foldable stats/trend),.phone(Tabs, 720), aView/Gomenubar, two routes. - Host each slot: implement
SceneHost::slot(id, ui)to draw a real component (sysmap→ the 🫧 real-liquidSystemMap(demo_systemmap_liquid()),stat→StatGridView,log→LogView,plot→PlotView) and return itsstate_json.command(id)handles menu commands. - Render in
Facet::ui: split the struct's fields (disjoint borrows), build the host over the slot widgets, callscene::render(&scene, &mut nav, ui, &mut host), cache the returnedstate_json. - Observe in
Facet::state_json: the pane tree, which panes are foldable / drawers / draggable splits, the livefolded+split_ratiomaps, 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:
- Relative per-child ratios as the whole resize model (FlexLayout
weight/ egui_dockfraction): onef32per child, proportions only — trivially serializable and resolution-independent. A drag just mutates two floats. - Split-tree of splits + tabs-at-leaves (egui_dock / i3): splits nest binary-or-N-way; tabs are a leaf layout, not a separate node kind.
- Responsive as override rules over one canonical tree (VS Code / FlexLayout): a breakpoint re-interprets a
SplitasTabs; it does not build a second tree. - Named peripheral slot → drawer (VS Code sidebar / FlexLayout borders): the rail is a pane whose phone override is "fold into a drawer", not tree surgery.
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)
- Window chrome (f6), all rect-level —
chrome_is_on_by_default_for_every_pane_but_the_root,maximizing_a_pane_shrinks_its_siblings_to_zero_area(siblingallocheight ==0.0,area== 0, dividers gone),restoring_returns_the_exact_prior_rects_and_ratios(rects compared byte-for-byte after the ratios were dragged off their authored values),minimizing_leaves_only_the_title_bar_and_feeds_its_siblings(allocheight ==CHROME_BAR, extent conserved, the siblings' 0.3:0.2 proportion survives),maximize_reaches_a_pane_nested_inside_tabs_inside_a_split,a_real_click_on_the_maximize_control_changes_the_drawn_geometry,a_real_click_on_the_minimize_control_collapses_and_restores_the_pane,split_extents_apportion_minimise_and_maximise(the pure function, no GUI),ron_split_without_resizable_is_draggable_by_default,menu_actions_and_routes_drive_the_chrome,chrome_glyphs_are_renderable(no tofu). Plusfacett-app/tests/robot_chrome.rs— a REALnornir-robotui0.2RobotSession, 17(action → expected)steps → 17robot-uiTestResultRows through aJsonFileSink, every chrome step asserting a rect out ofstate_json. RED-when-broken, verified by mutation: maximise not zeroing siblings → 5 red; minimise flipping only its flag → 3 red; chrome resolving to default-off → 5 red. facett-app(scene_tests.rs, all green in the worktree):ron_round_trips_byte_stable(RED-when-broken),ron_literal_parses_to_the_builder_scene,fold_omits_contents_unfold_reveals_them(kittest: folded OMITS the slot from the AccessKit tree),split_with_phone_override_renders_as_tabs_when_narrow,split_ratio_is_live_and_drives_the_divider,dragging_the_split_handle_reapportions_the_ratio(a REAL pointer drag on the handle),n_way_split_weights_normalize_and_all_children_render,a_drawer_pane_folds_on_phone_and_restores_on_desktop,nav_push_pop_toggle_and_deeplink_resolve_by_name,a_menu_trigger_fires_its_route,scene_renders_and_reports_every_pane,clicking_the_fold_header_toggles_the_pane.facett-demo:shell_scene_is_valid_and_all_content_panes_are_foldable,shell_scene_ron_round_trips_byte_stable,folding_a_pane_is_observable,the_master_detail_splits_are_draggable_with_live_ratios,the_activity_rail_is_a_drawer, plus the strengthenedscenecell in the coverage-gatedmega_matrix(asserts draggable splits + drawer) and thescenetab's row inrobot_demo. NOTE: facett-demo's authoritative build/test runs on the CANONICAL facett checkout — the knut↔facett path cycle (knut-pipelines→../../../facett/facett-pipeline) forces a second facett-core tree in a worktree, so facett-demo cannot be built from a worktree.