src/viz/shell_scene.rs
//! **The Urðr-Threads master shell, described DECLARATIVELY.** nornir's adoption
//! of facett-app's [`scene`](facett_app::scene) layer: instead of hand-wiring the
//! right detail `SidePanel`, the bottom action-log `TopBottomPanel`, and the
//! central pane dispatch, the shell's master/detail LAYOUT is a
//! [`Scene`](facett_app::scene::Scene) value (a menubar + a tree of panes + nav
//! routes) rendered by [`facett_app::scene::render`] through facett-app's OWN
//! primitives (Router / TabView / facett-menu). Authored 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
//! [`Split`](facett_app::scene::Layout::Split)s have real drag handles; dragging
//! the workspace|detail divider (or the panes|log divider) re-apportions the live
//! ratio (kept in `NavState`, observable in `state_json`).
//! - **Fold / unfold** — the 🔍 Time-machine inspector and the 🐞 action-log are
//! [`Foldable`](facett_app::scene::Fold::Foldable); folding draws only the header
//! (contents leave the layout AND the AccessKit tree).
//! - **Rail → drawer** — the action-log is a `.drawer(760)` that auto-folds when the
//! width crosses into phone-class and restores above it.
//! - **Responsive desktop ↔ phone** — under 760pt the master/detail `Split`
//! collapses to **Tabs**.
//!
//! The central `panes` slot is filled by the host with the ACTIVE tab's pane (the
//! 28 [`Tab`] panes, addressed by [`Tab::id`]); `detail` hosts the Time-machine
//! inspector and `action_log` the action trail. Nothing is forked: the scene
//! DRIVES the existing panes (the top-bar tab strip stays the `facett_ui::TabBar`
//! menubar host, per the migration brief). See `.nornir/scene-design.md`.
//!
//! # Stage 2 — the tab INTERNALS are panes too
//!
//! Adopting the scene at the SHELL level left every tab body a single opaque pane:
//! minimise / maximise / drag operated on the whole tab and could do **nothing
//! inside it**. A stats readout that grew pushed the canvas off the bottom, with no
//! divider to adjust, because both shared one imperative vertical flow.
//!
//! So a tab whose body is honestly master/detail declares that structure — its
//! `panes` position is replaced by a DRAGGABLE split of two named, chromed slots:
//!
//! ```text
//! <Tab>_body ── Split(H|V, ratio, DRAGGABLE)
//! ├─ <Tab>_main ← the graph / chart / table / results
//! └─ <Tab>_inspector ← controls, stats, legend, selection detail (drawer@760)
//! ```
//!
//! [`body_split`] is the ONE table saying which tabs are decomposed and how; a tab
//! absent from it is honestly ONE pane and keeps the single `panes` slot (forcing a
//! split with no master/detail is worse than leaving it). Chrome (fold · minimise ·
//! maximise) comes free — `Chrome::Auto` is the facett-app default, so every named
//! pane gains the window controls the moment it exists.
use facett_app::scene::{Action, Axis, Fold, Layout, MenuItem, Pane, Route, Scene, Target};
use super::app::Tab;
/// The slot id of the central pane the host fills with the ACTIVE tab's content.
/// (The host delegates it to the active [`Tab::id`], so every tab is itself a
/// host-filled slot — see [`tab_slot_ids`].)
pub const ACTIVE_PANE_SLOT: &str = "active_pane";
/// The slot id of the right master/detail inspector (the "Time machine" panel).
pub const DETAIL_SLOT: &str = "detail";
/// The slot id of the bottom action-log drawer.
pub const ACTION_LOG_SLOT: &str = "action_log";
/// The unique pane name of the DRAGGABLE master/detail split (workspace | detail).
pub const ROOT_SPLIT: &str = "root";
/// The unique pane name of the DRAGGABLE panes | action-log split.
pub const WORKSPACE_SPLIT: &str = "workspace";
/// The unique pane name of the central panes pane.
pub const PANES: &str = "panes";
/// The phone-class collapse breakpoint (points of available width) shared by the
/// root master/detail split (→ Tabs) and the action-log drawer (→ auto-fold).
pub const PHONE_BREAKPOINT: f32 = 760.0;
/// The initial master fraction of the workspace | detail split (the detail
/// inspector gets the rest — ~26%, mirroring the old 360pt `SidePanel`).
pub const DETAIL_MASTER_RATIO: f32 = 0.74;
/// The initial fraction of the panes | action-log split (the log drawer gets the
/// rest — ~18%, mirroring the old 160pt bottom panel).
pub const LOG_MASTER_RATIO: f32 = 0.82;
/// The host-filled tab slot ids — one per [`Tab::ALL`] variant (== [`Tab::id`]).
/// The central `active_pane` slot delegates to whichever of these is active, so
/// every tab is a `Content::Slot(Tab::id())` the [`SceneHost`](facett_app::scene::SceneHost)
/// fills with the tab's real pane widget.
pub fn tab_slot_ids() -> Vec<&'static str> {
Tab::ALL.iter().map(|t| t.id()).collect()
}
/// **The master/detail decomposition of ONE tab's internals** — the declarative
/// replacement for a tab body that used to be one imperative `egui` flow (or an
/// internal `SidePanel` / `TopBottomPanel` the shell could not see).
///
/// `ratio` is the MAIN half's seed fraction; the inspector gets the rest. The
/// inspector is a [`drawer`](Pane::drawer) at [`PHONE_BREAKPOINT`], so a narrow
/// window gives the whole tab to the canvas instead of a sliver of each.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BodySplit {
/// Divider orientation — `Horizontal` for canvas │ inspector, `Vertical` for
/// canvas ── over ── readout.
pub axis: Axis,
/// The MAIN half's seed fraction of the tab body (the inspector gets `1 - ratio`).
pub ratio: f32,
/// Is the MAIN half the FIRST child (left / top)? False puts the inspector first —
/// the honest order for a controls-over-results tab, where a query strip belongs
/// above its hits and a rail of runs belongs left of the run it selects. The
/// declared `ratio` still means the MAIN half either way.
pub main_first: bool,
/// The main pane's chrome-bar title.
pub main_title: &'static str,
/// The inspector pane's chrome-bar title.
pub inspector_title: &'static str,
/// The inspector's fold icon.
pub inspector_icon: &'static str,
}
impl BodySplit {
/// The seed ratios in CHILD order (`[first, second]`), honouring [`Self::main_first`].
pub fn child_ratios(&self) -> [f32; 2] {
if self.main_first {
[self.ratio, 1.0 - self.ratio]
} else {
[1.0 - self.ratio, self.ratio]
}
}
}
/// Which half of a decomposed tab body a slot id addresses.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BodyPart {
/// The `<Tab>_main` half — the graph / chart / table / results.
Main,
/// The `<Tab>_inspector` half — controls, stats, legend, selection detail.
Inspector,
}
/// The `<Tab>_body` pane name — the DRAGGABLE split a decomposed tab's internals become.
pub fn body_pane_name(tab: Tab) -> String {
format!("{}_body", tab.id())
}
/// The `<Tab>_main` slot id (== its pane name): the master half of a decomposed tab.
pub fn main_slot_id(tab: Tab) -> String {
format!("{}_main", tab.id())
}
/// The `<Tab>_inspector` slot id (== its pane name): the detail half of a decomposed tab.
pub fn inspector_slot_id(tab: Tab) -> String {
format!("{}_inspector", tab.id())
}
/// Resolve a host slot id back to `(tab, half)` — the inverse of [`main_slot_id`] /
/// [`inspector_slot_id`], used by the app's `SceneHost` to route a split slot to the
/// right half of the right tab. `None` for anything that isn't a body-split slot.
pub fn body_slot(id: &str) -> Option<(Tab, BodyPart)> {
if let Some(name) = id.strip_suffix("_main") {
return Tab::from_name(name).map(|t| (t, BodyPart::Main));
}
if let Some(name) = id.strip_suffix("_inspector") {
return Tab::from_name(name).map(|t| (t, BodyPart::Inspector));
}
None
}
/// **The decomposition table: which tabs have an honest master/detail body, and how
/// it splits.** `None` ⇒ the tab is honestly ONE pane and keeps the single `panes`
/// slot — a forced split with no master/detail is worse than no split at all.
///
/// Every entry here corresponds to structure the tab body ALREADY had implicitly
/// (an internal `SidePanel` / `TopBottomPanel`, or a list-then-detail flow); the
/// split makes it explicit, draggable, foldable and minimisable, and puts both
/// halves' rects into `$NORNIR_VIZ_STATE`.
pub fn body_split(tab: Tab) -> Option<BodySplit> {
match tab {
// 🗄 Warehouse — `SidePanel::left("wh_tables")` (240pt) + a CentralPanel
// preview/chart. The textbook table-list │ preview master/detail.
Tab::Warehouse => Some(BodySplit {
axis: Axis::Horizontal,
ratio: 0.24,
main_first: true,
main_title: "Tables",
inspector_title: "Preview",
inspector_icon: "🔎",
}),
// 🗺 Knowledge — **the bug, named.** The per-crate summary table and the
// bubble chart shared ONE vertical flow, so a workspace with many crates grew
// the table until it pushed the chart off the bottom, with no divider to
// adjust. Now: the table is the FIRST child (a controls-and-stats strip
// belongs above its canvas), the chart is the main half below it, and the
// divider between them is draggable — or the table minimises to a title bar.
Tab::Knowledge => Some(BodySplit {
axis: Axis::Vertical,
ratio: 0.62,
main_first: false,
main_title: "Bubble chart",
inspector_title: "Scanned crates",
inspector_icon: "📊",
}),
// 📡 Live Run — the release-run feed and the build-info live-tail were TWO
// independent scrolling feeds separated by a bare `ui.separator()` in one
// vertical flow. Whichever grew took the other's room and there was nothing to
// drag. They are two panes; now they say so.
Tab::LiveRun => Some(BodySplit {
axis: Axis::Vertical,
ratio: 0.62,
main_first: true,
main_title: "Run feed",
inspector_title: "Build info",
inspector_icon: "📡",
}),
_ => None,
}
}
/// Every tab whose body is a declared master/detail split, in [`Tab::ALL`] order —
/// the roster the rect oracles walk. Its complement is the honestly-single-pane set.
pub fn decomposed_tabs() -> Vec<Tab> {
Tab::ALL
.into_iter()
.filter(|t| body_split(*t).is_some())
.collect()
}
/// The pane occupying the shell's central workspace position for `tab`: either the
/// tab's DRAGGABLE master/detail body split (when [`body_split`] declares one) or
/// the single [`PANES`] slot the host fills with the whole tab pane.
fn tab_body_pane(tab: Tab) -> Pane {
let Some(s) = body_split(tab) else {
return Pane::slot(PANES, ACTIVE_PANE_SLOT).title("Workspace");
};
let main = Pane::slot(main_slot_id(tab), main_slot_id(tab))
.title(s.main_title)
.foldable(true);
let inspector = Pane::slot(inspector_slot_id(tab), inspector_slot_id(tab))
.title(s.inspector_title)
.foldable_icon(true, s.inspector_icon)
.drawer(PHONE_BREAKPOINT);
let ratios = s.child_ratios();
let children = if s.main_first {
vec![main, inspector]
} else {
vec![inspector, main]
};
// **The body container carries NO chrome, deliberately.** It is pure layout: its
// two halves own the fold / minimise / maximise controls, and maximising the
// container that already fills the tab means nothing. It also may not carry a
// TITLE: a chrome bar titled `tab.label()` puts a second AccessKit node reading
// "🧪 Test" on screen beside the tab-strip button of the same name, and a robot
// that clicks by label then finds two matches and can address neither. (holger's
// shell hit the identical hazard with a rail named after a menu.)
Pane::split_n(body_pane_name(tab), s.axis, ratios.to_vec(), children).no_chrome()
}
/// Build the Urðr-Threads master shell as a declarative [`Scene`] (the Rust-builder
/// form). Byte-stable through RON.
///
/// Layout tree (splits are DRAGGABLE; the detail + log panes foldable):
/// ```text
/// root ─ Split(H, 0.74, draggable) [phone → Tabs under 760pt]
/// ├─ workspace ─ Split(V, 0.82, draggable)
/// │ ├─ panes (Fixed) slot:active_pane — the ACTIVE tab's pane (28 Tab slots)
/// │ └─ action_log (Foldable 🐞, drawer@760) slot:action_log — the action trail
/// └─ detail (Foldable 🔍) slot:detail — the "Time machine" inspector
/// ```
///
/// This is the shell with the tab body UNDECOMPOSED (the single `panes` slot). The
/// live app renders [`nornir_shell_scene_for`] instead, which swaps that position for
/// the ACTIVE tab's master/detail body split when it declares one.
pub fn nornir_shell_scene() -> Scene {
shell_around(Pane::slot(PANES, ACTIVE_PANE_SLOT).title("Workspace"))
}
/// The shell scene for the ACTIVE `tab` — identical to [`nornir_shell_scene`] except
/// that the central workspace position carries that tab's declared master/detail body
/// split (see [`body_split`]) instead of the single `panes` slot.
///
/// The pane NAMES are per-tab (`<Tab>_body` / `<Tab>_main` / `<Tab>_inspector`), so
/// each tab keeps its OWN live fold + split ratio across tab switches: dragging the
/// 🗄 Warehouse divider cannot move the 🔍 Search one.
pub fn nornir_shell_scene_for(tab: Tab) -> Scene {
shell_around(tab_body_pane(tab))
}
/// The shell (menubar + routes + the detail / action-log panes) wrapped around
/// whichever pane occupies the central workspace position.
fn shell_around(panes: Pane) -> Scene {
// The action-log 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). Starts folded (the top-bar 🐞 toggle drives it), matching the old
// hidden-by-default bottom panel.
let action_log = Pane::slot(ACTION_LOG_SLOT, ACTION_LOG_SLOT)
.title("Action trail")
.foldable_icon(false, "🐞")
.drawer(PHONE_BREAKPOINT);
let workspace = Pane::split(
WORKSPACE_SPLIT,
Axis::Vertical,
LOG_MASTER_RATIO,
vec![panes, action_log],
)
.title("Workspace + log");
// The Time-machine inspector: the right master/detail pane, foldable so the
// operator can reclaim the width for the central pane.
let detail = Pane::slot(DETAIL_SLOT, DETAIL_SLOT)
.title("Time machine")
.foldable_icon(true, "🔍");
let root = Pane::split(
ROOT_SPLIT,
Axis::Horizontal,
DETAIL_MASTER_RATIO,
vec![workspace, detail],
)
.title("Urðr Threads")
// Responsive: on a phone-class width the master/detail split collapses to Tabs.
.phone(Layout::Tabs { active: 0 }, PHONE_BREAKPOINT);
Scene::new("urdr-threads", root)
.menu(MenuItem::new("view", "View").items(vec![
MenuItem::new("view.detail", "Toggle Time machine")
.shortcut("Ctrl+I")
.action(Action::Toggle(DETAIL_SLOT.into())),
MenuItem::new("view.log", "Toggle Action trail")
.shortcut("Ctrl+L")
.action(Action::Toggle(ACTION_LOG_SLOT.into())),
MenuItem::new("view.reload", "Reload")
.shortcut("Ctrl+R")
.action(Action::Command("refresh".into())),
]))
.menu(MenuItem::new("go", "Go").items(vec![
MenuItem::new("go.home", "Home (nornir)")
.shortcut("Ctrl+1")
.action(Action::Command("go:Nornir".into())),
MenuItem::new("go.release", "Release")
.shortcut("Ctrl+2")
.action(Action::Command("go:Release".into())),
MenuItem::new("go.test", "Test")
.shortcut("Ctrl+3")
.action(Action::Command("go:Test".into())),
MenuItem::new("go.warehouse", "Warehouse")
.shortcut("Ctrl+4")
.action(Action::Command("go:Warehouse".into())),
]))
.menu(MenuItem::new("help", "Help").items(vec![
MenuItem::new("help.about", "About Urðr Threads")
.shortcut("F1")
.action(Action::Command("about".into())),
]))
// Declared routes (over the shared Router): a shortcut + a button focus the
// Time-machine inspector, and the Go menu records nav history on the panes.
.route(Route::on_shortcut(
"Ctrl+I",
Target::Toggle(DETAIL_SLOT.into()),
))
.route(Route::on_button(
"focus-detail",
Target::Focus(DETAIL_SLOT.into()),
))
.route(Route::on_menu("go.release", Target::Show(PANES.into())))
}
/// The names of every foldable pane in the shell (for coverage assertions).
pub fn foldable_panes(scene: &Scene) -> Vec<String> {
scene
.pane_names()
.into_iter()
.filter(|n| matches!(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(scene: &Scene) -> Vec<String> {
scene
.pane_names()
.into_iter()
.filter(|n| 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(scene: &Scene) -> Vec<String> {
scene
.pane_names()
.into_iter()
.filter(|n| {
matches!(
scene.pane(n).map(|p| &p.layout),
Some(Layout::Split {
resizable: true,
..
})
)
})
.collect()
}
/// The names of every pane that draws **window chrome** — a title bar carrying
/// *minimise* and *maximise/restore* beside the fold toggle. Chrome is ON by default
/// in facett-app (`Chrome::Auto`) for every pane but the scene root, so a pane missing
/// from this list opted OUT; publishing it makes "the chrome silently disappeared" a
/// red assertion rather than a shrug. Delegates to facett-app's own resolver (L5).
pub fn chrome_panes(scene: &Scene) -> Vec<String> {
scene
.pane_names()
.into_iter()
.filter(|n| {
let spec = scene.chrome_of(n);
spec.minimize && spec.maximize
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use facett_app::scene::NavState;
#[test]
fn shell_scene_is_valid_with_unique_names() {
let scene = nornir_shell_scene();
scene.validate().expect("unique pane + menu names");
// The master/detail + panes/log panes are addressable by their stable names.
for want in [
ROOT_SPLIT,
WORKSPACE_SPLIT,
PANES,
DETAIL_SLOT,
ACTION_LOG_SLOT,
] {
assert!(scene.pane(want).is_some(), "the `{want}` pane exists");
}
}
#[test]
fn shell_scene_ron_round_trips_byte_stable() {
// RED-when-broken: parse ∘ serialize is a fixed point (a schema drift or a
// non-deterministic serialize breaks this).
let scene = nornir_shell_scene();
let ron1 = scene.to_ron();
let reparsed = Scene::from_ron(&ron1).expect("the shell parses back from its own RON");
assert_eq!(ron1, reparsed.to_ron(), "the shell's RON is byte-stable");
assert_eq!(
scene, reparsed,
"RON and the builder yield the SAME shell Scene"
);
}
#[test]
fn the_detail_and_log_panes_are_foldable() {
let scene = nornir_shell_scene();
let foldable = foldable_panes(&scene);
for want in [DETAIL_SLOT, ACTION_LOG_SLOT] {
assert!(
foldable.contains(&want.to_string()),
"the `{want}` pane is foldable"
);
}
}
#[test]
fn folding_the_detail_pane_is_observable() {
// RED-when-broken: a fold must round-trip through NavState (the render + the
// AccessKit tree read it).
let scene = nornir_shell_scene();
let mut nav = NavState::new(&scene);
assert!(
!nav.is_folded(DETAIL_SLOT),
"the Time-machine inspector starts unfolded"
);
assert!(nav.toggle_fold(DETAIL_SLOT), "fold it");
assert!(
nav.is_folded(DETAIL_SLOT),
"the fold state is observable in NavState"
);
}
#[test]
fn the_master_detail_splits_are_draggable_with_live_ratios() {
// RED-when-broken: both the master/detail split and the panes/log split must
// be DRAGGABLE (resizable), with live, observable, seeded ratios.
let scene = nornir_shell_scene();
let splits = draggable_splits(&scene);
assert!(
splits.contains(&ROOT_SPLIT.to_string()),
"the workspace|detail split is draggable"
);
assert!(
splits.contains(&WORKSPACE_SPLIT.to_string()),
"the panes|log split is draggable"
);
let nav = NavState::new(&scene);
let root = nav.split_ratios(ROOT_SPLIT).expect("root has a live ratio");
assert!(
(root[0] - DETAIL_MASTER_RATIO).abs() < 1e-5,
"root seeds the master fraction, got {root:?}"
);
let ws = nav
.split_ratios(WORKSPACE_SPLIT)
.expect("workspace has a live ratio");
assert!(
(ws[0] - LOG_MASTER_RATIO).abs() < 1e-5,
"workspace seeds the panes fraction, got {ws:?}"
);
}
#[test]
fn dragging_the_master_detail_divider_reapportions_the_live_ratio() {
// RED-when-broken: a programmatic drag (set_split_ratios) mutates the live
// ratio kept in NavState — the same state a real handle-drag writes back.
let scene = nornir_shell_scene();
let mut nav = NavState::new(&scene);
nav.set_split_ratios(ROOT_SPLIT, vec![0.5, 0.5]);
let r = nav.split_ratios(ROOT_SPLIT).expect("ratio present");
assert!(
(r[0] - 0.5).abs() < 1e-5 && (r[1] - 0.5).abs() < 1e-5,
"the dragged ratio is live, got {r:?}"
);
}
#[test]
fn the_action_log_is_a_drawer_at_the_phone_breakpoint() {
let scene = nornir_shell_scene();
assert!(
drawer_panes(&scene).contains(&ACTION_LOG_SLOT.to_string()),
"the action-log is a drawer"
);
assert_eq!(
scene
.pane(ACTION_LOG_SLOT)
.unwrap()
.responsive
.collapse_below,
Some(PHONE_BREAKPOINT),
"the drawer collapses at the same breakpoint the root split does"
);
}
#[test]
fn the_root_split_collapses_to_tabs_on_a_phone_width() {
let scene = nornir_shell_scene();
let root = scene.pane(ROOT_SPLIT).unwrap();
assert!(
matches!(root.responsive.phone, Some(Layout::Tabs { .. })),
"phone override is Tabs"
);
assert_eq!(root.responsive.collapse_below, Some(PHONE_BREAKPOINT));
}
#[test]
fn every_tab_is_a_host_slot_id() {
// The 28 Tab panes are the host-filled slots the central `active_pane`
// delegates to — one per Tab::ALL variant, matching Tab::id().
let ids = tab_slot_ids();
assert_eq!(
ids.len(),
Tab::ALL.len(),
"one slot id per Tab::ALL variant"
);
for t in Tab::ALL {
assert!(ids.contains(&t.id()), "Tab::{t:?} has a slot id");
}
}
#[test]
fn the_menubar_carries_actions_shortcuts_and_routes() {
let scene = nornir_shell_scene();
assert_eq!(scene.menubar.len(), 3, "View / Go / Help menus");
let reload = scene.menu_item("view.reload").expect("a Reload menu item");
assert_eq!(
reload.shortcut.as_deref(),
Some("Ctrl+R"),
"the Reload item hints its shortcut"
);
assert!(
matches!(reload.action, Some(Action::Command(_))),
"Reload fires a host command"
);
assert!(!scene.routes.is_empty(), "the shell declares nav routes");
}
// ── Stage 2: the tab INTERNALS are panes ────────────────────────────────────
/// A decomposed tab's body is a DRAGGABLE split of two uniquely-named slots, the
/// shell around it is unchanged, and the whole scene still validates (no name
/// collides with a shell pane). RED-when-broken if a body split is downgraded to
/// `split_fixed` or a half loses its own name.
#[test]
fn a_decomposed_tab_body_is_a_draggable_split_of_two_named_slots() {
for tab in decomposed_tabs() {
let s = nornir_shell_scene_for(tab);
s.validate()
.unwrap_or_else(|e| panic!("Tab::{tab:?} scene has unique names: {e}"));
let body = body_pane_name(tab);
let want = body_split(tab).expect("a decomposed tab has a split");
match s.pane(&body).map(|p| &p.layout) {
Some(Layout::Split {
axis,
ratios,
resizable: true,
}) => {
assert_eq!(*axis, want.axis, "`{body}` splits along the declared axis");
let seed = want.child_ratios();
assert!(
(ratios[0] - seed[0]).abs() < 1e-5 && (ratios[1] - seed[1]).abs() < 1e-5,
"`{body}` seeds its declared fractions {seed:?}, got {ratios:?}"
);
assert!(
(ratios.iter().sum::<f32>() - 1.0).abs() < 1e-5,
"ratios sum to 1"
);
}
other => panic!("`{body}` is a DRAGGABLE split, got {other:?}"),
}
// The declared child ORDER: `main_first` decides which half is left/top.
let kids = match &s.pane(&body).expect("body").content {
facett_app::scene::Content::Children(k) => {
k.iter().map(|p| p.name.clone()).collect::<Vec<_>>()
}
other => panic!("`{body}` holds children, got {other:?}"),
};
let expect_order = if want.main_first {
vec![main_slot_id(tab), inspector_slot_id(tab)]
} else {
vec![inspector_slot_id(tab), main_slot_id(tab)]
};
assert_eq!(
kids, expect_order,
"`{body}` lays its halves out in the declared order"
);
// Both halves exist, are foldable, and the inspector is a phone drawer.
let (main, insp) = (main_slot_id(tab), inspector_slot_id(tab));
let foldable = foldable_panes(&s);
for half in [&main, &insp] {
assert!(s.pane(half).is_some(), "the `{half}` pane exists");
assert!(
foldable.contains(half),
"`{half}` is foldable: {foldable:?}"
);
}
assert!(
drawer_panes(&s).contains(&insp),
"`{insp}` is a phone drawer"
);
// The shell around it is untouched: both shell dividers still draggable.
let splits = draggable_splits(&s);
for shell_split in [ROOT_SPLIT, WORKSPACE_SPLIT, body.as_str()] {
assert!(
splits.contains(&shell_split.to_string()),
"`{shell_split}` is draggable: {splits:?}"
);
}
// A decomposed tab REPLACES the single `panes` slot — the body IS the pane.
assert!(
s.pane(PANES).is_none(),
"Tab::{tab:?} hosts its body split, not the `panes` slot"
);
// Round-trips through RON byte-stably, like the undecomposed shell.
let ron1 = s.to_ron();
let back =
Scene::from_ron(&ron1).expect("the per-tab shell parses back from its own RON");
assert_eq!(
ron1,
back.to_ron(),
"Tab::{tab:?}'s shell RON is byte-stable"
);
}
}
/// An UNdecomposed tab keeps the single `panes` slot — the honest answer for a
/// tab with no master/detail structure. RED-when-broken if a split is forced on
/// one (a divider with nothing on the other side is worse than no divider).
#[test]
fn an_undecomposed_tab_keeps_the_single_panes_slot() {
for tab in Tab::ALL {
if body_split(tab).is_some() {
continue;
}
let s = nornir_shell_scene_for(tab);
assert!(s.pane(PANES).is_some(), "Tab::{tab:?} is honestly one pane");
assert_eq!(
draggable_splits(&s),
vec![ROOT_SPLIT.to_string(), WORKSPACE_SPLIT.to_string()],
"Tab::{tab:?} adds no divider of its own"
);
}
// …and the neutral shell (no tab) is exactly that undecomposed form.
assert_eq!(nornir_shell_scene(), nornir_shell_scene_for(Tab::Timeline));
}
/// Slot ids round-trip: `<Tab>_main` / `<Tab>_inspector` resolve back to the tab
/// and the half, and nothing else does. This is the routing the app's `SceneHost`
/// depends on — a mis-parse would silently draw the wrong tab's inspector.
#[test]
fn body_slot_ids_round_trip_to_their_tab_and_half() {
for tab in Tab::ALL {
assert_eq!(body_slot(&main_slot_id(tab)), Some((tab, BodyPart::Main)));
assert_eq!(
body_slot(&inspector_slot_id(tab)),
Some((tab, BodyPart::Inspector))
);
}
for other in [
ACTIVE_PANE_SLOT,
DETAIL_SLOT,
ACTION_LOG_SLOT,
PANES,
"Nope_main",
"",
] {
assert_eq!(body_slot(other), None, "`{other}` is not a body-split slot");
}
}
/// **Chrome comes free, and it is asserted rather than assumed.** Every pane draws
/// minimise + maximise (`Chrome::Auto` is facett-app's default) — including BOTH
/// halves of every decomposed tab body — except exactly two panes that are pure
/// layout and say so: the scene root (nothing to minimise into) and a tab's
/// `<Tab>_body` container (its halves own the controls, and a titled bar there
/// would shadow the tab-strip button's AccessKit label).
///
/// RED-when-broken the moment any OTHER pane opts out and the operator silently
/// loses the control.
#[test]
fn every_pane_but_the_layout_containers_draws_minimise_and_maximise() {
for tab in Tab::ALL {
let s = nornir_shell_scene_for(tab);
let chromed = chrome_panes(&s);
let bare: Vec<String> = match body_split(tab) {
Some(_) => vec![ROOT_SPLIT.to_string(), body_pane_name(tab)],
None => vec![ROOT_SPLIT.to_string()],
};
for name in s.pane_names() {
if bare.contains(&name) {
assert!(
!chromed.contains(&name),
"Tab::{tab:?}: `{name}` is pure layout, stays bare"
);
} else {
assert!(
chromed.contains(&name),
"Tab::{tab:?}: `{name}` draws minimise + maximise"
);
}
}
assert_eq!(
chromed.len(),
s.pane_names().len() - bare.len(),
"all but the {} layout container(s): {chromed:?}",
bare.len()
);
}
}
// ── The RECT oracle: geometry, not booleans ─────────────────────────────────
/// A stub host: one queryable label per slot, so a folded/minimised body is
/// provably absent from the AccessKit tree as well as from `state_json`.
struct StubHost;
impl facett_app::scene::SceneHost for StubHost {
fn slot(&mut self, id: &str, ui: &mut egui::Ui) -> serde_json::Value {
ui.label(format!("SLOT:{id}"));
serde_json::json!({ "slot": id })
}
}
/// `[x, y, w, h]` out of a rect the render published.
fn rect(v: &serde_json::Value) -> (f32, f32, f32, f32) {
let a = v
.as_array()
.unwrap_or_else(|| panic!("a rect array, got {v}"));
(
a[0].as_f64().unwrap() as f32,
a[1].as_f64().unwrap() as f32,
a[2].as_f64().unwrap() as f32,
a[3].as_f64().unwrap() as f32,
)
}
/// The centre of a published rect — where a robot clicks it.
fn centre(v: &serde_json::Value) -> egui::Pos2 {
let (x, y, w, h) = rect(v);
egui::pos2(x + w / 2.0, y + h / 2.0)
}
/// The extent of a rect ALONG a split's axis (width for horizontal, height for
/// vertical) — the one number a divider drag re-apportions.
fn extent(axis: Axis, v: &serde_json::Value) -> f32 {
let (_, _, w, h) = rect(v);
match axis {
Axis::Horizontal => w,
Axis::Vertical => h,
}
}
/// Render `scene`/`nav` at 1400×900 (the shell's desktop size) and hand back the
/// harness + the live render `state_json` (re-read after every `run`).
#[allow(clippy::type_complexity)]
fn harness(
scene: &Scene,
nav: std::rc::Rc<std::cell::RefCell<NavState>>,
) -> (
egui_kittest::Harness<'static>,
std::rc::Rc<std::cell::RefCell<serde_json::Value>>,
) {
let state = std::rc::Rc::new(std::cell::RefCell::new(serde_json::Value::Null));
let (s, n, sc) = (state.clone(), nav, scene.clone());
let mut h = egui_kittest::Harness::builder()
.with_size(egui::vec2(1400.0, 900.0))
.build_ui(move |ui| {
*s.borrow_mut() =
facett_app::scene::render(&sc, &mut n.borrow_mut(), ui, &mut StubHost);
});
h.run();
(h, state)
}
/// A real pointer click at `p` (press + release with a frame between).
fn click(h: &mut egui_kittest::Harness<'static>, p: egui::Pos2) {
h.event(egui::Event::PointerMoved(p));
h.run();
h.event(egui::Event::PointerButton {
pos: p,
button: egui::PointerButton::Primary,
pressed: true,
modifiers: Default::default(),
});
h.run();
h.event(egui::Event::PointerButton {
pos: p,
button: egui::PointerButton::Primary,
pressed: false,
modifiers: Default::default(),
});
h.run();
}
/// **RED-when-broken, at RECT level: dragging a tab body's divider moves BOTH
/// halves in OPPOSITE directions, and their sum is conserved.**
///
/// The ratio alone was never evidence. A renderer that updated the stored fraction
/// and laid the panes out exactly as before would satisfy a ratio assertion
/// completely, and the divider would still be nailed down on screen. So this reads
/// the two halves' allotted rects: one shrinks by what the other gains, and they
/// still tile the same body area.
#[test]
fn dragging_a_tab_body_divider_moves_both_halves_with_the_sum_conserved() {
for tab in decomposed_tabs() {
let spec = body_split(tab).expect("decomposed");
let scene = nornir_shell_scene_for(tab);
let nav = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene)));
let body = body_pane_name(tab);
let (main, insp) = (main_slot_id(tab), inspector_slot_id(tab));
// The MAIN half's index in the split (0 when it is the first child).
let mi = usize::from(!spec.main_first);
let (mut h, state) = harness(&scene, nav.clone());
let before = nav.borrow().split_ratios(&body).expect("a live body ratio")[mi];
let main0 = extent(spec.axis, &state.borrow()["panes"][&main]["alloc"]);
let insp0 = extent(spec.axis, &state.borrow()["panes"][&insp]["alloc"]);
assert!(
main0 > 1.0 && insp0 > 1.0,
"Tab::{tab:?}: both halves start with extent"
);
// The handle rect is published for robot addressing — drag it TOWARD the
// main half (shrinking it) by a third of that half's extent. Toward the
// main half is "back" when it is the first child and "forward" when it is
// the second, so the same assertions hold whichever order it declares.
let sep = state.borrow()["panes"][&body]["separators"][0].clone();
assert!(
sep.is_array(),
"Tab::{tab:?}: the body split publishes a drag handle rect"
);
let from = centre(&sep);
let delta = if spec.main_first {
-(main0 / 3.0)
} else {
main0 / 3.0
};
let to = match spec.axis {
Axis::Horizontal => egui::pos2(from.x + delta, from.y),
Axis::Vertical => egui::pos2(from.x, from.y + delta),
};
h.event(egui::Event::PointerMoved(from));
h.drag_at(from);
h.event(egui::Event::PointerMoved(to));
h.run();
h.drop_at(to);
h.run();
let after = nav.borrow().split_ratios(&body).expect("a live body ratio")[mi];
assert!(
after < before - 1e-3,
"Tab::{tab:?}: the drag re-apportioned ({before} → {after})"
);
assert!(
after > 0.0,
"Tab::{tab:?}: the ratio stays clamped above zero"
);
let main1 = extent(spec.axis, &state.borrow()["panes"][&main]["alloc"]);
let insp1 = extent(spec.axis, &state.borrow()["panes"][&insp]["alloc"]);
assert!(
main1 < main0 - 1.0,
"Tab::{tab:?}: the main half SHRANK {main0} → {main1}"
);
assert!(
insp1 > insp0 + 1.0,
"Tab::{tab:?}: the inspector GREW {insp0} → {insp1}"
);
assert!(
((main0 + insp0) - (main1 + insp1)).abs() < 8.0,
"Tab::{tab:?}: the halves still tile the same body area \
({main0}+{insp0} vs {main1}+{insp1})",
);
}
}
/// **RED-when-broken, at RECT level: maximising one half of a tab body drives its
/// sibling's area to ZERO, and restore returns the EXACT prior rects.**
///
/// Both are geometry: the sibling's allotted rect has zero extent and zero area
/// (plus the renderer's own `hidden_by: "maximize"` marker), and the restored
/// widths match the pre-maximise ones to within a point — which is only true
/// because a maximise never overwrites the stored split ratios.
#[test]
fn maximising_a_half_zeroes_its_sibling_and_restore_returns_the_exact_rects() {
for tab in decomposed_tabs() {
let spec = body_split(tab).expect("decomposed");
let scene = nornir_shell_scene_for(tab);
let nav = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene)));
let (main, insp) = (main_slot_id(tab), inspector_slot_id(tab));
let (mut h, state) = harness(&scene, nav.clone());
let main0 = extent(spec.axis, &state.borrow()["panes"][&main]["alloc"]);
let insp0 = extent(spec.axis, &state.borrow()["panes"][&insp]["alloc"]);
let btn = state.borrow()["panes"][&main]["chrome"]["buttons"]["maximize"].clone();
assert!(
btn.is_array(),
"Tab::{tab:?}: the main half publishes a maximise button rect"
);
click(&mut h, centre(&btn));
assert!(
nav.borrow().is_maximized(&main),
"Tab::{tab:?}: the click maximised the main half"
);
let main1 = extent(spec.axis, &state.borrow()["panes"][&main]["alloc"]);
let insp1 = extent(spec.axis, &state.borrow()["panes"][&insp]["alloc"]);
assert!(
main1 > main0 + 1.0,
"Tab::{tab:?}: the maximised half GREW {main0} → {main1}"
);
assert!(
insp1 <= 1.0,
"Tab::{tab:?}: the sibling is allotted zero extent, got {insp1}"
);
assert_eq!(
state.borrow()["panes"][&insp]["area"].as_f64().unwrap(),
0.0,
"Tab::{tab:?}: …and zero AREA",
);
assert_eq!(state.borrow()["panes"][&insp]["hidden_by"], "maximize");
// Restore (a second click) hands the sibling its EXACT extent back.
let btn = state.borrow()["panes"][&main]["chrome"]["buttons"]["maximize"].clone();
click(&mut h, centre(&btn));
assert!(
!nav.borrow().is_maximized(&main),
"Tab::{tab:?}: the second click restored"
);
let main2 = extent(spec.axis, &state.borrow()["panes"][&main]["alloc"]);
let insp2 = extent(spec.axis, &state.borrow()["panes"][&insp]["alloc"]);
assert!(
(main2 - main0).abs() < 1.0,
"Tab::{tab:?}: main restored EXACTLY {main0} → {main2}"
);
assert!(
(insp2 - insp0).abs() < 1.0,
"Tab::{tab:?}: sibling restored EXACTLY {insp0} → {insp2}"
);
}
}
/// **RED-when-broken, at RECT level: minimising the inspector leaves ONLY its
/// chrome bar, hands the freed extent to the main half, drops its body from the
/// AccessKit tree AND `state_json` — and restore puts all three back exactly.**
///
/// This is the affordance the whole complaint was about: the stats readout that
/// grew until it pushed the canvas off screen now collapses to a 22pt title bar
/// that is still there to click back open.
#[test]
fn minimising_the_inspector_leaves_only_its_chrome_bar_and_restore_is_exact() {
use egui_kittest::kittest::Queryable;
for tab in decomposed_tabs() {
let spec = body_split(tab).expect("decomposed");
let scene = nornir_shell_scene_for(tab);
let nav = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene)));
let (main, insp) = (main_slot_id(tab), inspector_slot_id(tab));
let (mut h, state) = harness(&scene, nav.clone());
let main0 = extent(spec.axis, &state.borrow()["panes"][&main]["alloc"]);
let insp0 = extent(spec.axis, &state.borrow()["panes"][&insp]["alloc"]);
assert!(
h.query_by_label(&format!("SLOT:{insp}")).is_some(),
"Tab::{tab:?}: the inspector's body is drawn"
);
let btn = state.borrow()["panes"][&insp]["chrome"]["buttons"]["minimize"].clone();
assert!(
btn.is_array(),
"Tab::{tab:?}: the inspector publishes a minimise button rect"
);
click(&mut h, centre(&btn));
assert!(
nav.borrow().is_minimized(&insp),
"Tab::{tab:?}: the click minimised the inspector"
);
let main1 = extent(spec.axis, &state.borrow()["panes"][&main]["alloc"]);
let insp1 = extent(spec.axis, &state.borrow()["panes"][&insp]["alloc"]);
assert!(
insp1 < insp0 - 1.0,
"Tab::{tab:?}: the inspector COLLAPSED {insp0} → {insp1}"
);
assert!(
main1 > main0 + 1.0,
"Tab::{tab:?}: the main half took the freed extent"
);
// ONLY the chrome bar is left — the collapsed extent is the bar itself.
assert!(
(insp1 - facett_app::scene::CHROME_BAR).abs() < 2.0,
"Tab::{tab:?}: a minimised inspector collapses to its {}pt chrome bar, got {insp1}",
facett_app::scene::CHROME_BAR,
);
assert!(
state.borrow()["panes"][&insp]["chrome"]["bar_height"]
.as_f64()
.unwrap()
> 0.0,
"Tab::{tab:?}: the title bar survives — a minimised pane must stay recoverable",
);
assert!(
h.query_by_label(&format!("SLOT:{insp}")).is_none(),
"Tab::{tab:?}: minimised — the body left the AccessKit tree"
);
assert!(
state.borrow()["slots"].get(&insp).is_none(),
"Tab::{tab:?}: …and left state_json"
);
// The body rect really is zero-extent (a measurement, not a flag).
let (_, _, _, body_h) = rect(&state.borrow()["panes"][&insp]["content_rect"]);
assert_eq!(
body_h, 0.0,
"Tab::{tab:?}: the minimised body occupied no height"
);
// Restore returns the geometry AND the body. A minimised pane's
// un-minimise control is published under `restore` when the bar collapsed
// to a narrow RAIL (a minimised child of a horizontal split) and under
// `minimize` (drawn as 🗗, labelled "restore") when the bar kept its full
// width — either way the pane must publish SOMETHING that gets it back,
// which is the invariant that matters.
let buttons = state.borrow()["panes"][&insp]["chrome"]["buttons"].clone();
let btn = if buttons["restore"].is_array() {
buttons["restore"].clone()
} else {
buttons["minimize"].clone()
};
assert!(
btn.is_array(),
"Tab::{tab:?}: a minimised inspector publishes a control that restores it, got {buttons}"
);
click(&mut h, centre(&btn));
assert!(
!nav.borrow().is_minimized(&insp),
"Tab::{tab:?}: the click restored the inspector"
);
let insp2 = extent(spec.axis, &state.borrow()["panes"][&insp]["alloc"]);
assert!(
(insp2 - insp0).abs() < 1.0,
"Tab::{tab:?}: restore is EXACT {insp0} → {insp2}"
);
assert!(
h.query_by_label(&format!("SLOT:{insp}")).is_some(),
"Tab::{tab:?}: restored — the body is back"
);
}
}
}