src/app/scene.rs
//! **The holger shell, described DECLARATIVELY.** The whole top-level UI — a
//! grouped menubar over a tabbed pane tree — is expressed as one
//! [`facett_app::scene::Scene`] value and rendered through facett-app's OWN
//! primitives ([`Router`](facett_app::Router) / [`TabView`](facett_app::TabView) /
//! `facett_menu`). Nothing is forked: the scene DRIVES the existing `<tab>_tab`
//! renderers through a [`SceneHost`], returning each view-model's `state_json()`.
//!
//! - **Describe** — [`holger_scene`] builds the shell (menubar groups + a draggable
//! master/detail split of the registry rail over a `Pane::tabs` of one foldable body
//! per `TAB_ORDER` entry, each body itself either a DRAGGABLE master/detail split or
//! an honest single pane — see [`body_split`]) with the Rust builder; the identical
//! value round-trips through **RON** byte-stably.
//! - **Host** — [`SceneHostImpl`] maps each `Content::Slot(id)` to the matching
//! `<tab>_<half>(ui)` method and folds its view-model `state_json()` into the scene
//! state. Menu `Action::Command`s route straight into the existing
//! [`dispatch_command`](HolgerUiApp::dispatch_command).
//! - **Render** — [`HolgerUiApp::render_scene`] calls [`facett_app::scene::render`]
//! from the app's draw, keeping `self.tab` authoritative (so every existing
//! command / demo / robot hook still drives the tab).
//! - **Observe** — [`HolgerUiApp::scene_state_json`] folds the pane tree + fold /
//! active-tab state + the last render facts into the app's `state_json`.
//!
//! **holger IS a master/detail app, and the shell now says so.** This module's older
//! doc argued the shell was "a flat, single-window tabbed workspace … draggable splits
//! / drawers are deliberately unused — there is no honest master/detail or
//! peripheral-rail surface to hang them on." That was wrong about its own app. holger
//! is an artifact REGISTRY BROWSER: **one selected repository** (`repos.selected`) is
//! the master, and Browse / Archive / Artifact / Upload / Lineage are all details OF
//! that selection — five tabs that open with `selected_repo()` and render whatever it
//! points at. The selection was reachable only from inside one tab (the Repos
//! combo box), so the master was hidden behind the detail. It is now a **rail**.
//!
//! The shell therefore exercises the whole scene vocabulary:
//! - **A draggable master/detail split** ([`Pane::split`], NOT `split_fixed`): the
//! registry rail beside the tabbed workspace, divider draggable, live ratio in
//! `NavState` and observable in `state_json`.
//! - **A second draggable split**, vertical, INSIDE the rail: the repository roster
//! over the selected repository's cross-tab context.
//! - **A drawer**: the rail auto-folds crossing into a phone-class viewport and
//! restores above it (desktop rail → phone drawer); the root split collapses to
//! `Tabs` there, so a narrow screen gets the workspace, not a 20% sliver of it.
//! - **Tabs** (the workspace layout, active-tab observable + switchable), **folds**
//! (every tab body, every body HALF and every rail pane is foldable), and a **routed
//! menubar** (groups → `Action::Command` → the existing dispatcher).
//!
//! **And the tab INTERNALS adopted it too.** The shell was a scene while every tab BODY
//! stayed one imperative egui flow inside a single slot pane — so minimise / maximise /
//! drag operated on a whole tab and could do NOTHING inside it. A readout that grew (an
//! archive's stats grid, a pagination footer, a tool's output) simply pushed the canvas
//! off the bottom of the pane, with no divider to correct it, because both shared one
//! vertical flow. Nine of the eleven bodies are now
//! `Split(axis, ratio, DRAGGABLE)` over `<slot>_main` (the canvas / table / list) and
//! `<slot>_inspector` (controls, readouts, detail) — two real windows, each with its own
//! chrome bar, with a divider between them. This is a DECOMPOSITION, not a rewrite: the
//! existing renderers were cut in two and are still hosted through the same
//! [`SceneHost`]. The tabs that are honestly ONE pane (**Status**, **Sign in**,
//! **Settings**) say so in [`body_split`] and keep their slot leaf — forcing a split
//! with no master/detail is worse than leaving it.
//!
//! ⚠ **facett patch wanted:** `facett_app::scene::Pane::header_title` is private, so a
//! host cannot audit the AccessKit labels its own scene publishes (chrome is default-ON,
//! and every pane TITLE is now an addressable node — a duplicate makes BOTH unreachable).
//! `every_pane_title_tab_label_and_menu_group_is_uniquely_addressable` below re-derives
//! it locally; making it `pub` would delete that copy.
// `LoginPane::state_json` (the signin slot's observable state) is a `Facet` method, so
// the trait must be in scope here as it is in the parent `app` module.
#[cfg_attr(feature = "mikro", allow(unused_imports))]
use facett::Facet;
use facett_app::scene::{
self as fscene, Action, Axis, Layout, MenuItem, Pane, Scene, SceneHost,
};
use serde_json::{json, Value};
#[cfg(not(feature = "mini"))]
use super::ToolsFn;
use super::{
HolgerUiApp, Tab, RAIL_PANE, RAIL_ROSTER_PANE, RAIL_SELECTION_PANE, SHELL_PANE, TAB_ORDER,
WORKSPACE_PANE,
};
use crate::data::RepoRow;
/// The ROOT pane's unique name — the draggable master/detail split (rail | workspace)
/// the `Router` reports as the scene's route. Aliases the app's [`SHELL_PANE`] so the
/// pane id a robot addresses (`/scene/panes/holger-shell`) has exactly ONE spelling
/// across the shell and its tests.
pub(super) const ROOT: &str = SHELL_PANE;
/// The tabbed WORKSPACE pane's name — the addressable handle whose live `active_tab`
/// index the renderer reads and a tab-header click mutates. Aliases the app's
/// [`WORKSPACE_PANE`].
pub(super) const WORKSPACE: &str = WORKSPACE_PANE;
/// The registry RAIL pane's name (the foldable drawer holding the roster + selection).
pub(super) const RAIL: &str = RAIL_PANE;
/// The width (points) below which the shell is phone-class: the rail drawer shuts and
/// the root split collapses to `Tabs`. Matches dwarves' shell breakpoint — one number
/// for one fleet-wide rail-becomes-drawer behaviour.
const PHONE_BREAKPOINT: f32 = 760.0;
/// The fold icon for a tab's collapsible body. The tab ORDER, slot ids and header
/// labels are NOT duplicated here — they come from the app's [`TAB_ORDER`] +
/// [`Tab::slot_id`] / [`Tab::label`], the single source of truth, so a tab added there
/// automatically gains a scene pane. Only the decorative icon is scene-local; an
/// unlisted tab simply folds without one.
fn fold_icon(tab: Tab) -> &'static str {
match tab {
Tab::Status => "🩺",
Tab::Repos => "🗄",
Tab::Browse => "📂",
Tab::Archive => "🧊",
Tab::Artifact => "📦",
#[cfg(all(feature = "mini", not(feature = "mikro")))]
Tab::Search => "🔎",
Tab::Graph => "🕸",
Tab::Helix => "🌀",
Tab::Lineage => "◉",
Tab::Tools => "🛠",
Tab::Upload => "⬆",
Tab::SignIn => "🔑",
// 🎛 — the SAME glyph dwarves-demo's `tab_icon` gives its `settings` tab, so
// the preferences pane wears one face across the suite.
Tab::Settings => "🎛",
}
}
/// The index of a [`Tab`] in the strip (its `active_tab` value). Delegates to the
/// app's [`Tab::index`] so the scene and `state_json` can never disagree.
pub(super) fn tab_index(tab: Tab) -> usize {
tab.index()
}
/// The [`Tab`] at strip index `i` (inverse of [`tab_index`]).
pub(super) fn tab_from_index(i: usize) -> Tab {
Tab::from_index(i)
}
/// A `<pane> → slot(id)` leaf: a host-filled widget, foldable (open by default) so
/// the operator can collapse a tab body to just its header. The pane name is
/// `"<slot>_pane"` (unique) and the slot id is the bare tab id the [`SceneHost`]
/// switches on.
fn leaf(slot: &str, title: &str, icon: &str) -> Pane {
Pane::slot(format!("{slot}_pane"), slot).title(title).foldable_icon(true, icon)
}
/// **The master/detail decomposition of ONE tab body.**
///
/// The shell adopted the scene at the SHELL level — a rail beside a tabbed workspace —
/// but every tab BODY stayed one imperative egui flow inside a single slot pane. That is
/// the bug this type exists to kill: with one pane per tab, minimise / maximise / drag
/// operate on the whole tab and can do NOTHING inside it, so a readout that grows (an
/// archive's stats grid, a pagination footer, a tool's output) simply pushes the canvas
/// off the bottom of the screen and the operator has no divider to correct it with.
///
/// A body that declares one of these becomes `Split(axis, ratio, DRAGGABLE)` over
/// `<slot>_main` (the canvas / table / list) and `<slot>_inspector` (controls, readouts,
/// detail) — two real windows, each with its own chrome bar, and a divider between them.
struct BodySplit {
/// Which way the body divides. Horizontal ⇒ master column beside detail column.
axis: Axis,
/// The MASTER's seed share of the body (what the divider then moves).
ratio: f32,
/// `(title, fold icon)` of the master pane.
main: (&'static str, &'static str),
/// `(title, fold icon)` of the inspector pane.
inspector: (&'static str, &'static str),
}
/// The decomposition for each tab, or **`None` for a tab that is HONESTLY one pane**.
///
/// Forcing a split on a body with no master/detail is worse than leaving it whole: it
/// buys a divider nobody can use and a second chrome bar that only steals rows. Three
/// tabs are honest single panes and say so here:
///
/// * **Status** — three server facts (status · version · uptime) in one grid. There is
/// no list to master and no detail to inspect; the whole body is the readout.
/// * **Sign in** — the SHARED `facett_auth_ui::LoginPane` paints its own device-code
/// state machine end to end. Splitting it would fork a widget holger does not own.
/// * **Settings** — one Appearance block of preference controls, nothing to inspect.
fn body_split(tab: Tab) -> Option<BodySplit> {
match tab {
// The repo×capability grid is the master; the selector + per-repo quick actions
// (and the refresh / error line) are what you DO with the row you picked.
Tab::Repos => Some(BodySplit {
axis: Axis::Horizontal,
ratio: 0.62,
main: ("Repos · grid", "📋"),
inspector: ("Repos · actions", "⚡"),
}),
// The artifact listing is the master; the source repo, the refresh and the
// pagination footer are its detail. The footer used to be appended UNDER the
// table, so a long listing pushed "N shown / load more" off the pane.
Tab::Browse => Some(BodySplit {
axis: Axis::Horizontal,
ratio: 0.66,
main: ("Browse · artifacts", "📋"),
inspector: ("Browse · source", "🔎"),
}),
// **Rickard's exact case.** The stats readout (files · bytes · archive path) sat
// ABOVE the file table in one vertical flow and pushed it off the bottom with no
// divider to adjust. Two windows, one divider.
Tab::Archive => Some(BodySplit {
axis: Axis::Horizontal,
ratio: 0.64,
main: ("Archive · files", "📋"),
inspector: ("Archive · stats", "📐"),
}),
// The artifact COORDINATE (the query) is the master; what it resolved to — the
// verdict plus the metadata/SBOM grid — is the detail beside it.
Tab::Artifact => Some(BodySplit {
axis: Axis::Horizontal,
ratio: 0.48,
main: ("Artifact · query", "🔎"),
inspector: ("Artifact · metadata", "🧾"),
}),
// Hits are the master; the query + counts are the inspector beside them.
#[cfg(all(feature = "mini", not(feature = "mikro")))]
Tab::Search => Some(BodySplit {
axis: Axis::Horizontal,
ratio: 0.68,
main: ("Search · hits", "📋"),
inspector: ("Search · query", "🔎"),
}),
// Canvas │ inspector. The rebuild control + caption used to eat rows off the top
// of the canvas; the canvas is a window now (maximise it and the inspector goes
// to zero area).
Tab::Graph => Some(BodySplit {
axis: Axis::Horizontal,
ratio: 0.74,
main: ("Graph · canvas", "🕸"),
inspector: ("Graph · inspector", "🔎"),
}),
Tab::Helix => Some(BodySplit {
axis: Axis::Horizontal,
ratio: 0.74,
main: ("Helix · canvas", "🌀"),
inspector: ("Helix · inspector", "🔎"),
}),
Tab::Lineage => Some(BodySplit {
axis: Axis::Horizontal,
ratio: 0.74,
main: ("Lineage · canvas", "◉"),
inspector: ("Lineage · feed", "📡"),
}),
// Action list │ output — the narrow master here, because the OUTPUT is what
// grows (a benchmark result, a scan report, a deploy log).
Tab::Tools => Some(BodySplit {
axis: Axis::Horizontal,
ratio: 0.26,
main: ("Tools · functions", "🧰"),
inspector: ("Tools · output", "📄"),
}),
// The upload form is the master; the write GATE (target repo · writable · the
// last attempt's notice) is its detail. A long failure notice used to grow under
// the form and push its own controls out of reach.
Tab::Upload => Some(BodySplit {
axis: Axis::Horizontal,
ratio: 0.58,
main: ("Upload · form", "📝"),
inspector: ("Upload · target", "🎯"),
}),
// **Settings** is honestly ONE pane — an Appearance block of preference
// controls. There is no list to master and no detail to inspect, so it keeps
// its slot leaf rather than buying a divider nobody can use and a second
// chrome bar that only steals rows. (dwarves-demo's `settings` pane is a
// single pane for the same reason, as is korp's ⚙ Settings.)
Tab::Status | Tab::SignIn | Tab::Settings => None,
}
}
/// One tab's body pane: either a plain foldable slot leaf (an honest single pane) or a
/// DRAGGABLE master/detail split of two named slots. Either way the pane is named
/// `"<slot>_pane"` and titled with the tab's label, so it is still the tab-strip header
/// `TabView` reads and the handle every existing test addresses.
fn tab_body(tab: Tab) -> Pane {
let slot = tab.slot_id();
let pane = match body_split(tab) {
None => leaf(slot, tab.label(), fold_icon(tab)),
Some(b) => Pane::split(
format!("{slot}_pane"),
b.axis,
b.ratio,
vec![
Pane::slot(format!("{slot}_main"), format!("{slot}-main"))
.title(b.main.0)
.foldable_icon(true, b.main.1),
Pane::slot(format!("{slot}_inspector"), format!("{slot}-inspector"))
.title(b.inspector.0)
.foldable_icon(true, b.inspector.1),
],
)
.title(tab.label())
.foldable_icon(true, fold_icon(tab)),
};
// Mini: the tab HEADER carries the "what does this view do" hint as a hover tooltip —
// facett's `TabView` renders `Pane::tooltip` as `on_hover_text` on the tab button. No
// in-body ⓘ clutter; hover the tab in the strip and the explanation appears.
#[cfg(feature = "mini")]
let pane = pane.tooltip(tab.help_text());
pane
}
/// Every tab body that DECOMPOSED into a draggable master/detail split, by pane name —
/// the divider roster the internal-split robot cells drag. Derived from [`body_split`],
/// so a body silently downgraded back to one pane drops out of here.
pub(super) fn split_bodies() -> Vec<String> {
TAB_ORDER
.iter()
.filter(|t| body_split(**t).is_some())
.map(|t| format!("{}_pane", t.slot_id()))
.collect()
}
/// The tabs that are HONESTLY one pane (no master/detail to hang a divider on) — the
/// complement of [`split_bodies`], published so the coverage test asserts the decision
/// explicitly rather than by omission.
pub(super) fn single_pane_bodies() -> Vec<String> {
TAB_ORDER
.iter()
.filter(|t| body_split(**t).is_none())
.map(|t| format!("{}_pane", t.slot_id()))
.collect()
}
/// Build the holger shell as a declarative [`Scene`] (the Rust-builder form). The
/// pane tree is CONSTANT (one leaf per [`TAB_ORDER`] entry) so a persisted `NavState`
/// stays valid frame-to-frame; only the **Repositories** submenu is data-driven (one
/// `Action::Command("repo:<name>")` per reported repository), so this is rebuilt
/// each frame from the current repo roster.
///
/// ```text
/// holger-shell ── Split(H, 0.22, DRAGGABLE) [phone → Tabs(active 1) under 760pt]
/// ├─ holger-rail ── Split(V, 0.60, DRAGGABLE) (Foldable 🗄, drawer@760) "Registry"
/// │ ├─ rail_roster_pane (Foldable 📚) slot:rail-roster — the MASTER selection
/// │ └─ rail_selection_pane (Foldable 🔎) slot:rail-selection — its cross-tab context
/// └─ holger-workspace ── Tabs(active 0) "Workspace"
/// ├─ status_pane (Foldable 🩺) slot:status — HONESTLY one pane
/// ├─ repos_pane ── Split(H, 0.62, DRAGGABLE)
/// │ ├─ repos_main "Repos · grid" slot:repos-main
/// │ └─ repos_inspector "Repos · actions" slot:repos-inspector
/// ├─ browse_pane ── Split(H, 0.66, DRAGGABLE)
/// │ ├─ browse_main "Browse · artifacts" slot:browse-main
/// │ └─ browse_inspector "Browse · source" slot:browse-inspector
/// ├─ archive_pane ── Split(H, 0.64, DRAGGABLE)
/// │ ├─ archive_main "Archive · files" slot:archive-main
/// │ └─ archive_inspector"Archive · stats" slot:archive-inspector
/// ├─ artifact_pane ── Split(H, 0.48, DRAGGABLE) query │ metadata
/// ├─ graph_pane ── Split(H, 0.74, DRAGGABLE) canvas │ inspector
/// ├─ helix_pane ── Split(H, 0.74, DRAGGABLE) canvas │ inspector
/// ├─ lineage_pane ── Split(H, 0.74, DRAGGABLE) canvas │ feed
/// ├─ tools_pane ── Split(H, 0.26, DRAGGABLE) functions │ output
/// ├─ upload_pane ── Split(H, 0.58, DRAGGABLE) form │ target
/// └─ signin_pane (Foldable 🔑) slot:signin — HONESTLY one pane
/// ```
///
/// The rail's pane TITLES ("Registry" / "Roster" / "Selection") deliberately avoid the
/// tab labels and the menu-group names: a fold header, a tab header and a menu button
/// are all AccessKit-labelled nodes, so a rail titled "Repositories" would shadow the
/// Repositories MENU and make both un-addressable by a robot.
// `repos` feeds only the Repositories menu, which mini drops — hence unused there.
#[cfg_attr(feature = "mini", allow(unused_variables))]
pub(super) fn holger_scene(repos: &[RepoRow]) -> Scene {
let leaves: Vec<Pane> = TAB_ORDER.iter().map(|t| tab_body(*t)).collect();
let workspace = Pane::tabs(WORKSPACE, 0, leaves).title("Workspace");
// ── The registry RAIL (master) — a draggable VERTICAL split: the repository roster
// over what the workspace currently knows about the selected repo. The rail itself is
// a foldable DRAWER, so a phone-class viewport shuts it and gives the screen to the
// workspace.
let rail = Pane::split(
RAIL,
Axis::Vertical,
0.60,
vec![
Pane::slot(RAIL_ROSTER_PANE, "rail-roster")
.title("Roster")
.foldable_icon(true, "📚"),
Pane::slot(RAIL_SELECTION_PANE, "rail-selection")
.title("Selection")
.foldable_icon(true, "🔎"),
],
)
.title("Registry")
.foldable_icon(true, "🗄")
.drawer(PHONE_BREAKPOINT);
// ── The shell root: a DRAGGABLE horizontal master/detail split. Below the phone
// breakpoint it collapses to `Tabs` with the WORKSPACE (child 1) active — a narrow
// screen gets the whole detail surface, with the rail one tab away, instead of a 22%
// sliver of each.
let root = Pane::split(ROOT, Axis::Horizontal, 0.22, vec![rail, workspace])
.title("holger")
.phone(Layout::Tabs { active: 1 }, PHONE_BREAKPOINT);
// ── The grouped menubar (mirrors the Ctrl-K command set): every menu action is
// an `Action::Command(<existing id>)` routed straight into `dispatch_command`, so
// the menu and the palette share ONE interpreter (REUSE, no second dispatcher).
// Mini drops the View menu: with only five tabs the visible TabView strip already IS
// the tab navigator, so a dropdown that just re-lists it is redundant chrome.
#[cfg(not(feature = "mini"))]
let view = MenuItem::new("view", "View").items(
TAB_ORDER
.iter()
.enumerate()
.map(|(i, t)| {
let slot = t.slot_id();
let item = MenuItem::new(format!("goto:{slot}"), t.label())
.action(Action::Command(format!("goto:{slot}")));
match view_accelerator(i) {
Some(keys) => item.shortcut(keys),
// Past the digit row there is no accelerator to give — better a
// menu entry with none than one that lies (see `view_accelerator`).
None => item,
}
})
.collect(),
);
// Mini drops the Operate menu wholesale: every entry is a full-tier tool the mini
// server cannot serve — holger-ds (benchmark egress), Migrate (Nexus/Artifactory
// federation), Security scan (Móðguðr), Deploy (Skíðblaðnir host provisioning),
// Airgap populate (build→push, a WRITE), Demo appliance (jera OCI container).
#[cfg(not(feature = "mini"))]
let operate = MenuItem::new("operate", "Operate").items(vec![
tool_item("tool:holger-ds", "holger-ds (delivery bench)"),
tool_item("tool:migrate", "Migrate"),
tool_item("tool:security", "Security scan (Móðguðr)"),
tool_item("tool:deploy", "Deploy (Skíðblaðnir)"),
tool_item("tool:airgap", "Airgap populate"),
tool_item("tool:demo", "Demo appliance (jera container)"),
]);
// One command per reported repository — the runtime-built submenu (the reason
// the scene is rebuilt each frame). Mini drops it: the rail's Roster IS the repo
// picker (filterable, always visible), so the menu only duplicated it (plus a jump
// to Browse). The `repo:` commands still live in the Ctrl-K palette.
#[cfg(not(feature = "mini"))]
let repositories = MenuItem::new("repositories", "Repositories").items(
repos
.iter()
.map(|r| {
MenuItem::new(format!("repo:{}", r.name), format!("Open · {}", r.name))
.action(Action::Command(format!("repo:{}", r.name)))
})
.collect(),
);
// `mut` for the full tier's conditional pushes below; mini keeps only Refresh.
#[cfg_attr(feature = "mini", allow(unused_mut))]
let mut action_items = vec![MenuItem::new("action:refresh", "Refresh repositories")
.shortcut("F5")
.action(Action::Command("action:refresh".into()))];
// Scan (Móðguðr) is a full-tier tool and the static / Skade-Vinter toggle is the
// demo showcase — the mini tier carries neither, so its Action menu is just Refresh.
#[cfg(not(feature = "mini"))]
{
action_items.push(
MenuItem::new("action:scan-selected", "Scan selected repo")
.action(Action::Command("action:scan-selected".into())),
);
action_items.push(
MenuItem::new("action:toggle-static", "Toggle static (Skade Vinter)")
.action(Action::Command("action:toggle-static".into())),
);
}
let action = MenuItem::new("action", "Action").items(action_items);
// ⚠ There is deliberately NO "Look" menu here any more.
//
// The menubar used to carry a top-level **Look** group listing every theme preset,
// and the chrome bar carried the same switcher inline — appearance advertised twice
// at the shell root. The root menu is for NAVIGATION (View · Repositories) and
// ACTIONS (Operate · Action · Help); appearance is a PREFERENCE, and it now lives in
// the ⚙ **Settings** pane, exactly as dwarves-demo splits it (its `settings` tab,
// glyph 🎛, owns the theme picker and none of its menus mention appearance).
//
// Nothing was lost: Settings is a real, foldable scene pane reachable from the tab
// strip and from `goto:settings` (View menu + Ctrl-K, both derived from `TAB_ORDER`),
// and the `look:<name>` commands still resolve in `dispatch_command` — so the Ctrl-K
// palette, the demo tour and every headless robot drive are unchanged. Ctrl-K is a
// searchable index of everything the app can do, not a menu.
let help = MenuItem::new("help", "Help").items(vec![MenuItem::new("about:open", "About holger")
.action(Action::Command("about:open".into()))]);
// Full adds View (tab list) + Operate (tools) on the left of the bar; mini omits both
// (redundant tab strip / no full-tier tools). Repositories · Action · Help stay.
let scene = Scene::new("holger", root);
#[cfg(not(feature = "mini"))]
let scene = scene.menu(view).menu(operate).menu(repositories);
scene.menu(action).menu(help)
}
/// The keyboard accelerator advertised for the View-menu entry of the tab at strip
/// index `i` — `Ctrl+1`…`Ctrl+9` for the first nine, `Ctrl+0` for the tenth, and
/// **`None`** past that.
///
/// The old formula was `Ctrl+{(i + 1) % 10}`, written when the strip had ten tabs.
/// The eleventh (**Sign in**) wrapped it back around: `(10 + 1) % 10 == 1`, so the
/// menu advertised **`Ctrl+1` twice** — once for Status and once for Sign in. Two
/// entries claiming one accelerator is a menu that lies to the operator, and it is
/// exactly the drift a modulo hides: it never fails, it just silently aliases.
///
/// There are only ten digits. Beyond them the honest answer is "no accelerator",
/// and `view_accelerators_are_unique_across_the_whole_strip` holds it to that
/// however many tabs the strip grows to.
#[cfg(not(feature = "mini"))]
fn view_accelerator(i: usize) -> Option<String> {
match i {
0..=8 => Some(format!("Ctrl+{}", i + 1)),
9 => Some("Ctrl+0".to_string()),
_ => None,
}
}
/// An Operate-menu leaf: `Action::Command("tool:<id>")` → the existing dispatcher
/// selects the Tools tab + that tool.
#[cfg(not(feature = "mini"))]
fn tool_item(id: &str, label: &str) -> MenuItem {
MenuItem::new(id, label).action(Action::Command(id.into()))
}
/// The names of every foldable pane in a scene (introspection for `state_json` and
/// the coverage tests).
///
/// A thin delegate to facett-app's OWN [`Scene::foldable_panes`] — holger used to
/// re-walk the tree here, which is exactly the twin L5 forbids. The shared crate is the
/// one place that knows what "foldable" means.
pub(super) fn foldable_panes(scene: &Scene) -> Vec<String> {
scene.foldable_panes()
}
/// The names of every pane whose layout is a **draggable** (`resizable`) split — the
/// divider roster the robot suite drags and `state_json` reports. A split that loses
/// `resizable: true` drops out of this list, which is exactly what the coverage test
/// asserts against. Delegates to facett-app's own [`Scene::draggable_splits`] (L5).
pub(super) fn draggable_splits(scene: &Scene) -> Vec<String> {
scene.draggable_splits()
}
/// The names of every DRAWER pane (auto-folds crossing into a phone-class width).
pub(super) 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 slot host: draws each [`fscene::Content::Slot`] by calling the matching
/// existing `<tab>_tab` renderer on the app, and folds that view-model's
/// `state_json()` into the scene's observable state. Menu commands route straight
/// into [`HolgerUiApp::dispatch_command`].
struct SceneHostImpl<'a> {
app: &'a mut HolgerUiApp,
}
impl SceneHost for SceneHostImpl<'_> {
fn slot(&mut self, id: &str, ui: &mut egui::Ui) -> Value {
let app = &mut *self.app;
match id {
// ── the registry RAIL (the master side) ──────────────────────────────────
"rail-roster" => app.rail_roster(ui),
"rail-selection" => app.rail_selection(ui),
"status" => {
app.status_tab(ui);
app.data.status.state_json()
}
// ── the DECOMPOSED tab bodies: `<slot>-main` is the canvas / table / list,
// `<slot>-inspector` the controls, readouts and detail beside it. Each pair is
// a DRAGGABLE split, so minimise / maximise / drag finally reach INSIDE a tab.
"repos-main" => {
app.repos_grid(ui);
app.data.repos.state_json()
}
"repos-inspector" => app.repos_actions(ui),
// Browse / Archive are the mini tier's PAGING surfaces; the mikro tier
// registers no such pane (its `TAB_ORDER` has three entries) and compiles
// no body behind one.
#[cfg(not(feature = "mikro"))]
"browse-main" => {
app.browse_listing(ui);
app.data.browse.state_json()
}
#[cfg(not(feature = "mikro"))]
"browse-inspector" => app.browse_source(ui),
#[cfg(not(feature = "mikro"))]
"archive-main" => {
app.archive_files(ui);
app.data.archive.state_json()
}
#[cfg(not(feature = "mikro"))]
"archive-inspector" => app.archive_stats(ui),
"artifact-main" => {
app.artifact_query(ui);
app.data.artifact.state_json()
}
"artifact-inspector" => app.artifact_metadata(ui),
#[cfg(all(feature = "mini", not(feature = "mikro")))]
"search-main" => {
app.search_hits(ui);
app.data.search.state_json()
}
#[cfg(all(feature = "mini", not(feature = "mikro")))]
"search-inspector" => app.search_query(ui),
// ── the FULL-TIER slots. `holger_scene` does not register these panes on
// the read-only tiers (their `#[cfg(not(feature = "mini"))]` blocks above),
// and the bodies behind them are not compiled there either — so the arms
// must go too, or the dispatch would name methods that do not exist.
#[cfg(not(feature = "mini"))]
"graph-main" => {
app.graph_canvas(ui);
app.graph_state_json()
}
#[cfg(not(feature = "mini"))]
"graph-inspector" => app.graph_inspector(ui),
#[cfg(not(feature = "mini"))]
"helix-main" => {
app.helix_canvas(ui);
json!({ "built": app.helix.is_some() })
}
#[cfg(not(feature = "mini"))]
"helix-inspector" => app.helix_inspector(ui),
#[cfg(not(feature = "mini"))]
"lineage-main" => {
app.lineage_canvas(ui);
app.data.lineage.state_json()
}
#[cfg(not(feature = "mini"))]
"lineage-inspector" => app.lineage_feed(ui),
#[cfg(not(feature = "mini"))]
"tools-main" => app.tools_functions(ui),
#[cfg(not(feature = "mini"))]
"tools-inspector" => {
app.tools_output(ui);
json!({ "tool": tool_id(app.tool) })
}
#[cfg(not(feature = "mini"))]
"upload-main" => {
app.upload_form(ui);
json!({ "writable": app.data.repos.upload_enabled() })
}
#[cfg(not(feature = "mini"))]
"upload-inspector" => app.upload_target(ui),
// The SHARED device-code sign-in pane (facett_auth_ui) — its own `state_json`
// carries the login state machine's node + the subject / credential NAMES,
// never a token VALUE.
#[cfg(not(feature = "mini"))]
"signin" => {
app.signin_tab(ui);
app.login_pane.state_json()
}
// ⚙ Settings — the preferences pane and the ONE home of holger's look &
// feel (it moved off the root chrome bar and out of the root "Look" menu;
// see `HolgerUiApp::settings_pane`). Registered on EVERY tier: it drives
// no gRPC op, and the read-only tiers would otherwise have no look control
// at all. Its state IS `look_state_json`, so there is one look oracle.
"settings" => app.settings_pane(ui),
other => {
ui.weak(format!("no widget for slot: {other}"));
Value::Null
}
}
}
fn command(&mut self, id: &str) {
self.app.dispatch_command(id);
}
}
/// The stable tool id (mirror of the `state_json` mapping) for the tools slot's
/// observable state.
#[cfg(not(feature = "mini"))]
fn tool_id(tool: ToolsFn) -> &'static str {
match tool {
ToolsFn::HolgerDs => "holger-ds",
ToolsFn::Migrate => "migrate",
ToolsFn::Security => "security",
ToolsFn::Deploy => "deploy",
ToolsFn::Airgap => "airgap",
ToolsFn::Demo => "demo",
}
}
impl HolgerUiApp {
/// **The rail's ROSTER pane — holger's master selection, hoisted out of a tab.**
///
/// A filter box over a scrolling list of every reported repository; clicking a row
/// runs the SAME [`select_repo`](HolgerUiApp::select_repo) the Repos tab's combo box
/// and the `repo:<name>` menu command run, so there is exactly ONE selection in the
/// app and every detail tab follows it. Row labels carry a writability glyph and are
/// prefixed (`▪`) so they can never collide with the bare repo names the Repos combo
/// popup and the Repositories menu render.
///
/// Returns the pane's observable state (filter, shown/total counts, the selection).
/// **Search · hits — the MASTER half (mini).** The cross-repo artifact hits as a
/// table, plus the path-axis hit count. Renders whatever the last search loaded.
#[cfg(all(feature = "mini", not(feature = "mikro")))]
fn search_hits(&mut self, ui: &mut egui::Ui) {
use facett::table::Table;
let mut table = Table::new(
"search-hits",
vec!["repo".into(), "name".into(), "version".into(), "size".into()],
);
for h in &self.data.search.artifacts {
table.push_row(vec![
h.repository.clone(),
match &h.id.namespace {
Some(ns) => format!("{ns}/{}", h.id.name),
None => h.id.name.clone(),
},
h.id.version.clone(),
h.size_bytes.to_string(),
]);
}
table.ui(ui);
if !self.data.search.paths.is_empty() {
ui.weak(format!("+ {} archive-path hits", self.data.search.paths.len()));
}
}
/// **Search · query — the INSPECTOR half (mini).** The name-axis input, the search
/// trigger, and the result counts / truncation / error readout.
#[cfg(all(feature = "mini", not(feature = "mikro")))]
fn search_query(&mut self, ui: &mut egui::Ui) -> Value {
ui.label("name contains");
let resp = ui.text_edit_singleline(&mut self.search_input);
let submitted = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
if ui.button("🔎 Search").clicked() || submitted {
let q = self.search_input.clone();
self.data.refresh_search(&q);
}
ui.separator();
let sv = &self.data.search;
if let Some(err) = &sv.error {
ui.colored_label(egui::Color32::RED, format!("error: {err}"));
} else if sv.searched {
ui.label(format!(
"{} artifact hits · {} path hits{}",
sv.artifacts.len(),
sv.paths.len(),
if sv.truncated { " (truncated — narrow the query)" } else { "" }
));
} else {
ui.weak("search every repository at once by name substring");
}
self.data.search.state_json()
}
fn rail_roster(&mut self, ui: &mut egui::Ui) -> Value {
ui.horizontal(|ui| {
ui.label("🔎");
ui.add(
egui::TextEdit::singleline(&mut self.rail_filter)
.desired_width(f32::INFINITY)
.hint_text("filter repositories"),
);
});
let needle = self.rail_filter.trim().to_lowercase();
// Collect first: the click handler below mutates `self.data`, so no borrow of the
// roster may outlive this scan.
let shown: Vec<(usize, String, bool)> = self
.data
.repos
.repos
.iter()
.enumerate()
.filter(|(_, r)| needle.is_empty() || r.name.to_lowercase().contains(&needle))
.map(|(i, r)| (i, r.name.clone(), r.writable))
.collect();
let selected = self.data.repos.selected;
let mut pick: Option<usize> = None;
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.id_salt("rail-roster-scroll")
.show(ui, |ui| {
if shown.is_empty() {
ui.weak(if self.data.repos.repos.is_empty() {
"(no repositories reported)"
} else {
"(no repository matches the filter)"
});
}
for (i, name, writable) in &shown {
// `✎` writable · `🔒` read-only. The archive/listing facts belong to
// the Selection pane below — a row label stays a stable, addressable
// identifier (a robot clicks it by name), never a live status string.
// Mini: every repo on the read-only tier is 🔒, so the icon carries no
// information — the row is just the name.
#[cfg(not(feature = "mini"))]
let label = format!("▪ {} {name}", if *writable { "✎" } else { "🔒" });
#[cfg(feature = "mini")]
let label = {
let _ = writable;
format!("▪ {name}")
};
if ui.selectable_label(selected == Some(*i), label).clicked() {
pick = Some(*i);
}
}
});
if let Some(i) = pick {
self.select_repo(i);
}
json!({
"filter": self.rail_filter,
"shown": shown.len(),
"total": self.data.repos.repos.len(),
"selected": self.data.repos.selected,
"selected_repo": self.data.repos.selected_repo().map(|r| r.name.clone()),
})
}
/// **The rail's SELECTION pane — the detail the Repos table cannot show.**
///
/// The Repos tab renders a row PER repository; this renders what the whole workspace
/// currently holds for the ONE selected repository: its capabilities, whether the
/// Browse listing / Archive view in the workspace are actually loaded FOR it (they
/// stamp the repo they fetched), and whether Upload is unlocked. That cross-tab fact
/// exists nowhere else in the shell — it is why this is an honest second pane and not
/// a decorative one.
fn rail_selection(&mut self, ui: &mut egui::Ui) -> Value {
let Some(r) = self.data.repos.selected_repo().cloned() else {
ui.weak("(no repository selected)");
ui.weak("pick one from the roster above");
return json!({ "selected": Value::Null });
};
// Loaded-FOR-this-repo, not merely "loaded": both views stamp the repository they
// fetched, so a stale listing from the previous selection reads as not loaded.
let browse_loaded = self.data.browse.repository == r.name;
let archive_loaded = self.data.archive.repository == r.name;
let browse_entries = if browse_loaded { self.data.browse.entries.len() } else { 0 };
let archive_files = if archive_loaded { self.data.archive.file_count } else { 0 };
#[cfg(not(feature = "mini"))]
let upload_enabled = self.data.repos.upload_enabled();
ui.label(egui::RichText::new(&r.name).strong());
egui::Grid::new("rail_selection_grid").num_columns(2).striped(true).show(ui, |ui| {
ui.label("type");
ui.label(&r.repo_type);
ui.end_row();
// "access" (always read-only) and "upload" (a write gate with no Upload tab)
// carry no information on the read-only mini tier — dropped there.
#[cfg(not(feature = "mini"))]
{
ui.label("access");
ui.label(if r.writable { "read · write" } else { "read only" });
ui.end_row();
}
ui.label("archive");
ui.label(if r.has_archive { "znippy" } else { "—" });
ui.end_row();
ui.label("listed");
ui.label(if browse_loaded { format!("{browse_entries} artifacts") } else { "—".into() });
ui.end_row();
ui.label("archived");
ui.label(if archive_loaded { format!("{archive_files} files") } else { "—".into() });
ui.end_row();
#[cfg(not(feature = "mini"))]
{
ui.label("upload");
ui.label(if upload_enabled { "unlocked" } else { "locked" });
ui.end_row();
}
});
// `mut` for the full tier's writable/upload_enabled inserts below.
#[cfg_attr(feature = "mini", allow(unused_mut))]
let mut state = json!({
"selected": r.name,
"repo_type": r.repo_type,
"has_archive": r.has_archive,
"browse_loaded": browse_loaded,
"browse_entries": browse_entries,
"archive_loaded": archive_loaded,
"archive_files": archive_files,
});
#[cfg(not(feature = "mini"))]
{
state["writable"] = json!(r.writable);
state["upload_enabled"] = json!(upload_enabled);
}
state
}
/// Render the shell through the declarative [`Scene`] — the menubar + the tabbed
/// pane tree — DRIVING the existing tab renderers via [`SceneHostImpl`]. Called
/// from [`HolgerUiApp::render`] in place of the old hand-wired menu-bar + tab
/// strip + `match self.tab` central panel.
///
/// `self.tab` stays the authoritative tab (so every command / demo / robot hook
/// keeps driving it): it is pushed into the scene's `active_tab` before the render
/// and, if a tab-header click moved it this frame, read back afterwards. The
/// static-mode Upload guard is re-applied on the way out. Returns the render
/// `state_json` (cached into `self.last_render`).
pub(super) fn render_scene(&mut self, ui: &mut egui::Ui) -> Value {
// Rebuild the scene each frame so the Repositories submenu tracks the live
// roster; the pane tree is constant, so the persisted `nav` stays valid.
let scene = holger_scene(&self.data.repos.repos);
let mut nav = self.nav.clone();
// Push the authoritative tab into the scene before rendering. The tab strip is
// the WORKSPACE pane (the detail side of the root split), not the root itself.
let pre = tab_index(self.tab);
nav.set_active_tab(WORKSPACE, pre);
let val = {
let mut host = SceneHostImpl { app: self };
fscene::render(&scene, &mut nav, ui, &mut host)
};
// A tab-header click this frame moved the active tab → adopt it. (A menu
// command instead changed `self.tab` directly and left `active_tab` at `pre`,
// so we don't clobber it.)
let post = nav.active_tab(WORKSPACE);
if post != pre {
self.tab = tab_from_index(post);
}
// Write tabs are unavailable in static (read-only) mode — bounce Upload back.
if self.static_mode && self.tab == Tab::Upload {
self.tab = Tab::Status;
}
nav.set_active_tab(WORKSPACE, tab_index(self.tab));
self.nav = nav;
val
}
/// The declarative shell as observable data (folded into the app's `state_json`):
/// the pane tree, which panes are foldable, the live fold + active-tab state, and
/// the last render's facts (device class, pane rects, per-slot state).
pub(super) fn scene_state_json(&self) -> Value {
// Start from the last render's OWN facts (`scene` name, the responsive `device`
// class + `width`, and the per-pane `panes` render map) so the adopted-scene and
// responsive-fold oracles read them at the top level of `/scene`, then fold the
// declarative shell's structure in beside them. Both surfaces, one object.
let mut out = match self.last_render.clone() {
Value::Object(m) => m,
_ => serde_json::Map::new(),
};
out.insert("name".into(), json!(self.scene.name));
// The pane tree as a NAME LIST (the render map above already owns `panes`, keyed
// by pane name — this is the flat roster the addressability oracle walks).
out.insert("pane_names".into(), json!(self.scene.pane_names()));
out.insert("foldable".into(), json!(foldable_panes(&self.scene)));
// The DRAGGABLE dividers, by pane name — the affordance roster a robot drags and
// an operator grabs. Derived from the scene, so a split that silently loses its
// `resizable` disappears from here (RED-when-broken, not "still declared").
out.insert("draggable_splits".into(), json!(draggable_splits(&self.scene)));
// Every pane that draws a WINDOW-CHROME title bar (fold · minimise · maximise).
out.insert("chrome".into(), json!(self.scene.chrome_panes()));
// facett-app's own adoption audit — the one blob naming every affordance this
// shell ships. Asserting against THIS (rather than a hand-copied list) is what
// makes "the chrome silently disappeared" a red test instead of a shrug.
out.insert("affordances".into(), self.scene.affordances());
// The DRAWER panes (auto-fold crossing into phone-class).
out.insert("drawers".into(), json!(drawer_panes(&self.scene)));
// **The tab INTERNALS, as data.** Which tab bodies decomposed into a draggable
// master/detail split of `<slot>_main` | `<slot>_inspector`, and which are
// honestly ONE pane. A robot reads this instead of hard-coding the decomposition,
// so a tab split (or un-split) later is swept without editing the suite.
out.insert("split_bodies".into(), json!(split_bodies()));
out.insert("single_pane_bodies".into(), json!(single_pane_bodies()));
out.insert("menus".into(), json!(self.scene.menubar.len()));
// The tab strip lives on the WORKSPACE pane (the detail side of the root split).
out.insert("active_tab".into(), json!(self.nav.active_tab.get(WORKSPACE)));
out.insert("folded".into(), json!(self.nav.folded));
// The live WINDOW state: which panes are collapsed to their title bar, which one
// (at most) holds the whole shell, and the last chrome control that fired.
out.insert("minimized".into(), json!(self.nav.minimized));
out.insert("maximized".into(), json!(self.nav.maximized));
out.insert("last_chrome".into(), json!(self.nav.last_chrome));
// The LIVE split ratios (a divider drag moves them; observable without pixels).
out.insert("split_ratio".into(), json!(self.nav.split_ratio));
out.insert("render".into(), self.last_render.clone());
Value::Object(out)
}
/// Robot/test hook: fold or unfold a scene pane by name (the same toggle a
/// fold-header click runs). Returns the new folded state. Observable in
/// `state_json["scene"]["folded"]`.
pub fn toggle_scene_fold(&mut self, pane: &str) -> bool {
self.nav.toggle_fold(pane)
}
/// Robot/test/command hook: **minimise** a scene pane — collapse it to its chrome
/// title bar and hand its space back to its siblings. The same toggle the bar's `−`
/// control runs. Returns the new minimised state; observable in
/// `state_json["scene"]["minimized"]`, and in the pane's shrunken render `alloc`.
pub fn toggle_scene_minimize(&mut self, pane: &str) -> bool {
self.nav.toggle_minimize(pane)
}
/// Robot/test/command hook: **maximise** a scene pane — give it the whole shell
/// area, shrinking every non-ancestor sibling to zero. At most one pane holds it.
/// The same toggle the bar's `🗖` control runs. Returns the new maximised state;
/// observable in `state_json["scene"]["maximized"]`.
pub fn toggle_scene_maximize(&mut self, pane: &str) -> bool {
self.nav.toggle_maximize(pane)
}
/// Robot/test/command hook: **restore** a scene pane — drop its minimise AND its
/// maximise. The split ratios were never overwritten, so the layout returns exactly
/// as it was. Returns true if anything changed.
pub fn restore_scene_pane(&mut self, pane: &str) -> bool {
self.nav.restore(pane)
}
/// Robot/test/command hook: **restore every pane** (the "reset layout" one-liner).
/// Returns true if anything changed.
pub fn restore_scene_layout(&mut self) -> bool {
self.nav.restore_all()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn repo(name: &str) -> RepoRow {
RepoRow {
name: name.into(),
repo_type: "rust".into(),
writable: true,
has_archive: false,
}
}
fn scene() -> Scene {
holger_scene(&[repo("rust-dev"), repo("cache")])
}
/// The label a pane's chrome bar / fold header actually paints (title, else name).
///
/// A local mirror of facett-app's own `Pane::header_title`, which is PRIVATE — the
/// host cannot audit the AccessKit labels its own scene publishes without it. See the
/// facett patch note in this module's docs.
fn header_title(p: &Pane) -> &str {
if p.title.is_empty() { &p.name } else { &p.title }
}
/// The shell scene is addressable: every pane name AND every menu name is unique
/// (`Scene::validate`) — RED-when-broken if a tab id or menu id ever collides.
#[test]
fn holger_scene_is_valid() {
scene().validate().expect("unique pane + menu names");
// root split · rail split · 2 rail leaves · workspace tabs · one body per tab,
// plus the two halves of every body that DECOMPOSED into a master/detail split.
let names = scene().pane_names();
for structural in [ROOT, RAIL, RAIL_ROSTER_PANE, RAIL_SELECTION_PANE, WORKSPACE] {
assert!(
names.contains(&structural.to_string()),
"the `{structural}` structural pane is present: {names:?}"
);
}
assert_eq!(
names.len(),
5 + TAB_ORDER.len() + 2 * split_bodies().len(),
"root + rail + 2 rail leaves + workspace + one body per tab + 2 halves per \
decomposed body: {names:?}"
);
}
/// **Every tab body that claims a master/detail split really is one — DRAGGABLE,
/// two named halves, each its own foldable/chromed pane.**
///
/// This is the regression that shipped: the SHELL adopted the scene, the tab
/// INTERNALS never did, so minimise/maximise/drag operated on a whole tab and could
/// do nothing inside it. RED-when-broken the moment a body is downgraded back to a
/// slot leaf or its split loses `resizable`.
#[test]
fn every_decomposed_tab_body_is_a_draggable_split_of_two_named_halves() {
let s = scene();
let splits = draggable_splits(&s);
let bodies = split_bodies();
assert!(!bodies.is_empty(), "at least one tab body decomposes");
for t in TAB_ORDER {
let body = format!("{}_pane", t.slot_id());
let pane = s.pane(&body).unwrap_or_else(|| panic!("`{body}` exists"));
let Some(b) = body_split(t) else {
// An HONEST single pane: a slot leaf, and it must NOT claim a divider.
assert!(
matches!(pane.content, fscene::Content::Slot(_)),
"`{body}` is honestly one pane — a slot leaf, got {:?}",
pane.layout
);
assert!(
!splits.contains(&body),
"a single-pane body declares no divider: {splits:?}"
);
continue;
};
match &pane.layout {
Layout::Split { axis, ratios, resizable: true } => {
assert_eq!(*axis, b.axis, "`{body}` splits along its declared axis");
assert!(
(ratios[0] - b.ratio).abs() < 1e-5,
"`{body}` seeds its master at {}: {ratios:?}",
b.ratio
);
}
other => panic!("`{body}` is a DRAGGABLE split, got {other:?}"),
}
assert!(splits.contains(&body), "`{body}` is reported draggable: {splits:?}");
// Both halves exist, are foldable (⇒ chromed), and carry DISTINCT titles that
// shadow neither the tab label nor each other.
let foldable = foldable_panes(&s);
for (half, title) in [("main", b.main.0), ("inspector", b.inspector.0)] {
let name = format!("{}_{half}", t.slot_id());
let p = s.pane(&name).unwrap_or_else(|| panic!("`{name}` exists"));
assert_eq!(header_title(p), title, "`{name}` keeps its declared title");
assert!(foldable.contains(&name), "`{name}` folds: {foldable:?}");
assert_ne!(header_title(p), t.label(), "`{name}` does not shadow the tab label");
}
assert_ne!(b.main.0, b.inspector.0, "`{body}`'s halves are distinctly titled");
}
// The two rosters partition the strip — no tab is both, none is neither.
assert_eq!(
bodies.len() + single_pane_bodies().len(),
TAB_ORDER.len(),
"every tab is either decomposed or honestly single-pane"
);
}
/// **No two addressable labels in the shell collide.** Pane titles, tab labels and
/// menu-group names all become AccessKit-labelled nodes, and `query_by_label` ERRORS
/// on ambiguity — so a second `🗄 Warehouse`-style duplicate makes both nodes
/// unreachable. Chrome is default-ON, which turned every pane TITLE into such a node,
/// so this must hold over the whole scene and not just the tab strip.
#[test]
fn every_pane_title_tab_label_and_menu_group_is_uniquely_addressable() {
let s = scene();
let mut seen: Vec<(String, String)> = Vec::new();
let mut claim = |label: String, kind: &str| {
if let Some((other, _)) = seen.iter().find(|(l, _)| *l == label) {
panic!("`{label}` is claimed by BOTH a {other} and a {kind} — ambiguous");
}
seen.push((kind.to_string(), label));
};
for name in s.pane_names() {
let p = s.pane(&name).expect("pane");
// The tab BODIES are titled with their tab label ON PURPOSE — the TabView
// header reads that title — so they are claimed once, as the tab.
claim(header_title(p).to_string(), "pane title");
}
for m in &s.menubar {
claim(m.label.clone(), "menu group");
}
}
/// The shell round-trips through RON byte-stably (`to_ron ∘ from_ron` is a fixed
/// point) AND the RON parses back to the SAME `Scene` the builder produced —
/// RED-when-broken if the serde shape or the canonical RON config drifts.
#[test]
fn holger_scene_ron_round_trips_byte_stable() {
let s = scene();
let ron1 = s.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!(s, reparsed, "RON and the builder yield the SAME shell Scene");
}
/// Every tab body AND every rail pane is a foldable content pane (the fold
/// interaction covers them all) — RED-when-broken if a leaf loses its `Foldable`.
#[test]
fn every_tab_leaf_and_rail_pane_is_foldable() {
let s = scene();
let foldable = foldable_panes(&s);
for t in TAB_ORDER {
let pane = format!("{}_pane", t.slot_id());
assert!(foldable.contains(&pane), "the `{pane}` tab body is foldable: {foldable:?}");
}
for rail in [RAIL, RAIL_ROSTER_PANE, RAIL_SELECTION_PANE] {
assert!(foldable.contains(&rail.to_string()), "`{rail}` is foldable: {foldable:?}");
}
// …and so is every HALF of a decomposed body: the fold/minimise/maximise chrome
// reaches INSIDE a tab, which is the whole point of the decomposition.
for half in split_bodies() {
let slot = half.trim_end_matches("_pane");
for name in [format!("{slot}_main"), format!("{slot}_inspector")] {
assert!(foldable.contains(&name), "`{name}` is foldable: {foldable:?}");
}
}
assert_eq!(
foldable.len(),
TAB_ORDER.len() + 3 + 2 * split_bodies().len(),
"all {} tab bodies + their split halves + the rail and its two panes fold",
TAB_ORDER.len()
);
}
/// **The shell has real structure: two DRAGGABLE dividers and a drawer.** The root
/// is a resizable horizontal master/detail split (rail | workspace) that collapses
/// to `Tabs` on a phone-class width; the rail is a resizable VERTICAL split and a
/// drawer; the workspace is the `Tabs` pane whose active index maps to a real
/// [`Tab`] both ways (the `self.tab` ⇄ `active_tab` bridge the renderer relies on).
///
/// RED-when-broken the moment a split is downgraded to `split_fixed` — which is the
/// exact regression this shell already shipped once, as a shell with no affordances.
#[test]
fn shell_is_a_draggable_master_detail_split_over_a_drawer_rail() {
let s = scene();
// The root: a DRAGGABLE horizontal split, rail-first, at the 0.22 seed.
match s.pane(ROOT).map(|p| &p.layout) {
Some(Layout::Split { axis: Axis::Horizontal, ratios, resizable: true }) => {
assert!(
(ratios[0] - 0.22).abs() < 1e-5,
"the rail seeds at 0.22 of the shell width: {ratios:?}"
);
assert!((ratios.iter().sum::<f32>() - 1.0).abs() < 1e-5, "ratios sum to 1");
}
other => panic!("the shell root is a DRAGGABLE horizontal split, got {other:?}"),
}
// …that collapses to Tabs with the WORKSPACE (child 1) active on a phone.
let root = s.pane(ROOT).expect("root");
assert_eq!(
root.responsive.phone,
Some(Layout::Tabs { active: 1 }),
"phone-class collapses the split to Tabs on the workspace"
);
assert_eq!(root.responsive.collapse_below, Some(PHONE_BREAKPOINT));
// The rail: a DRAGGABLE vertical split, and a drawer.
let rail = s.pane(RAIL).expect("the registry rail");
assert!(
matches!(rail.layout, Layout::Split { axis: Axis::Vertical, resizable: true, .. }),
"the rail is a DRAGGABLE vertical split, got {:?}",
rail.layout
);
assert!(rail.responsive.drawer, "the rail is a phone drawer");
// The SHELL's two dividers come first (root, then rail), and every remaining
// draggable split is a decomposed tab BODY — nothing else claims to be draggable.
let mut expected = vec![ROOT.to_string(), RAIL.to_string()];
expected.extend(split_bodies());
assert_eq!(
draggable_splits(&s),
expected,
"the two shell dividers, root-first, then one per decomposed tab body"
);
assert_eq!(drawer_panes(&s), vec![RAIL.to_string()], "the rail is the one drawer");
// The workspace is the Tabs pane, and the index bridge is total.
assert!(
matches!(s.pane(WORKSPACE).map(|p| &p.layout), Some(Layout::Tabs { .. })),
"the workspace is a Tabs layout"
);
for (i, tab) in TAB_ORDER.iter().enumerate() {
assert_eq!(tab_index(*tab), i, "tab_index round-trips");
assert_eq!(tab_from_index(i), *tab, "tab_from_index round-trips");
}
}
/// The menubar carries the five grouped menus, and each leaf routes an
/// `Action::Command(<existing id>)` — proof the menu reuses the ONE dispatcher
/// (no second command interpreter). RED-when-broken if a group or a wired command
/// is dropped.
///
/// There were SIX groups: a top-level **Look** submenu listed every theme preset.
/// The root menu is navigation (View · Repositories) and actions (Operate · Action ·
/// Help); appearance is a preference, so it moved into the ⚙ Settings pane
/// (`HolgerUiApp::settings_pane`), matching dwarves-demo's `settings` tab. This
/// asserts the menubar is now free of look & feel — see
/// [`no_look_and_feel_at_the_shell_root`] for the RED-when-it-comes-back guard, and
/// [`super::super::look_tests::settings_pane_is_the_one_home_of_look_and_feel`] for
/// the proof the control still works where it landed.
///
/// [`no_look_and_feel_at_the_shell_root`]: self::no_look_and_feel_at_the_shell_root
// FULL TIER ONLY: the read-only tiers carry no View / Operate / Repositories
// groups (no full-tier commands to route), so this asserts the full menubar.
#[cfg(not(feature = "mini"))]
#[test]
fn menubar_groups_route_existing_commands() {
let s = scene();
let groups: Vec<&str> = s.menubar.iter().map(|m| m.name.as_str()).collect();
assert_eq!(
groups,
vec!["view", "operate", "repositories", "action", "help"],
"the five grouped menus, in order — navigation + actions, no appearance"
);
// A representative leaf from each functional group carries a Command action
// whose id the existing `dispatch_command` understands.
for id in ["goto:browse", "tool:security", "action:refresh", "about:open"] {
let item = s.menu_item(id).unwrap_or_else(|| panic!("menu item {id} exists"));
assert_eq!(
item.action,
Some(Action::Command(id.to_string())),
"menu `{id}` routes Action::Command(\"{id}\") into the shared dispatcher"
);
}
// Settings is reachable from the ROOT the same way every other view is — the
// View menu's `goto:` entry, derived from `TAB_ORDER`. Moving the look control
// off the menubar did not put it behind a hidden door.
let item = s.menu_item("goto:settings").expect("View ▸ Settings exists");
assert_eq!(item.action, Some(Action::Command("goto:settings".into())));
// The runtime Repositories submenu carries one command per reported repo.
assert!(s.menu_item("repo:rust-dev").is_some(), "a repo becomes a menu command");
}
/// **RED-when-broken: NO look & feel at the shell ROOT.**
///
/// The declarative scene is the whole root surface — the menubar plus the pane
/// tree — so this walks every menu group, every menu leaf id and every leaf LABEL
/// and asserts none of them is an appearance control. A re-added `Look` menu, or a
/// stray `look:<preset>` leaf smuggled into another group, fails here.
///
/// The look control is not gone, it MOVED: `settings` is a registered pane in this
/// same scene (asserted below), and `look:<name>` still resolves in the shared
/// `dispatch_command` for Ctrl-K and the headless drives.
#[test]
fn no_look_and_feel_at_the_shell_root() {
let s = scene();
// (a) No menubar GROUP is an appearance group.
for m in &s.menubar {
assert_ne!(m.name, "look", "the root Look menu is gone — it lives in ⚙ Settings");
assert!(
!m.label.eq_ignore_ascii_case("look") && !m.label.eq_ignore_ascii_case("theme"),
"no appearance group on the menubar: {:?}",
m.label
);
}
// (b) No menu LEAF anywhere in the bar is a look command — not by id, and not
// by label (a preset re-added under a different id would still read "Look: …").
for name in super::super::look_names() {
assert!(
s.menu_item(&format!("look:{name}")).is_none(),
"the `look:{name}` preset must not be a menubar leaf"
);
}
// (c) …and the pane it moved to really exists in this scene, foldable, with the
// shared 🎛 glyph — a REAL pane, not a modal or a dropdown.
let pane = s.pane("settings_pane").expect("the ⚙ Settings pane is registered");
assert_eq!(pane.title, "Settings");
assert!(foldable_panes(&s).iter().any(|p| p == "settings_pane"), "Settings folds");
assert_eq!(fold_icon(Tab::Settings), "🎛", "the suite-wide Settings glyph");
}
/// **No two View-menu entries may claim the same keyboard accelerator.**
///
/// RED-when-broken, and it WAS broken: the accelerator came from
/// `Ctrl+{(i + 1) % 10}`, written when the strip had ten tabs. The eleventh
/// (Sign in) wrapped — `(10 + 1) % 10 == 1` — so the menu advertised `Ctrl+1`
/// for BOTH Status and Sign in. A modulo never fails; it silently aliases. This
/// asserts uniqueness over the whole strip, so the twelfth tab cannot repeat it.
// The View menu's digit accelerators exist only on the full tier (the read-only
// tiers carry no View menu), so the test that holds them to uniqueness rides on
// the same gate its subject does.
#[cfg(not(feature = "mini"))]
#[test]
fn view_accelerators_are_unique_across_the_whole_strip() {
let s = scene();
let view = s
.menubar
.iter()
.find(|m| m.name == "view")
.expect("the View menu exists");
let mut seen: Vec<(&str, &str)> = Vec::new();
for item in &view.items {
let Some(keys) = item.shortcut.as_deref() else { continue };
if let Some((other, _)) = seen.iter().find(|(_, k)| *k == keys) {
panic!("View menu advertises `{keys}` for BOTH `{other}` and `{}`", item.name);
}
seen.push((item.name.as_str(), keys));
}
// Every tab is offered; the first ten carry a digit accelerator, the rest
// honestly carry none (there are only ten digits).
assert_eq!(view.items.len(), TAB_ORDER.len(), "one View entry per tab");
assert_eq!(seen.len(), TAB_ORDER.len().min(10), "the digit row, and no more");
// The generator itself, at the boundary that broke.
assert_eq!(view_accelerator(0).as_deref(), Some("Ctrl+1"));
assert_eq!(view_accelerator(8).as_deref(), Some("Ctrl+9"));
assert_eq!(view_accelerator(9).as_deref(), Some("Ctrl+0"));
assert_eq!(view_accelerator(10), None, "the 11th tab gets none, not a duplicate");
assert_eq!(view_accelerator(11), None);
}
}