facett-app/src/scene.rs
//! **`scene` — a DECLARATIVE UI-SCENE layer over facett-app.** Describe a whole
//! app's shell as *data* — a menubar, a tree of foldable panes, and a set of
//! navigation routes — then render it through this crate's OWN primitives
//! ([`Router`](crate::Router) is the nav engine, [`TabView`](crate::TabView) draws
//! tab strips, [`facett_menu`] draws the menubar). Nothing here reinvents
//! navigation or layout: the scene is a thin, serde-friendly *description* that
//! [`render`] walks.
//!
//! Two authoring forms, ONE [`Scene`] value out (both via serde):
//! - **RON** — [`Scene::from_ron`] / [`Scene::to_ron`] (byte-stable round-trip).
//! - **Rust builder** — [`Pane::flow`]/[`Pane::split`]/… + [`Scene::new`].
//!
//! **Interactions (all declarative-data-driven):**
//! - **Per-pane WINDOW CHROME — on by DEFAULT.** Every pane except the scene root
//! draws a [`Chrome`] title bar carrying *fold* · *minimise* · *maximise/restore*.
//! Minimising collapses the pane to that bar and **hands its space back to its
//! siblings** (a split child shrinks to [`CHROME_BAR`] points); maximising gives the
//! pane the whole scene area and shrinks every non-ancestor sibling to **zero**;
//! restore returns the EXACT prior ratios (they are never overwritten). All of it is
//! in `state_json` (`nav.minimized` / `nav.maximized`, plus per-pane `chrome` button
//! rects), so a robot can drive it and read the resulting GEOMETRY back.
//! Opt out per pane with [`Pane::no_chrome`] — the default is ON because capability
//! that ships default-off is capability nobody ever sees.
//! - **Draggable / resizable splits.** A [`Layout::Split`] carries per-child `ratios`
//! and a `resizable` flag (**defaulting to `true`**, including for hand-authored RON
//! that omits it). When resizable, the renderer draws a real drag handle
//! between each pair of children; dragging it re-apportions the two adjacent ratios,
//! and the LIVE ratios live in [`NavState`] (observable in `state_json`) — this is a
//! genuine binary/N-way split tree (the tiling-WM / `egui_dock` / Golden-Layout
//! model), not a static fraction.
//! - **Fold / unfold.** A [`Fold::Foldable`] pane renders a header with a fold toggle
//! and, when folded, draws ONLY that header — its contents are omitted from the
//! layout (and so from the AccessKit tree), mirroring the ⚙ fold in `facett-graphnav`.
//! Fold is the *authored* section collapse (it hides the body but KEEPS the pane's
//! slot); chrome's *minimise* is the runtime window collapse (it hides the body AND
//! gives the slot back). The chrome bar **hosts** the fold toggle rather than drawing
//! a second header beside it (L5 — extend the fold primitive, never twin it).
//! - **Runtime presence.** A pane the HOST declares absent ([`NavState::set_absent`])
//! does not exist on screen: zero extent, no chrome, no slot fill, adjacent split
//! dividers stand down, no tab header — its siblings absorb the space by their
//! unchanged ratios, so presence returning restores the exact prior arrangement.
//! For a pane whose *subject* does not exist right now (a configurator for a recipe
//! that declares none); fold/minimise keep chrome on screen, presence removes it.
//! Never authored: RON scenes declare what CAN exist, the host says what DOES.
//! - **Responsive desktop ↔ phone.** The renderer picks a [`Device`] class from the
//! available width; a pane's [`Responsive`] can swap its layout on phone/tablet
//! (e.g. a `Split` collapses to `Tabs`), and a `drawer` pane auto-folds when the
//! width crosses into phone-class (a desktop rail becomes a phone drawer).
//!
//! Navigation reuses the [`Router`](crate::Router): [`NavState`] wraps it with the
//! per-pane fold + active-tab + split-ratio + focus state the renderer reads and the
//! routes mutate. Unique pane names make every pane addressable (deep-link `app#pane`).
//!
//! # The AREA / EDITOR model (Blender's, not the IDE's)
//!
//! An IDE *perspective* has a privileged central editor with tool windows arranged
//! around it. Blender has no centre: the window is tiled into **areas**, and **any area
//! can host any editor type**, swapped from a dropdown in that area's own header. For a
//! control centre that is strictly more powerful — a set of "tabs" stops being a set of
//! *screens* and becomes a set of **editor types**, so an analyst can put Graph and
//! Tables side by side, or two Graphs at different queries. The tab model forbids both
//! by construction.
//!
//! | Blender | here |
//! |---|---|
//! | area (tiled rect, never floating) | a [`Pane`] with [`Content::Area`] |
//! | editor type, swappable per area | an [`Editor`](crate::editor::Editor) in the host's [`EditorRegistry`](crate::editor::EditorRegistry) |
//! | workspace tabs (Layout / Shading / …) | [`Workspace`] — a named BODY tree, spliced at the scene's [`Content::Body`] pane |
//! | area header | the [`Chrome`] title bar, which now also carries the editor dropdown |
//! | split / join by dragging an edge | [`Layout::Split`] with its live ratios |
//! | Ctrl+Space maximise area | [`NavState::toggle_maximize`] |
//! | the topbar / status bar | ordinary panes with [`Pane::fixed`](struct.Pane.html#structfield.fixed) |
//!
//! **ONE shell, N workspaces.** [`Scene::root`] is the shell skeleton — menu bar ·
//! workspace bar · toolbar · body · status bar — and it is authored **once**; a
//! [`Workspace`] owns the body and nothing else. So shell chrome cannot drift between
//! workspaces, and `shell.menubar` / `shell.statusbar` address the same panes in every
//! workspace: a saved route, a persisted layout key and a robot's `panes["…"]` do not
//! shift underneath the caller when the workspace changes. Pane-name uniqueness is scoped
//! to (shell ∪ ONE workspace), so `main` / `side` may repeat across workspaces —
//! [`NavState`] keeps the flat maps for shell ∪ the ACTIVE body and parks the rest under
//! [`NavState::workspaces`], which is how a layout file tells the two apart without
//! qualifying a single address.
//!
//! **The pane NAME is identity; the editor it hosts is STATE.** The binding lives in
//! [`NavState::area_editor`] and is published in `state_json`, so a robot addresses the
//! same pane, and reads back the same rect, across every swap.
//!
//! # The bug this model makes structurally impossible
//!
//! > korp→Graph's centrality readout grew a row per node until it pushed the graph canvas
//! > off the bottom of the screen, because canvas and readout were consecutive widgets in
//! > ONE pane's vertical flow.
//!
//! Three locks, each asserted on measured geometry rather than a flag:
//!
//! 1. **Composition.** An inspector is a *sibling area* under a [`Layout::Split`], never
//! a widget in the canvas's flow. the split apportionment derives rects from
//! `(parent rect, ratios)` alone (`split_extents`) — **content length is not an input to that
//! function** — so a 100× larger readout leaves the canvas's `alloc` byte-identical.
//! 2. **Clip + overflow.** The allotment holding is not the pixels staying inside it: a
//! child `Ui` inherits its parent's clip rect, so an over-long leaf can paint over its
//! sibling while every rect assertion passes. Every bounded leaf now clips to its
//! allotment and, under [`Overflow::Auto`], scrolls inside it. `state_json` publishes
//! `clipped` and `overflowed` per pane, so "the content escaped" is measurable.
//! 3. **Authoring.** [`Content::Area`] takes **one** editor id, not a list. There is no
//! affordance in the model for "two widgets in one area" — to show two things you
//! split. The bug's shape is unrepresentable.
//!
//! # Persistence
//!
//! Chrome state used to die with the process. [`NavState`] is now serde and
//! [`crate::persist`] writes it to `<state dir>/<app>.layout.json`: the active workspace,
//! the shell ∪ active-body arrangement (split ratios, minimises, the maximise, folds,
//! active tabs, per-area editor bindings), and every inactive workspace's parked body.
//! The *scene* is never written — it is the app's authored design and ships with the
//! binary.
use crate::router::Router;
use crate::tabview::TabView;
use crate::workspace::{WORKSPACEBAR_H, Workspace, WorkspaceSlot};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::BTreeMap;
// ─────────────────────────────────────────────────────────────────────────────
// SCHEMA — the whole app UI as data. serde-derive on every node.
// ─────────────────────────────────────────────────────────────────────────────
/// A whole app's UI described as data: a menubar, a root pane tree, and routes.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Scene {
/// The scene / app name (also the deep-link namespace, and the layout file's
/// persistence key).
pub name: String,
/// Display title — what a workspace tab shows. Falls back to `name`. Skipped on
/// serialize while empty, so every pre-existing scene's RON is byte-identical.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub title: String,
/// The nested menubar (`mainmenu { menu1 { menu1.1 } }`).
#[serde(default)]
pub menubar: Vec<MenuItem>,
/// The root pane — the whole layout tree hangs off it.
pub root: Pane,
/// Navigation routes: trigger → target (over the shared [`Router`]).
#[serde(default)]
pub routes: Vec<Route>,
/// **The WORKSPACES** — named body arrangements, spliced into the shell's
/// [`Content::Body`] pane one at a time. Empty ⇒ a plain single-arrangement scene,
/// exactly as before (and the field is skipped on serialize, so no existing RON moved
/// a byte).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub workspaces: Vec<Workspace>,
/// Which workspace is authored as the default (index into `workspaces`). The LIVE one
/// is [`NavState::active_workspace`].
#[serde(default, skip_serializing_if = "is_zero")]
pub active_workspace: usize,
}
/// Serde skip predicate for the default workspace index.
fn is_zero(n: &usize) -> bool {
*n == 0
}
/// Every pane name in a tree, preorder. Used for the SHELL half of every scoped query
/// (a workspace body is walked by [`Workspace::pane_names`]).
fn shell_names(root: &Pane) -> Vec<String> {
let mut out = Vec::new();
fn walk(p: &Pane, out: &mut Vec<String>) {
out.push(p.name.clone());
if let Content::Children(kids) = &p.content {
for k in kids {
walk(k, out);
}
}
}
walk(root, &mut out);
out
}
/// One (possibly nested) menu entry. `name` is UNIQUE across the whole menubar.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MenuItem {
/// Unique id (addressable by [`Trigger::Menu`]).
pub name: String,
/// The human label shown in the menu.
pub label: String,
/// Optional keyboard-shortcut hint (display only; the OS binding is the host's).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shortcut: Option<String>,
/// What clicking it does (a leaf without children usually carries one).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub action: Option<Action>,
/// Child entries (a submenu). Empty ⇒ a leaf.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub items: Vec<MenuItem>,
}
/// A layout pane. `name` is UNIQUE across the whole scene (⇒ addressable / foldable).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Pane {
/// Unique id — the fold/nav/deep-link handle.
pub name: String,
/// Header title (shown on the fold header; falls back to `name`).
#[serde(default)]
pub title: String,
/// Optional hover tooltip for this pane's TAB header — when this pane is a child of a
/// [`Layout::Tabs`] parent, [`TabView`] renders it as `on_hover_text` on the tab button
/// (a "what does this view do" hint). `None` (the default) leaves the strip
/// byte-identical, and it is skipped on serialize so existing RON scenes round-trip.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tooltip: Option<String>,
/// How this pane arranges its children (ignored for a [`Content::Slot`]).
#[serde(default)]
pub layout: Layout,
/// Fixed, or foldable (THE interaction).
#[serde(default)]
pub fold: Fold,
/// Per-pane window chrome — the title bar with fold / minimise / maximise.
/// **Defaults to [`Chrome::Auto`] (ON for every pane but the scene root)**; it is
/// skipped on serialize while it holds that default, so an existing RON scene
/// round-trips BYTE-IDENTICALLY.
#[serde(default, skip_serializing_if = "Chrome::is_auto")]
pub chrome: Chrome,
/// Optional per-device layout overrides + a collapse breakpoint.
#[serde(default, skip_serializing_if = "Responsive::is_empty")]
pub responsive: Responsive,
/// **How this pane's content behaves when it outgrows its allotment** — the CLIP
/// LOCK. Defaults to [`Overflow::Auto`] (scroll inside a bounded parent), skipped on
/// serialize while it holds that default.
#[serde(default, skip_serializing_if = "Overflow::is_auto")]
pub overflow: Overflow,
/// **A FIXED extent (points) along the parent [`Layout::Split`]'s axis**, instead of
/// a share of the ratios. This is what makes the shell's own chrome — the menu bar,
/// the toolbar, the status bar — ordinary panes: they are rows of the root split
/// that ask for an exact height, not special-cased widgets outside the tree.
///
/// The remaining extent is apportioned among the *flexible* siblings by their
/// unchanged ratios, exactly as a minimised sibling's [`CHROME_BAR`] is. Ignored
/// outside a `Split` parent. Skipped on serialize while unset.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fixed: Option<f32>,
/// Child panes or a host-provided widget slot.
pub content: Content,
}
/// How a pane arranges its children.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
pub enum Layout {
/// Horizontal wrapped flow (children laid left-to-right, wrapping).
#[default]
Flow,
/// An N-way split along `axis`. `ratios` is one fraction per child (they are
/// normalised to sum to 1). When `resizable`, a real drag handle sits between each
/// adjacent pair; dragging re-apportions those two ratios and the LIVE ratios are
/// kept in [`NavState`] (so a drag is observable in `state_json`). When not
/// `resizable` the ratios are a fixed apportionment (no handle).
///
/// `resizable` **defaults to `true`**: a hand-authored RON `Split` that omits the
/// field gets a real, draggable divider. Being un-draggable must be an explicit,
/// deliberate act ([`Pane::split_fixed`]) — never an accident of omission.
Split {
/// Which way the children are laid out.
axis: Axis,
/// One fraction per child (normalised to sum to 1).
ratios: Vec<f32>,
/// Whether a real drag handle sits between adjacent children (**default true**).
#[serde(default = "yes")]
resizable: bool,
},
/// A grid of `cols` columns.
Grid { cols: usize },
/// A tab strip; `active` is the initially-selected child index.
Tabs { active: usize },
}
/// Split axis.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub enum Axis {
/// Left / right.
Horizontal,
/// Top / bottom.
Vertical,
}
/// A pane's content: child panes, a host-supplied widget slot, or an **area** bound to
/// a swappable editor type.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum Content {
/// Child panes laid out by this pane's [`Layout`].
Children(Vec<Pane>),
/// A named slot the [`SceneHost`] fills with a real widget. A slot is bound to ONE
/// widget for the life of the scene — use [`Content::Area`] when the operator should
/// be able to change what is shown there.
Slot(String),
/// **The scene's MENU BAR, as a pane.** Draws [`Scene::menubar`] in place, so the top
/// menu is a fixed-height region of the pane tree like any other — it has a name, a
/// rect and an `alloc` in `state_json`, and it can be moved, folded or dropped by
/// editing the tree. A scene with a menubar pane does NOT also draw the implicit top
/// strip; without one, the implicit strip is drawn exactly as before.
Menubar,
/// **THE SPLICE POINT** — where the active [`Workspace`]'s body tree is grafted into
/// the shell at render time. Everything around it (menu bar, workspace bar, toolbar,
/// status bar) is authored ONCE and does not move when the workspace changes; only
/// what hangs below this pane varies. With no workspaces declared it draws nothing.
Body,
/// **The workspace TAB STRIP, as a pane** — Blender's topbar tabs. A fixed-height row
/// of the shell, so it has a name, a rect and an `alloc` like every other pane, and
/// each tab publishes a clickable rect plus a `"workspace <name>"` AccessKit label.
WorkspaceBar,
/// **An AREA** (Blender's model): a tiled rect that can host *any* registered editor
/// type, swapped at runtime from the dropdown in its own header. `editor` is the
/// **default** id — the live binding is *state* ([`NavState::area_editor`]), so the
/// pane NAME stays the stable handle a robot addresses while the editor underneath
/// changes. `allow` gates which types the header offers.
///
/// **It takes ONE editor id, not a list.** There is no affordance anywhere in the
/// model for "two widgets in one area" — to show two things you split into two
/// areas. That is the third lock on the korp→Graph bug: its shape is unrepresentable.
Area {
/// The default editor id.
editor: String,
/// Which editor types this area's header offers.
#[serde(default, skip_serializing_if = "Allow::is_any")]
allow: Allow,
},
}
/// Which editor types an [`Content::Area`]'s header dropdown offers.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum Allow {
/// **The default** — every type in the host's registry.
#[default]
Any,
/// Only these ids (in this order), intersected with the registry.
Only(Vec<String>),
/// **Pinned**: no dropdown at all. The area shows its editor's name as a plain label
/// and cannot be swapped — for the handful of surfaces where a swap is meaningless.
Fixed,
}
impl Allow {
/// Is this the (skipped-on-serialize) default?
pub fn is_any(&self) -> bool {
matches!(self, Allow::Any)
}
/// Filter the host's registry options down to what this area offers.
fn filter(&self, options: Vec<(String, String)>) -> Vec<(String, String)> {
match self {
Allow::Any => options,
Allow::Fixed => Vec::new(),
Allow::Only(ids) => {
ids.iter().filter_map(|w| options.iter().find(|(o, _)| o == w).cloned()).collect()
}
}
}
}
/// A pane's fold behaviour — the ONE interaction.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
pub enum Fold {
/// Always laid out; no fold affordance.
#[default]
Fixed,
/// Foldable to a small header. `default_open` seeds [`NavState`]; `icon` decorates
/// the header.
Foldable {
/// Whether the pane starts unfolded.
default_open: bool,
/// Optional header glyph (e.g. `"⚙"`).
#[serde(default, skip_serializing_if = "Option::is_none")]
icon: Option<String>,
},
}
/// Serde default for [`Layout::Split`]'s `resizable`: **draggable**.
fn yes() -> bool {
true
}
/// **The CLIP LOCK** — what a pane does when its content outgrows its allotted rect.
///
/// [`Layout::Split`] apportions rects up front from `(parent rect, ratios)`, and content
/// length is not an input to that function — so a sibling area can never *shrink* another
/// one. That protects the **allotment**. It does not protect the **pixels**: in egui a
/// child `Ui` built with `UiBuilder::max_rect(alloc)` inherits its parent's clip rect,
/// not its own, so a leaf whose content exceeds its allotment paints straight over its
/// sibling — the rect assertion passes while the screen is wrong. This closes that.
///
/// Every leaf clips to its allotment, and [`Overflow::Auto`] (the default) resolves to
/// [`Overflow::Scroll`] inside a bounded parent (a `Split` or `Tabs` child, which has an
/// exact rect) and [`Overflow::Grow`] inside a `Flow`/`Grid` (which has none to overflow
/// out of). The default is ON, for the same reason [`Chrome::Auto`] is: capability that
/// ships opt-in is capability nobody sees.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum Overflow {
/// **The default.** `Scroll` under a bounded parent, `Grow` under `Flow`/`Grid`.
#[default]
Auto,
/// Scroll the content inside the allotment.
Scroll,
/// Clip the content to the allotment (no scrollbars — what is past the edge is gone).
Clip,
/// **Opt out**: let the content take whatever height it wants. Honest only under a
/// `Flow`/`Grid` parent; under a `Split` this is how the korp→Graph bug looked.
Grow,
}
impl Overflow {
/// Is this the (skipped-on-serialize) default?
pub fn is_auto(&self) -> bool {
matches!(self, Overflow::Auto)
}
/// The stable tag string.
pub fn tag(self) -> &'static str {
match self {
Overflow::Auto => "auto",
Overflow::Scroll => "scroll",
Overflow::Clip => "clip",
Overflow::Grow => "grow",
}
}
/// Resolve `Auto` against whether the parent allotted an exact rect.
fn resolve(self, bounded: bool) -> Overflow {
match self {
Overflow::Auto if bounded => Overflow::Scroll,
Overflow::Auto => Overflow::Grow,
other => other,
}
}
}
/// **Per-pane window chrome** — the title bar that carries the fold, minimise and
/// maximise/restore affordances.
///
/// ## The default is [`Chrome::Auto`], and `Auto` means ON
/// Every pane in the tree gets a chrome bar EXCEPT the scene root (there is nothing
/// for the root to minimise into and maximising it is the identity — [`Chrome::Always`]
/// forces one anyway). This is deliberate and it is the whole point: a window
/// affordance that a host must opt into is an affordance no operator ever discovers.
/// A pane that genuinely must stay bare says so — [`Pane::no_chrome`].
///
/// `Auto` is skipped on serialize, so adding chrome did NOT change any existing
/// scene's RON by one byte.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum Chrome {
/// **The default.** A full title bar (fold · minimise · maximise) on every pane
/// except the scene root.
#[default]
Auto,
/// A full title bar ALWAYS — including on the scene root.
Always,
/// A title bar carrying only the fold toggle (no minimise / maximise).
FoldOnly,
/// **Opt out**: no title bar at all. A [`Fold::Foldable`] pane falls back to the
/// plain collapsing header it had before chrome existed, so an opted-out pane is
/// pixel-identical to the pre-chrome renderer.
None,
}
impl Chrome {
/// Is this the (skipped-on-serialize) default?
pub fn is_auto(&self) -> bool {
matches!(self, Chrome::Auto)
}
/// The stable tag string (`"auto"` / `"always"` / `"fold_only"` / `"none"`).
pub fn tag(self) -> &'static str {
match self {
Chrome::Auto => "auto",
Chrome::Always => "always",
Chrome::FoldOnly => "fold_only",
Chrome::None => "none",
}
}
}
/// Which chrome controls a pane actually draws this frame, after resolving its
/// [`Chrome`] against its position in the tree. Returned by [`Scene::chrome_of`] so a
/// host or a robot can ask "what will this pane show?" without rendering.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub struct ChromeSpec {
/// Draw the title bar at all.
pub bar: bool,
/// Draw the fold ⏷/⏵ toggle (only for a [`Fold::Foldable`] pane).
pub fold: bool,
/// Draw the minimise control.
pub minimize: bool,
/// Draw the maximise / restore control.
pub maximize: bool,
/// Draw the **editor-type dropdown** — true for a [`Content::Area`] pane whose
/// [`Allow`] is not [`Allow::Fixed`]. This is the one chrome control that is a
/// property of the pane's CONTENT rather than of its [`Chrome`] setting.
pub editor: bool,
}
impl ChromeSpec {
/// This spec as JSON (folded into `state_json`).
fn json(&self) -> Value {
json!({ "bar": self.bar, "fold": self.fold, "minimize": self.minimize, "maximize": self.maximize, "editor": self.editor })
}
}
/// Resolve a pane's [`Chrome`] into the controls it draws. `is_root` is true only for
/// the scene's root pane.
fn chrome_spec(pane: &Pane, is_root: bool) -> ChromeSpec {
let foldable = matches!(pane.fold, Fold::Foldable { .. });
// The editor picker follows the CONTENT, not the Chrome setting: an area that says
// `Allow::Fixed` has nothing to swap to and claims no affordance.
let editor = matches!(&pane.content, Content::Area { allow, .. } if !matches!(allow, Allow::Fixed));
match pane.chrome {
Chrome::None => ChromeSpec::default(),
Chrome::FoldOnly => ChromeSpec { bar: !is_root, fold: foldable, minimize: false, maximize: false, editor },
Chrome::Auto => {
ChromeSpec { bar: !is_root, fold: foldable, minimize: !is_root, maximize: !is_root, editor }
}
Chrome::Always => ChromeSpec { bar: true, fold: foldable, minimize: true, maximize: true, editor },
}
}
/// Per-device layout overrides + a collapse breakpoint (in points of available width).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Responsive {
/// Layout to use on a phone-class width (else the pane's own [`Layout`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub phone: Option<Layout>,
/// Layout to use on a tablet-class width.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tablet: Option<Layout>,
/// Below this width (points) the pane is treated as phone-class.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub collapse_below: Option<f32>,
/// When true, a foldable pane auto-folds (becomes a "drawer") as the width crosses
/// into phone-class, and auto-restores to its `default_open` when it crosses back —
/// the desktop-rail → phone-drawer pattern. Only applied on a device-class
/// *transition*, so the user can still open/close the drawer while on a phone.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub drawer: bool,
}
impl Responsive {
fn is_empty(&self) -> bool {
self.phone.is_none() && self.tablet.is_none() && self.collapse_below.is_none() && !self.drawer
}
}
/// What a menu action does.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum Action {
/// Navigate the [`Router`] to a pane.
NavigateTo(String),
/// Fire a host command id (routed to [`SceneHost::command`]).
Command(String),
/// Toggle a pane's fold.
Toggle(String),
/// Toggle a pane's **minimise** (collapse to its chrome title bar).
Minimize(String),
/// Toggle a pane's **maximise** (take the whole scene area, hiding siblings).
Maximize(String),
/// **Restore** a pane: un-minimise it and drop any maximise it holds.
Restore(String),
/// **Swap an AREA's editor type** — Blender's header dropdown, as a menu action.
SetEditor {
/// The area pane (its NAME — unchanged by the swap).
pane: String,
/// The editor id to mount.
editor: String,
},
/// **Switch WORKSPACE** — Blender's topbar tabs, as a menu action.
Workspace(String),
}
/// A navigation route: a trigger and its target, resolved over the [`Router`].
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Route {
/// What fires this route.
pub trigger: Trigger,
/// What it does.
pub target: Target,
}
/// What fires a [`Route`].
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum Trigger {
/// A menu item was chosen (by unique name).
Menu(String),
/// A keyboard shortcut string.
Shortcut(String),
/// A named button was clicked.
Button(String),
/// A host event id.
Event(String),
}
/// A [`Route`]'s effect on [`NavState`].
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum Target {
/// Navigate to a pane (records back-history).
Show(String),
/// Focus a pane (navigate + set focus).
Focus(String),
/// Push a pane onto the stack.
Push(String),
/// Pop back one step.
Pop,
/// Toggle a pane's fold.
Toggle(String),
/// Toggle a pane's **minimise** (collapse to its chrome title bar).
Minimize(String),
/// Toggle a pane's **maximise** (take the whole scene area, hiding siblings).
Maximize(String),
/// **Restore** a pane: un-minimise it and drop any maximise it holds.
Restore(String),
/// Navigate to a pane carrying an argument.
ShowWith {
/// Target pane.
pane: String,
/// The argument handed along (recorded in state).
arg: String,
},
/// **Swap an AREA's editor type.** The pane keeps its name, its rect and its chrome;
/// only what is mounted inside changes.
SetEditor {
/// The area pane.
pane: String,
/// The editor id to mount.
editor: String,
},
/// **Switch WORKSPACE** — swap the body tree spliced into the shell. The shell does
/// not move.
Workspace(String),
}
// ─────────────────────────────────────────────────────────────────────────────
// RON <-> Scene (byte-stable) + validation.
// ─────────────────────────────────────────────────────────────────────────────
impl Scene {
/// The canonical RON pretty config — a FIXED format so [`to_ron`](Self::to_ron)
/// is byte-stable (parse ∘ serialize is a fixed point).
fn ron_config() -> ron::ser::PrettyConfig {
ron::ser::PrettyConfig::new().indentor(" ".to_string()).struct_names(true)
}
/// Parse a [`Scene`] from RON text.
pub fn from_ron(text: &str) -> Result<Scene, ron::error::SpannedError> {
ron::from_str(text)
}
/// Serialize this scene to canonical (byte-stable) RON.
pub fn to_ron(&self) -> String {
ron::ser::to_string_pretty(self, Self::ron_config()).expect("Scene serializes to RON")
}
/// Build a scene from a root pane (Rust-builder entry point). With workspaces, the
/// root is the **shell skeleton** and carries a [`Content::Body`] splice pane.
pub fn new(name: impl Into<String>, root: Pane) -> Self {
Self {
name: name.into(),
title: String::new(),
menubar: Vec::new(),
root,
routes: Vec::new(),
workspaces: Vec::new(),
active_workspace: 0,
}
}
/// Add a [`Workspace`] (builder). Its body tree is spliced into the shell's
/// [`Content::Body`] pane when it is the active one.
pub fn workspace(mut self, ws: Workspace) -> Self {
self.workspaces.push(ws);
self
}
/// Start on the workspace called `name` (builder). Unknown names are ignored.
pub fn default_workspace(mut self, name: &str) -> Self {
if let Some(i) = self.workspaces.iter().position(|w| w.name == name) {
self.active_workspace = i;
}
self
}
// ── Workspace lookup ─────────────────────────────────────────────────────────
/// A workspace by name.
pub fn workspace_named(&self, name: &str) -> Option<&Workspace> {
self.workspaces.iter().find(|w| w.name == name)
}
/// The AUTHORED-default workspace (`None` when the scene declares none).
pub fn default_ws(&self) -> Option<&Workspace> {
self.workspaces.get(self.active_workspace.min(self.workspaces.len().saturating_sub(1)))
}
/// Resolve a workspace name to a workspace, falling back to the authored default —
/// the one lookup the renderer and every query use, so a stale name can never make a
/// pane un-addressable.
pub fn ws_or_default(&self, name: Option<&str>) -> Option<&Workspace> {
name.and_then(|n| self.workspace_named(n)).or_else(|| self.default_ws())
}
/// Every workspace name, in tab order.
pub fn workspace_names(&self) -> Vec<&str> {
self.workspaces.iter().map(|w| w.name.as_str()).collect()
}
/// The pane in the shell that hosts the [`Content::Body`] splice, if any.
pub fn body_pane(&self) -> Option<String> {
shell_names(&self.root).into_iter().find(|n| matches!(self.shell_pane(n).map(|p| &p.content), Some(Content::Body)))
}
/// The pane in the shell that hosts the workspace tab strip, if any.
pub fn workspace_bar_pane(&self) -> Option<String> {
shell_names(&self.root)
.into_iter()
.find(|n| matches!(self.shell_pane(n).map(|p| &p.content), Some(Content::WorkspaceBar)))
}
/// A pane in the SHELL only (never a workspace body).
pub fn shell_pane(&self, name: &str) -> Option<&Pane> {
fn walk<'a>(p: &'a Pane, name: &str) -> Option<&'a Pane> {
if p.name == name {
return Some(p);
}
if let Content::Children(kids) = &p.content {
for k in kids {
if let Some(hit) = walk(k, name) {
return Some(hit);
}
}
}
None
}
walk(&self.root, name)
}
/// A pane in **shell ∪ workspace `ws`** — the scoped lookup every query is built on.
pub fn pane_in(&self, ws: Option<&str>, name: &str) -> Option<&Pane> {
self.shell_pane(name).or_else(|| self.ws_or_default(ws).and_then(|w| w.pane(name)))
}
/// Every pane name in **shell ∪ workspace `ws`**, shell first.
pub fn pane_names_in(&self, ws: Option<&str>) -> Vec<String> {
let mut out = shell_names(&self.root);
if let Some(w) = self.ws_or_default(ws) {
out.extend(w.pane_names());
}
out
}
/// Set the display title (what a workspace tab shows).
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = title.into();
self
}
/// The display title, falling back to the scene name.
pub fn header_title(&self) -> &str {
if self.title.is_empty() { &self.name } else { &self.title }
}
/// Add a top-level menu (builder).
pub fn menu(mut self, item: MenuItem) -> Self {
self.menubar.push(item);
self
}
/// Add a route (builder).
pub fn route(mut self, route: Route) -> Self {
self.routes.push(route);
self
}
/// Depth-first pane lookup by unique name, over **shell ∪ the authored-default
/// workspace**. Use [`pane_in`](Self::pane_in) to scope it to a specific workspace.
pub fn pane(&self, name: &str) -> Option<&Pane> {
self.pane_in(None, name)
}
/// Every pane name in preorder, over **shell ∪ the authored-default workspace**.
pub fn pane_names(&self) -> Vec<String> {
self.pane_names_in(None)
}
/// Validate the addressability invariant.
///
/// **Name uniqueness is scoped to (shell ∪ ONE workspace)** — across workspaces the
/// same body pane name may, and should, repeat (`nav`, `main`, `side`, `out` exist in
/// every workspace). Each workspace is therefore checked against the shell
/// independently. Workspace names themselves are globally unique, and so are menu
/// names. Returns the first violation found.
pub fn validate(&self) -> Result<(), String> {
let shell = shell_names(&self.root);
let mut seen = std::collections::BTreeSet::new();
for n in &shell {
if !seen.insert(n.clone()) {
return Err(format!("duplicate pane name: {n}"));
}
}
let mut ws_seen = std::collections::BTreeSet::new();
for w in &self.workspaces {
if !ws_seen.insert(w.name.clone()) {
return Err(format!("duplicate workspace name: {}", w.name));
}
// Shell ∪ THIS workspace — never workspace ∪ workspace.
let mut scope: std::collections::BTreeSet<String> = seen.clone();
for n in w.pane_names() {
if !scope.insert(n.clone()) {
return Err(format!("workspace {}: duplicate pane name: {n}", w.name));
}
}
}
if !self.workspaces.is_empty() && self.body_pane().is_none() {
return Err("a scene with workspaces needs a Content::Body pane to splice them into".into());
}
let mut mseen = std::collections::BTreeSet::new();
fn walk_menu(items: &[MenuItem], seen: &mut std::collections::BTreeSet<String>) -> Result<(), String> {
for it in items {
if !seen.insert(it.name.clone()) {
return Err(format!("duplicate menu name: {}", it.name));
}
walk_menu(&it.items, seen)?;
}
Ok(())
}
walk_menu(&self.menubar, &mut mseen)
}
/// The ancestor chain from the shell root down to `name`, inclusive. For a **body**
/// pane the chain runs shell-root → … → the [`Content::Body`] splice pane → the
/// workspace's own body root → … → `name`, so a maximise inside a workspace keeps
/// every shell ancestor on the path and the shell chrome stays put.
pub fn path_to(&self, name: &str) -> Option<Vec<String>> {
self.path_to_in(None, name)
}
/// [`path_to`](Self::path_to), scoped to a named workspace.
pub fn path_to_in(&self, ws: Option<&str>, name: &str) -> Option<Vec<String>> {
fn walk(p: &Pane, name: &str, acc: &mut Vec<String>) -> bool {
acc.push(p.name.clone());
if p.name == name {
return true;
}
if let Content::Children(kids) = &p.content {
for k in kids {
if walk(k, name, acc) {
return true;
}
}
}
acc.pop();
false
}
let mut acc = Vec::new();
if walk(&self.root, name, &mut acc) {
return Some(acc);
}
// A body pane: shell path down to the splice, then the workspace's own path.
let w = self.ws_or_default(ws)?;
let inner = w.path_to(name)?;
let mut acc = Vec::new();
walk(&self.root, &self.body_pane()?, &mut acc).then(|| {
acc.extend(inner);
acc
})
}
/// The chrome controls `name` will draw, resolved against its position in the tree
/// (the shell root is bare under [`Chrome::Auto`]). All-false for an unknown pane.
pub fn chrome_of(&self, name: &str) -> ChromeSpec {
self.chrome_of_in(None, name)
}
/// [`chrome_of`](Self::chrome_of), scoped to a named workspace.
pub fn chrome_of_in(&self, ws: Option<&str>, name: &str) -> ChromeSpec {
match self.pane_in(ws, name) {
Some(p) => chrome_spec(p, name == self.root.name),
None => ChromeSpec::default(),
}
}
/// Every pane that draws a chrome title bar (in preorder).
pub fn chrome_panes(&self) -> Vec<String> {
self.pane_names().into_iter().filter(|n| self.chrome_of(n).bar).collect()
}
/// Every pane that renders a **draggable** divider (a resizable [`Layout::Split`]
/// with ≥2 children), in preorder.
pub fn draggable_splits(&self) -> Vec<String> {
self.pane_names()
.into_iter()
.filter(|n| match self.pane(n) {
Some(p) => {
let kids = match &p.content {
Content::Children(k) => k.len(),
_ => 0,
};
matches!(p.layout, Layout::Split { resizable: true, .. }) && kids >= 2
}
None => false,
})
.collect()
}
/// Every **AREA** pane (a [`Content::Area`] — a rect whose editor is swappable), in
/// preorder, as `(pane name, default editor id)`.
pub fn areas(&self) -> Vec<(String, String)> {
self.areas_in(None)
}
/// [`areas`](Self::areas), scoped to a named workspace (whose per-area `editors`
/// overrides are applied).
pub fn areas_in(&self, ws: Option<&str>) -> Vec<(String, String)> {
let w = self.ws_or_default(ws);
self.pane_names_in(ws)
.into_iter()
.filter_map(|n| match self.pane_in(ws, &n).map(|p| &p.content) {
Some(Content::Area { editor, .. }) => {
let e = w.and_then(|w| w.editors.get(&n)).cloned().unwrap_or_else(|| editor.clone());
Some((n, e))
}
_ => None,
})
.collect()
}
/// The pane that hosts the MENU BAR ([`Content::Menubar`]), if the tree carries one.
/// When it does, [`render`] does not also draw the implicit top strip.
pub fn menubar_pane(&self) -> Option<String> {
self.pane_names().into_iter().find(|n| matches!(self.pane(n).map(|p| &p.content), Some(Content::Menubar)))
}
/// Every pane asking for a FIXED extent, in preorder, as `(name, points)` — the
/// shell's own chrome rows (menu bar / toolbar / status bar) and anything else
/// pinned. A shell that claims a status bar and reports none here has not got one.
pub fn fixed_panes(&self) -> Vec<(String, f32)> {
self.pane_names().into_iter().filter_map(|n| self.pane(&n).and_then(|p| p.fixed).map(|f| (n, f))).collect()
}
/// The DEFAULT editor id authored for area pane `name` (`None` for a non-area pane).
pub fn area_editor(&self, name: &str) -> Option<&str> {
match self.pane(name).map(|p| &p.content) {
Some(Content::Area { editor, .. }) => Some(editor.as_str()),
_ => None,
}
}
/// Every [`Fold::Foldable`] pane, in preorder.
pub fn foldable_panes(&self) -> Vec<String> {
self.pane_names()
.into_iter()
.filter(|n| matches!(self.pane(n).map(|p| &p.fold), Some(Fold::Foldable { .. })))
.collect()
}
/// **The adoption audit** — one JSON blob naming every interactive affordance this
/// scene actually ships. A host's robot test asserts against THIS instead of
/// re-deriving it: a shell with an empty `chrome` list, or one that wants
/// master/detail but reports zero `draggable_splits`, is a shell the operator
/// cannot drive.
pub fn affordances(&self) -> Value {
// area -> its default editor, and the inverse: editor -> the areas that can host
// it. A robot reads the second to answer "where can I put a Graph?".
let areas: BTreeMap<String, String> = self.areas().into_iter().collect();
let mut by_editor: BTreeMap<String, Vec<String>> = BTreeMap::new();
for (pane, editor) in &areas {
by_editor.entry(editor.clone()).or_default().push(pane.clone());
}
json!({
"scene": self.name,
"panes": self.pane_names(),
"chrome": self.chrome_panes(),
"draggable_splits": self.draggable_splits(),
"foldable": self.foldable_panes(),
"areas": areas,
"editors": by_editor,
"fixed_panes": self.fixed_panes().into_iter().map(|(p, _)| p).collect::<Vec<_>>(),
"menubar_pane": self.menubar_pane(),
// The shell is authored ONCE; only what hangs under `body_pane` varies.
"workspaces": self.workspace_names(),
"active_workspace": self.default_ws().map(|w| w.name.clone()),
"shell_panes": shell_names(&self.root),
"body_pane": self.body_pane(),
"workspace_bar_pane": self.workspace_bar_pane(),
})
}
/// Find a [`MenuItem`] by unique name (depth-first).
pub fn menu_item(&self, name: &str) -> Option<&MenuItem> {
fn walk<'a>(items: &'a [MenuItem], name: &str) -> Option<&'a MenuItem> {
for it in items {
if it.name == name {
return Some(it);
}
if let Some(hit) = walk(&it.items, name) {
return Some(hit);
}
}
None
}
walk(&self.menubar, name)
}
}
// ── Pane / MenuItem / Route builders (the pure-Rust authoring form) ────────────
impl Pane {
fn base(name: impl Into<String>, layout: Layout, content: Content) -> Self {
let name = name.into();
Self {
title: name.clone(),
tooltip: None,
name,
layout,
fold: Fold::Fixed,
chrome: Chrome::Auto,
responsive: Responsive::default(),
overflow: Overflow::Auto,
fixed: None,
content,
}
}
/// A host-widget slot pane (leaf). The widget is FIXED for the life of the scene.
pub fn slot(name: impl Into<String>, slot_id: impl Into<String>) -> Self {
Self::base(name, Layout::Flow, Content::Slot(slot_id.into()))
}
/// **An AREA** — a leaf pane that can host any registered editor type, swappable at
/// runtime from the dropdown in its own chrome header (Blender's area/editor model).
///
/// `editor_id` is only the DEFAULT: the live binding lives in
/// [`NavState::area_editor`] and is published in `state_json`, so
/// - the pane **name** is the stable handle (`click_label`, deep link, persisted
/// layout key) and never changes when the editor does, and
/// - a robot swaps the editor with [`NavState::set_editor`] (or the
/// [`Target::SetEditor`] route) and reads the *same* rect back.
///
/// ```no_run
/// # use facett_app::{Pane, Axis};
/// // Two areas side by side, each hosting a different editor — the arrangement a
/// // tab model cannot express at all.
/// let body = Pane::split("body", Axis::Horizontal, 0.6, vec![
/// Pane::area("area.left", "graph").title("Graph"),
/// Pane::area("area.right", "tables").title("Tables"),
/// ]);
/// ```
pub fn area(name: impl Into<String>, editor_id: impl Into<String>) -> Self {
Self::base(name, Layout::Flow, Content::Area { editor: editor_id.into(), allow: Allow::Any })
}
/// Restrict which editor types this area's header offers (no-op for a non-area pane).
/// The listed ids are intersected with the host's registry, in the order given.
pub fn only_editors(mut self, ids: impl IntoIterator<Item = impl Into<String>>) -> Self {
if let Content::Area { allow, .. } = &mut self.content {
*allow = Allow::Only(ids.into_iter().map(Into::into).collect());
}
self
}
/// **Pin this area's editor**: no dropdown, no swap (no-op for a non-area pane).
pub fn fixed_editor(mut self) -> Self {
if let Content::Area { allow, .. } = &mut self.content {
*allow = Allow::Fixed;
}
self
}
/// A flow container over children.
pub fn flow(name: impl Into<String>, children: Vec<Pane>) -> Self {
Self::base(name, Layout::Flow, Content::Children(children))
}
/// A two-way split with a **draggable** divider; `ratio` is the first child's
/// initial fraction (the second gets the rest). Use [`Pane::split_fixed`] for a
/// static (non-draggable) apportionment, or [`Pane::split_n`] for N children.
pub fn split(name: impl Into<String>, axis: Axis, ratio: f32, children: Vec<Pane>) -> Self {
let ratios = vec![ratio, (1.0 - ratio).max(0.0)];
Self::base(name, Layout::Split { axis, ratios, resizable: true }, Content::Children(children))
}
/// A two-way split with a STATIC divider (no drag handle).
pub fn split_fixed(name: impl Into<String>, axis: Axis, ratio: f32, children: Vec<Pane>) -> Self {
let ratios = vec![ratio, (1.0 - ratio).max(0.0)];
Self::base(name, Layout::Split { axis, ratios, resizable: false }, Content::Children(children))
}
/// An N-way **draggable** split; `ratios` is one fraction per child (normalised).
pub fn split_n(name: impl Into<String>, axis: Axis, ratios: Vec<f32>, children: Vec<Pane>) -> Self {
Self::base(name, Layout::Split { axis, ratios, resizable: true }, Content::Children(children))
}
/// Toggle whether this pane's [`Layout::Split`] has a draggable handle (no-op for a
/// non-Split layout).
pub fn resizable(mut self, on: bool) -> Self {
if let Layout::Split { resizable, .. } = &mut self.layout {
*resizable = on;
}
self
}
/// A grid over children.
pub fn grid(name: impl Into<String>, cols: usize, children: Vec<Pane>) -> Self {
Self::base(name, Layout::Grid { cols }, Content::Children(children))
}
/// A tab strip over children.
pub fn tabs(name: impl Into<String>, active: usize, children: Vec<Pane>) -> Self {
Self::base(name, Layout::Tabs { active }, Content::Children(children))
}
/// Set the header title.
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = title.into();
self
}
/// Set a hover tooltip for this pane's TAB header — [`TabView`] shows it as
/// `on_hover_text` when this pane is a tab (a "what does this view do" hint).
pub fn tooltip(mut self, tooltip: impl Into<String>) -> Self {
self.tooltip = Some(tooltip.into());
self
}
/// Set this pane's window [`Chrome`] explicitly.
pub fn chrome(mut self, chrome: Chrome) -> Self {
self.chrome = chrome;
self
}
/// **Opt out** of the window chrome: no title bar, no minimise/maximise. A
/// [`Fold::Foldable`] pane falls back to its plain collapsing header.
pub fn no_chrome(mut self) -> Self {
self.chrome = Chrome::None;
self
}
/// Force the full chrome bar even on the scene ROOT (which [`Chrome::Auto`] leaves
/// bare).
pub fn chrome_always(mut self) -> Self {
self.chrome = Chrome::Always;
self
}
/// Make the pane foldable (THE interaction).
pub fn foldable(mut self, default_open: bool) -> Self {
self.fold = Fold::Foldable { default_open, icon: None };
self
}
/// Make the pane foldable with a header icon.
pub fn foldable_icon(mut self, default_open: bool, icon: impl Into<String>) -> Self {
self.fold = Fold::Foldable { default_open, icon: Some(icon.into()) };
self
}
/// Set the phone-class layout override + the collapse breakpoint.
pub fn phone(mut self, layout: Layout, collapse_below: f32) -> Self {
self.responsive.phone = Some(layout);
self.responsive.collapse_below = Some(collapse_below);
self
}
/// Set the tablet-class layout override.
pub fn tablet(mut self, layout: Layout) -> Self {
self.responsive.tablet = Some(layout);
self
}
/// Make this (foldable) pane a **drawer**: auto-fold when the width crosses into
/// phone-class (`collapse_below` points) and auto-restore above it — the
/// desktop-rail → phone-drawer pattern.
pub fn drawer(mut self, collapse_below: f32) -> Self {
self.responsive.drawer = true;
self.responsive.collapse_below = Some(collapse_below);
self
}
/// **Ask for an exact extent** (points) along the parent split's axis instead of a
/// share of its ratios. See [`Pane::fixed`](struct.Pane.html#structfield.fixed).
pub fn fixed(mut self, points: f32) -> Self {
self.fixed = Some(points.max(0.0));
self
}
/// Drop a fixed extent — the pane goes back to taking its ratio share.
pub fn flexible(mut self) -> Self {
self.fixed = None;
self
}
/// Set this pane's [`Overflow`] (the clip lock) explicitly.
pub fn overflow(mut self, overflow: Overflow) -> Self {
self.overflow = overflow;
self
}
// ── The SHELL's own chrome, as panes ─────────────────────────────────────────
// The menu bar, the toolbar and the status bar are not widgets bolted on outside
// the tree: they are fixed-height ROWS of the root split. That means each has a
// name, a rect and an `alloc` in `state_json`, each is addressable by a robot, and
// moving or removing one is an edit to the pane tree rather than a code path.
/// **The menu bar, as a pane** — a fixed-height row that draws [`Scene::menubar`] in
/// place. Bare by default (a menu bar with its own minimise button would be absurd);
/// say `.chrome(Chrome::Auto)` if you really want one.
pub fn menubar(name: impl Into<String>) -> Self {
Self::base(name, Layout::Flow, Content::Menubar).fixed(MENUBAR_H).no_chrome()
}
/// **The workspace TAB STRIP, as a pane** — Blender's topbar tabs, a fixed-height
/// row of the shell. Bare like the menu bar.
pub fn workspace_bar(name: impl Into<String>) -> Self {
Self::base(name, Layout::Flow, Content::WorkspaceBar).fixed(WORKSPACEBAR_H).no_chrome()
}
/// **THE BODY SPLICE** — the pane the active [`Workspace`]'s tree is grafted into.
/// Flexible by default (it takes whatever the fixed chrome rows leave) and bare: the
/// window controls belong to the areas inside it, not to the hole they sit in.
pub fn body(name: impl Into<String>) -> Self {
Self::base(name, Layout::Flow, Content::Body).no_chrome()
}
/// **A toolbar / status bar, as a pane** — a fixed-height slot row, bare like the
/// menu bar. `Pane::bar("statusbar", "status", 22.0)`.
pub fn bar(name: impl Into<String>, slot_id: impl Into<String>, height: f32) -> Self {
Self::slot(name, slot_id).fixed(height).no_chrome()
}
/// **The whole shell as one vertical stack**: fixed-height chrome rows around a
/// flexible body. Any child carrying [`fixed`](Pane::fixed) takes exactly that many
/// points; the rest share what is left.
///
/// ```no_run
/// # use facett_app::{Axis, Pane, Scene, Workspace, STATUSBAR_H, TOOLBAR_H};
/// // The SHELL — authored once, identical in every workspace.
/// let scene = Scene::new("korp", Pane::shell("shell.root", vec![
/// Pane::menubar("shell.menubar"), // fixed MENUBAR_H
/// Pane::workspace_bar("shell.workspaces"), // fixed WORKSPACEBAR_H
/// Pane::bar("shell.toolbar", "tools", TOOLBAR_H), // fixed
/// Pane::body("shell.body"), // ← the SPLICE, flexible
/// Pane::bar("shell.statusbar", "status", STATUSBAR_H),// fixed
/// ]))
/// // …and the BODIES that take turns in it.
/// .workspace(Workspace::new("investigate", "🔎 Investigate",
/// Pane::split("ws.root", Axis::Horizontal, 0.25, vec![
/// Pane::area("nav", "browser"),
/// Pane::area("main", "graph"),
/// ])))
/// .workspace(Workspace::new("data", "🧮 Data", Pane::area("main", "tables")));
/// ```
pub fn shell(name: impl Into<String>, rows: Vec<Pane>) -> Self {
let n = rows.len().max(1);
Self::base(
name,
Layout::Split { axis: Axis::Vertical, ratios: vec![1.0 / n as f32; n], resizable: true },
Content::Children(rows),
)
}
fn header_title(&self) -> &str {
if self.title.is_empty() { &self.name } else { &self.title }
}
}
impl MenuItem {
/// A leaf/branch menu item.
pub fn new(name: impl Into<String>, label: impl Into<String>) -> Self {
Self { name: name.into(), label: label.into(), shortcut: None, action: None, items: Vec::new() }
}
/// Attach a shortcut hint.
pub fn shortcut(mut self, s: impl Into<String>) -> Self {
self.shortcut = Some(s.into());
self
}
/// Attach an action.
pub fn action(mut self, a: Action) -> Self {
self.action = Some(a);
self
}
/// Attach child items (a submenu).
pub fn items(mut self, items: Vec<MenuItem>) -> Self {
self.items = items;
self
}
}
impl Route {
/// A route on a menu-item name.
pub fn on_menu(name: impl Into<String>, target: Target) -> Self {
Self { trigger: Trigger::Menu(name.into()), target }
}
/// A route on a button name.
pub fn on_button(name: impl Into<String>, target: Target) -> Self {
Self { trigger: Trigger::Button(name.into()), target }
}
/// A route on a shortcut.
pub fn on_shortcut(keys: impl Into<String>, target: Target) -> Self {
Self { trigger: Trigger::Shortcut(keys.into()), target }
}
/// A route on a host event.
pub fn on_event(id: impl Into<String>, target: Target) -> Self {
Self { trigger: Trigger::Event(id.into()), target }
}
}
// ─────────────────────────────────────────────────────────────────────────────
// NAVSTATE — wraps the Router with the per-scene runtime state the renderer reads.
// ─────────────────────────────────────────────────────────────────────────────
/// The runtime navigation state for a [`Scene`]: the shared [`Router`] (history)
/// plus per-pane active-tab, fold, and focus. The renderer READS it (which tab is
/// active / which pane is folded); triggers MUTATE it.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct NavState {
/// The nav engine — route stack with back/forward + deep-link hash.
pub router: Router,
/// Per-pane (Tabs layout) active child index.
pub active_tab: BTreeMap<String, usize>,
/// Per-pane folded flag (`true` = folded, contents omitted).
pub folded: BTreeMap<String, bool>,
/// Per-pane MINIMISED flag (`true` = collapsed to its chrome title bar, and its
/// space handed back to its siblings). Seeded `false` for every pane, so
/// `state_json` always carries the full roster a robot can address.
pub minimized: BTreeMap<String, bool>,
/// The ONE maximised pane, if any (window-manager semantics: at most one). While
/// set, every pane that is not an ancestor/descendant of it is allotted ZERO area.
/// The split ratios are NOT touched, so restoring returns the exact prior layout.
pub maximized: Option<String>,
/// The last chrome control a click fired, as `"<action>:<pane>"` (e.g.
/// `"maximize:body"`) — the robot's read-back that its click LANDED.
pub last_chrome: Option<String>,
/// Per-pane (Split layout) LIVE child ratios — seeded from the layout, mutated by
/// dragging a divider (so a drag round-trips through the observable state).
pub split_ratio: BTreeMap<String, Vec<f32>>,
/// **Per-pane runtime PRESENCE: the panes the HOST has declared ABSENT this frame.**
///
/// An absent pane does not EXIST on screen: it is allotted **zero** extent, draws
/// **no chrome and no content** (unlike a minimise, which keeps the title bar), its
/// adjacent split dividers stand down, and a `Layout::Tabs` strip grows no header
/// for it. Its siblings absorb the space **by their unchanged ratios** — exactly the
/// maximise discipline — so presence returning restores the exact prior layout,
/// folds and dragged dividers included.
///
/// This is *presence*, not *visibility sugar*: it is for a pane whose subject does
/// not exist right now (a configurator for a recipe that declares none, an
/// inspector with nothing to inspect), where fold/minimise would still show chrome
/// promising content that cannot come. The host re-asserts it every frame from its
/// own domain facts ([`set_absent`](Self::set_absent)); it is never authored in the
/// scene (RON scenes carry no presence — a scene declares what CAN exist, the host
/// says what DOES). Serde: absent-empty serializes to nothing, so persisted layouts
/// round-trip unchanged; a stale persisted entry is overwritten on the first frame.
#[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
pub absent: std::collections::BTreeSet<String>,
/// **Per-AREA editor binding** — pane name → the editor id it currently hosts.
/// Seeded from each [`Content::Area`]'s authored default and mutated by the header
/// dropdown / [`set_editor`](Self::set_editor). This is the line that makes the
/// editor *state* rather than *identity*: the key never changes, the value does.
pub area_editor: BTreeMap<String, String>,
/// The area whose editor-type dropdown is OPEN, if any (at most one — it is a menu).
pub editor_menu: Option<String>,
/// **The LIVE workspace** — whose body tree is spliced into the shell's
/// [`Content::Body`] pane this frame. Empty for a scene with no workspaces.
#[serde(default)]
pub active_workspace: String,
/// **The PARKED body state of every workspace that is not showing.**
///
/// The flat maps above hold shell ∪ the ACTIVE workspace's body — exactly the panes
/// on screen, so two workspaces both naming a pane `main` never collide there.
/// Everything else lives here under its workspace name, which is what lets a
/// persisted layout tell `main` in *investigate* from `main` in *data* without
/// qualifying a single robot address.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub workspaces: BTreeMap<String, WorkspaceSlot>,
/// The last workspace switch, as `"workspace:<name>"` — the robot's read-back.
#[serde(default)]
pub last_workspace: Option<String>,
/// The focused pane, if any.
pub focus: Option<String>,
/// The last `ShowWith` argument delivered (host reads it).
pub arg: Option<String>,
/// The device class the previous render resolved (drives drawer transitions).
pub last_device: Option<Device>,
}
impl NavState {
/// Walk a tree and seed the four per-pane maps plus the area bindings from what it
/// authors. Shared by [`NavState::new`] and
/// [`WorkspaceSlot::seeded`](crate::workspace::WorkspaceSlot::seeded) so a workspace
/// body is seeded by exactly the same rules as the shell.
#[allow(clippy::type_complexity)]
pub fn seed_maps(
root: &Pane,
) -> (
BTreeMap<String, bool>,
BTreeMap<String, bool>,
BTreeMap<String, usize>,
BTreeMap<String, Vec<f32>>,
BTreeMap<String, String>,
) {
let mut folded = BTreeMap::new();
let mut minimized = BTreeMap::new();
let mut active_tab = BTreeMap::new();
let mut split_ratio = BTreeMap::new();
let mut area_editor = BTreeMap::new();
fn walk(
p: &Pane,
folded: &mut BTreeMap<String, bool>,
minimized: &mut BTreeMap<String, bool>,
active_tab: &mut BTreeMap<String, usize>,
split_ratio: &mut BTreeMap<String, Vec<f32>>,
area_editor: &mut BTreeMap<String, String>,
) {
if let Fold::Foldable { default_open, .. } = &p.fold {
folded.insert(p.name.clone(), !*default_open);
}
minimized.insert(p.name.clone(), false);
if let Content::Area { editor, .. } = &p.content {
area_editor.insert(p.name.clone(), editor.clone());
}
match &p.layout {
Layout::Tabs { active } => {
active_tab.insert(p.name.clone(), *active);
}
Layout::Split { ratios, .. } => {
let n = match &p.content {
Content::Children(kids) => kids.len(),
_ => ratios.len(),
};
split_ratio.insert(p.name.clone(), normalized_ratios(ratios, n));
}
_ => {}
}
if let Content::Children(kids) = &p.content {
for k in kids {
walk(k, folded, minimized, active_tab, split_ratio, area_editor);
}
}
}
walk(root, &mut folded, &mut minimized, &mut active_tab, &mut split_ratio, &mut area_editor);
(folded, minimized, active_tab, split_ratio, area_editor)
}
/// Seed the runtime state from a scene: the SHELL's maps, the authored-default
/// workspace's body merged into them (it is the one on screen), and every other
/// workspace parked in [`workspaces`](Self::workspaces).
pub fn new(scene: &Scene) -> Self {
let (mut folded, mut minimized, mut active_tab, mut split_ratio, mut area_editor) =
Self::seed_maps(&scene.root);
let active = scene.default_ws().map(|w| w.name.clone()).unwrap_or_default();
let mut workspaces = BTreeMap::new();
for w in &scene.workspaces {
let slot = WorkspaceSlot::seeded(w);
if w.name == active {
// The active workspace's body IS on screen, so it lives in the flat maps.
folded.extend(slot.folded);
minimized.extend(slot.minimized);
active_tab.extend(slot.active_tab);
split_ratio.extend(slot.split_ratio);
area_editor.extend(slot.area_editor);
} else {
workspaces.insert(w.name.clone(), slot);
}
}
Self {
router: Router::new(scene.root.name.clone()),
active_tab,
folded,
minimized,
maximized: None,
last_chrome: None,
split_ratio,
absent: Default::default(),
area_editor,
editor_menu: None,
active_workspace: active,
workspaces,
last_workspace: None,
focus: None,
arg: None,
last_device: None,
}
}
// ── WORKSPACE switching: park the outgoing body, un-park the incoming one ─────
/// The live workspace name (empty for a scene with no workspaces).
pub fn workspace(&self) -> &str {
&self.active_workspace
}
/// **Switch workspace.** The outgoing body's state is parked under its own name and
/// the incoming one is un-parked into the flat maps — so the shell's entries never
/// move, every shell pane address stays valid, and returning to a workspace returns
/// the EXACT arrangement you left it in. Returns true if it changed.
pub fn set_workspace(&mut self, scene: &Scene, name: &str) -> bool {
if self.active_workspace == name || scene.workspace_named(name).is_none() {
return false;
}
// Park the outgoing body (only the panes that belong to it — the shell stays put).
if let Some(out) = scene.workspace_named(&self.active_workspace) {
let mut slot = WorkspaceSlot::default();
for n in out.pane_names() {
if let Some(v) = self.folded.remove(&n) {
slot.folded.insert(n.clone(), v);
}
if let Some(v) = self.minimized.remove(&n) {
slot.minimized.insert(n.clone(), v);
}
if let Some(v) = self.active_tab.remove(&n) {
slot.active_tab.insert(n.clone(), v);
}
if let Some(v) = self.split_ratio.remove(&n) {
slot.split_ratio.insert(n.clone(), v);
}
if let Some(v) = self.area_editor.remove(&n) {
slot.area_editor.insert(n.clone(), v);
}
// A maximise on a BODY pane travels with the body; one on a shell pane
// stays where it is.
if self.maximized.as_deref() == Some(n.as_str()) {
slot.maximized = self.maximized.take();
}
}
self.workspaces.insert(out.name.clone(), slot);
}
// Un-park the incoming one, seeding anything the parked slot lacks.
let incoming = scene.workspace_named(name).expect("checked above");
let mut slot = self.workspaces.remove(name).unwrap_or_default();
let seed = WorkspaceSlot::seeded(incoming);
for (k, v) in seed.folded {
slot.folded.entry(k).or_insert(v);
}
for (k, v) in seed.minimized {
slot.minimized.entry(k).or_insert(v);
}
for (k, v) in seed.active_tab {
slot.active_tab.entry(k).or_insert(v);
}
for (k, v) in seed.split_ratio {
slot.split_ratio.entry(k).or_insert(v);
}
for (k, v) in seed.area_editor {
slot.area_editor.entry(k).or_insert(v);
}
self.folded.extend(slot.folded);
self.minimized.extend(slot.minimized);
self.active_tab.extend(slot.active_tab);
self.split_ratio.extend(slot.split_ratio);
self.area_editor.extend(slot.area_editor);
if let Some(m) = slot.maximized {
self.maximized = Some(m);
}
self.active_workspace = name.to_string();
self.last_workspace = Some(format!("workspace:{name}"));
true
}
/// **Reconcile a RESTORED state with the scene it is being applied to.**
///
/// A persisted layout is older than the build loading it: panes get renamed, added
/// and removed between releases. This drops every entry naming a pane the scene no
/// longer has, seeds the ones it gained, and clears a maximise pointing at a pane
/// that is gone — so a stale layout **degrades to the authored default for the parts
/// that moved and keeps the user's arrangement for the parts that did not**, rather
/// than being thrown away wholesale (which is what "your layout resets on upgrade"
/// actually is).
///
/// Call it right after loading. [`render`] does NOT call it per frame; the renderer
/// already tolerates stale keys, and walking every pane twice a frame to re-derive a
/// conclusion that only changes at load time is work nobody asked for.
pub fn adopt(&mut self, scene: &Scene) {
// A saved active workspace that no longer exists falls back to the authored one.
if scene.workspace_named(&self.active_workspace).is_none() {
self.active_workspace = scene.default_ws().map(|w| w.name.clone()).unwrap_or_default();
}
// Drop parked slots for workspaces this build removed, and reconcile the rest
// against their OWN tree (that is where per-workspace keys are disambiguated).
let live: std::collections::BTreeSet<&str> = scene.workspace_names().into_iter().collect();
self.workspaces.retain(|k, _| live.contains(k.as_str()) && k != &self.active_workspace);
for w in &scene.workspaces {
if w.name == self.active_workspace {
continue;
}
let slot = self.workspaces.entry(w.name.clone()).or_default();
let seed = WorkspaceSlot::seeded(w);
slot.folded.retain(|k, _| seed.folded.contains_key(k));
slot.minimized.retain(|k, _| seed.minimized.contains_key(k));
slot.active_tab.retain(|k, _| seed.active_tab.contains_key(k));
slot.split_ratio.retain(|k, v| seed.split_ratio.get(k).is_some_and(|f| f.len() == v.len()));
slot.area_editor.retain(|k, _| seed.area_editor.contains_key(k));
if slot.maximized.as_deref().is_some_and(|m| w.pane(m).is_none()) {
slot.maximized = None;
}
for (k, v) in seed.folded {
slot.folded.entry(k).or_insert(v);
}
for (k, v) in seed.minimized {
slot.minimized.entry(k).or_insert(v);
}
for (k, v) in seed.active_tab {
slot.active_tab.entry(k).or_insert(v);
}
for (k, v) in seed.split_ratio {
slot.split_ratio.entry(k).or_insert(v);
}
for (k, v) in seed.area_editor {
slot.area_editor.entry(k).or_insert(v);
}
}
// The flat maps hold shell ∪ the ACTIVE workspace's body — reconcile against that.
let fresh = NavState::new(scene);
self.folded.retain(|k, _| fresh.folded.contains_key(k));
for (k, v) in &fresh.folded {
self.folded.entry(k.clone()).or_insert(*v);
}
self.minimized.retain(|k, _| fresh.minimized.contains_key(k));
for k in fresh.minimized.keys() {
self.minimized.entry(k.clone()).or_insert(false);
}
self.active_tab.retain(|k, _| fresh.active_tab.contains_key(k));
for (k, v) in &fresh.active_tab {
self.active_tab.entry(k.clone()).or_insert(*v);
}
// A ratio vector whose child COUNT changed is meaningless — take the authored one.
self.split_ratio.retain(|k, v| fresh.split_ratio.get(k).is_some_and(|f| f.len() == v.len()));
for (k, v) in &fresh.split_ratio {
self.split_ratio.entry(k.clone()).or_insert_with(|| v.clone());
}
// An area binding survives only if the pane is still an area.
self.area_editor.retain(|k, _| fresh.area_editor.contains_key(k));
for (k, v) in &fresh.area_editor {
self.area_editor.entry(k.clone()).or_insert_with(|| v.clone());
}
if self.maximized.as_deref().is_some_and(|m| scene.pane_in(Some(&self.active_workspace), m).is_none()) {
self.maximized = None;
}
// A persisted absence naming a pane this build no longer authors is noise; a
// persisted absence naming a live pane is at most one frame stale — the host
// re-asserts presence from its own domain facts every frame (`set_absent`).
self.absent.retain(|k| scene.pane_in(Some(&self.active_workspace), k).is_some());
if self.editor_menu.as_deref().is_some_and(|m| !self.area_editor.contains_key(m)) {
self.editor_menu = None;
}
if scene.pane_in(Some(&self.active_workspace), self.router.current()).is_none() {
self.router = Router::new(scene.root.name.clone());
}
}
// ── AREA ⇄ EDITOR binding (Blender's "any area can host any editor type") ─────
/// The editor id area `pane` currently hosts (`None` for a non-area pane).
pub fn editor_of(&self, pane: &str) -> Option<&str> {
self.area_editor.get(pane).map(String::as_str)
}
/// **Swap an area's editor.** The pane name, its rect and its chrome are untouched —
/// only what is mounted changes. Returns true if it changed. Records
/// `last_chrome = "editor:<pane>=<id>"`, so a robot reads the swap back the same way
/// it reads a minimise. A pane with no area binding is a no-op (never a panic).
pub fn set_editor(&mut self, pane: &str, editor: &str) -> bool {
match self.area_editor.get(pane) {
Some(cur) if cur == editor => false,
Some(_) => {
self.area_editor.insert(pane.to_string(), editor.to_string());
self.editor_menu = None;
self.last_chrome = Some(format!("editor:{pane}={editor}"));
true
}
None => false,
}
}
/// Is `pane`'s editor-type dropdown open?
pub fn editor_menu_open(&self, pane: &str) -> bool {
self.editor_menu.as_deref() == Some(pane)
}
/// Open / close `pane`'s editor-type dropdown (at most one is open at a time).
pub fn set_editor_menu(&mut self, pane: &str, open: bool) {
self.editor_menu = if open { Some(pane.to_string()) } else { None };
}
/// The live child ratios for a Split pane (if any).
pub fn split_ratios(&self, pane: &str) -> Option<&Vec<f32>> {
self.split_ratio.get(pane)
}
/// Set the live child ratios for a Split pane (normalised to sum to 1).
pub fn set_split_ratios(&mut self, pane: &str, ratios: Vec<f32>) {
let n = ratios.len();
self.split_ratio.insert(pane.to_string(), normalized_ratios(&ratios, n));
}
/// Is `pane` currently folded? (Fixed panes are never folded.)
pub fn is_folded(&self, pane: &str) -> bool {
self.folded.get(pane).copied().unwrap_or(false)
}
/// Toggle a pane's fold. No-op for a non-foldable pane. Returns the new state.
pub fn toggle_fold(&mut self, pane: &str) -> bool {
if let Some(f) = self.folded.get_mut(pane) {
*f = !*f;
*f
} else {
false
}
}
/// Set a pane's fold explicitly (no-op for non-foldable).
pub fn set_fold(&mut self, pane: &str, folded: bool) {
if let Some(f) = self.folded.get_mut(pane) {
*f = folded;
}
}
// ── Runtime presence: a pane the host declares ABSENT does not exist on screen ──
/// Is `pane` absent this frame (allotted nothing, drawing nothing — see
/// [`absent`](Self::absent))?
pub fn is_absent(&self, pane: &str) -> bool {
self.absent.contains(pane)
}
/// Declare `pane` present (`false`) or absent (`true`). Idempotent — a host calls
/// this every frame from its own domain facts, which is what keeps presence a
/// consequence and never a stale flag. Fold / minimise / ratio state is deliberately
/// NOT touched, so a pane that returns comes back in the exact arrangement it left.
pub fn set_absent(&mut self, pane: &str, absent: bool) {
if absent {
self.absent.insert(pane.to_string());
} else {
self.absent.remove(pane);
}
}
// ── Window chrome: minimise / maximise / restore ─────────────────────────────
// These are the headless drive points. They work for ANY pane, wherever it sits in
// the tree (inside a Split, inside Tabs, nested) — [`Chrome`] governs whether the
// *affordance* is drawn, never whether the capability exists.
/// Is `pane` currently minimised (collapsed to its title bar)?
pub fn is_minimized(&self, pane: &str) -> bool {
self.minimized.get(pane).copied().unwrap_or(false)
}
/// Set a pane's minimised state explicitly. Returns true if it changed.
pub fn set_minimized(&mut self, pane: &str, on: bool) -> bool {
let prev = self.is_minimized(pane);
self.minimized.insert(pane.to_string(), on);
if prev != on {
self.last_chrome = Some(format!("{}:{pane}", if on { "minimize" } else { "unminimize" }));
}
prev != on
}
/// Toggle a pane's minimise. Returns the NEW state.
pub fn toggle_minimize(&mut self, pane: &str) -> bool {
let next = !self.is_minimized(pane);
self.set_minimized(pane, next);
next
}
/// Is `pane` the maximised one?
pub fn is_maximized(&self, pane: &str) -> bool {
self.maximized.as_deref() == Some(pane)
}
/// The maximised pane, if any.
pub fn maximized_pane(&self) -> Option<&str> {
self.maximized.as_deref()
}
/// Maximise `pane` (replacing any other maximise). Un-minimises it too — you cannot
/// be both. Returns true if it changed.
pub fn set_maximized(&mut self, pane: &str) -> bool {
let changed = self.maximized.as_deref() != Some(pane);
self.maximized = Some(pane.to_string());
self.minimized.insert(pane.to_string(), false);
if changed {
self.last_chrome = Some(format!("maximize:{pane}"));
}
changed
}
/// Toggle `pane`'s maximise. Returns the NEW state (`true` = now maximised).
pub fn toggle_maximize(&mut self, pane: &str) -> bool {
if self.is_maximized(pane) {
self.maximized = None;
self.last_chrome = Some(format!("restore:{pane}"));
false
} else {
self.set_maximized(pane);
true
}
}
/// **Restore** `pane`: un-minimise it and drop the maximise if it holds it. The
/// split ratios were never touched, so the layout returns EXACTLY as it was.
/// Returns true if it changed anything.
pub fn restore(&mut self, pane: &str) -> bool {
let was_min = self.is_minimized(pane);
let was_max = self.is_maximized(pane);
self.minimized.insert(pane.to_string(), false);
if was_max {
self.maximized = None;
}
if was_min || was_max {
self.last_chrome = Some(format!("restore:{pane}"));
}
was_min || was_max
}
/// Restore EVERY pane: clear all minimises and the maximise (the "reset layout"
/// one-liner a host menu hangs off).
pub fn restore_all(&mut self) -> bool {
let changed = self.maximized.is_some() || self.minimized.values().any(|v| *v);
for v in self.minimized.values_mut() {
*v = false;
}
self.maximized = None;
if changed {
self.last_chrome = Some("restore_all:*".to_string());
}
changed
}
/// The active child index for a Tabs pane.
pub fn active_tab(&self, pane: &str) -> usize {
self.active_tab.get(pane).copied().unwrap_or(0)
}
/// Set the active child index for a Tabs pane.
pub fn set_active_tab(&mut self, pane: &str, idx: usize) {
self.active_tab.insert(pane.to_string(), idx);
}
/// Apply a [`Target`] to the state, **scoped to a scene** (which
/// [`Target::Workspace`] needs in order to park the outgoing body and un-park the
/// incoming one). Returns true if it changed anything.
pub fn apply_in(&mut self, scene: &Scene, target: &Target) -> bool {
match target {
Target::Workspace(w) => self.set_workspace(scene, w),
other => self.apply(other),
}
}
/// Apply a [`Target`] to the state. Returns true if it changed anything.
/// [`Target::Workspace`] is a no-op here — it needs the scene, so use
/// [`apply_in`](Self::apply_in) (which [`fire`](Self::fire) and [`render`] both do).
pub fn apply(&mut self, target: &Target) -> bool {
match target {
Target::Show(p) => self.router.navigate(p.clone()),
Target::Focus(p) => {
self.focus = Some(p.clone());
self.router.navigate(p.clone())
}
Target::Push(p) => self.router.navigate(p.clone()),
Target::Pop => self.router.back(),
Target::Toggle(p) => {
self.toggle_fold(p);
true
}
Target::Minimize(p) => {
self.toggle_minimize(p);
true
}
Target::Maximize(p) => {
self.toggle_maximize(p);
true
}
Target::Restore(p) => self.restore(p),
Target::ShowWith { pane, arg } => {
self.arg = Some(arg.clone());
self.router.navigate(pane.clone())
}
Target::SetEditor { pane, editor } => self.set_editor(pane, editor),
Target::Workspace(_) => false,
}
}
/// Fire the first route whose trigger matches, applying its target. Returns true
/// if a route matched (and its target was applied).
pub fn fire(&mut self, scene: &Scene, trigger: &Trigger) -> bool {
if let Some(route) = scene.routes.iter().find(|r| &r.trigger == trigger) {
let target = route.target.clone();
self.apply_in(scene, &target);
true
} else {
false
}
}
/// Resolve a deep link (`app#pane` or `#pane`) by unique pane name. Returns true
/// if the pane exists and the router moved to it.
pub fn deep_link(&mut self, scene: &Scene, link: &str) -> bool {
let frag = link.rsplit('#').next().unwrap_or(link);
match Router::route_from_hash(frag) {
Some(name) if scene.pane(&name).is_some() => self.router.navigate(name),
_ => false,
}
}
/// The runtime state as JSON (folded into the scene's `state_json`).
pub fn state_json(&self) -> Value {
json!({
"router": self.router.state_json(),
"focus": self.focus,
"arg": self.arg,
"folded": self.folded,
"minimized": self.minimized,
"maximized": self.maximized,
"last_chrome": self.last_chrome,
"active_tab": self.active_tab,
"split_ratio": self.split_ratio,
"absent": self.absent,
"area_editor": self.area_editor,
"editor_menu": self.editor_menu,
"active_workspace": self.active_workspace,
"last_workspace": self.last_workspace,
// Parked bodies, keyed by workspace — this is where `main` in one workspace
// is told apart from `main` in another.
"workspaces": self.workspaces.iter().map(|(k, v)| (k.clone(), v.state_json())).collect::<BTreeMap<_, _>>(),
})
}
}
/// Normalise `ratios` to exactly `n` non-negative fractions that sum to 1. Missing
/// entries default to an equal share; a zero/degenerate sum falls back to equal shares.
fn normalized_ratios(ratios: &[f32], n: usize) -> Vec<f32> {
if n == 0 {
return Vec::new();
}
let mut v: Vec<f32> = (0..n).map(|i| ratios.get(i).copied().unwrap_or(1.0 / n as f32).max(0.0)).collect();
let sum: f32 = v.iter().sum();
if sum <= f32::EPSILON {
return vec![1.0 / n as f32; n];
}
for r in &mut v {
*r /= sum;
}
v
}
// ─────────────────────────────────────────────────────────────────────────────
// RENDER — walk the pane tree, apply each Layout via facett-app primitives.
// ─────────────────────────────────────────────────────────────────────────────
/// The host that fills each [`Content::Slot`] with a real widget. Implemented by the
/// app adopting the scene (e.g. facett-demo), where the concrete widgets live.
pub trait SceneHost {
/// Draw the widget for slot `id` into `ui`. Return its `state_json` (folded into
/// the scene's observable state); return [`Value::Null`] if there's nothing to add.
fn slot(&mut self, id: &str, ui: &mut egui::Ui) -> Value;
/// The host app's **DEV-ID component** — the `<component>` of
/// [`facett_core::errcode`]'s `facet-<component>-<n>`, so the identity a pane shows
/// and the identity its errors carry share one vocabulary.
///
/// Non-release builds paint `component/slot_id` as a click-to-copy chip on every
/// slot ([`facett_core::devid`]), so a screenshot or a chat message says exactly
/// which app and which pane. **Defaulted to `""`** — purely additive, every
/// existing `SceneHost` keeps compiling and simply renders the honest
/// `⟨unregistered⟩` marker until it declares one.
fn component(&self) -> &'static str {
""
}
/// Handle a host command id (from an [`Action::Command`] menu action).
fn command(&mut self, _id: &str) {}
// ── AREAS (Blender's model). Both defaulted, so this trait stayed ADDITIVE: an
// existing host that never authors a `Pane::area` implements nothing new. ──────
/// Draw the editor currently mounted in AREA pane `pane` (editor id `editor_id`).
///
/// The canonical implementation is one line over
/// [`EditorAreas::draw`](crate::editor::EditorAreas::draw):
///
/// ```ignore
/// fn area(&mut self, pane: &str, editor_id: &str, ui: &mut egui::Ui) -> Value {
/// self.areas.draw(&self.registry, pane, editor_id, ui)
/// }
/// ```
///
/// **Defaults to [`slot`](Self::slot)** with the editor id as the slot id, so a host
/// that already switches on string ids gets areas working before it adopts the
/// registry at all.
fn area(&mut self, _pane: &str, editor_id: &str, ui: &mut egui::Ui) -> Value {
self.slot(editor_id, ui)
}
/// The `(editor id, display name)` pairs an area's header dropdown offers — i.e.
/// [`EditorRegistry::options`](crate::editor::EditorRegistry::options). **Defaults to
/// empty**, in which case an area header shows its editor's id as a plain label and
/// no dropdown is drawn (there is nothing to swap to, so no affordance is claimed).
fn editors(&self) -> Vec<(String, String)> {
Vec::new()
}
}
/// The device class the renderer picked from the available width + `collapse_below`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Device {
/// Phone-class (narrow).
Phone,
/// Tablet-class.
Tablet,
/// Desktop-class (wide).
Desktop,
}
impl Device {
/// Classify an available width (points) into a device class using the default
/// breakpoints (`< 600` ⇒ phone, `< 900` ⇒ tablet, else desktop). This is the
/// public entry a host SHELL calls to decide whether to FOLD its control strip on
/// a narrow (phone-class) viewport so the content/canvas gets the screen — the
/// same classification [`render`] applies to a [`Scene`]'s panes.
pub fn from_width(width: f32) -> Device {
classify(width, None)
}
/// The stable tag string (`"phone"` / `"tablet"` / `"desktop"`).
pub fn tag(self) -> &'static str {
match self {
Device::Phone => "phone",
Device::Tablet => "tablet",
Device::Desktop => "desktop",
}
}
}
/// Default breakpoints (points of available width).
const TABLET_MAX: f32 = 900.0;
const PHONE_MAX: f32 = 600.0;
/// Classify the width into a device class, honouring a pane's `collapse_below`
/// override (below it ⇒ always phone-class).
fn classify(width: f32, collapse_below: Option<f32>) -> Device {
if let Some(cb) = collapse_below && width < cb {
return Device::Phone;
}
if width < PHONE_MAX {
Device::Phone
} else if width < TABLET_MAX {
Device::Tablet
} else {
Device::Desktop
}
}
/// The effective layout for a pane given its device class + responsive overrides.
fn effective_layout(pane: &Pane, width: f32) -> Layout {
match classify(width, pane.responsive.collapse_below) {
Device::Phone => pane.responsive.phone.clone().unwrap_or_else(|| pane.layout.clone()),
Device::Tablet => pane.responsive.tablet.clone().unwrap_or_else(|| pane.layout.clone()),
Device::Desktop => pane.layout.clone(),
}
}
/// On a device-class *transition*, auto-fold every `drawer` pane entering phone-class
/// and restore it (to its `default_open`) leaving phone-class. Applied only on a change
/// so the user can still open/close a drawer while it stays phone-class.
fn apply_drawer_transitions(scene: &Scene, nav: &mut NavState, device: Device) {
let was_phone = matches!(nav.last_device, Some(Device::Phone));
let is_phone = matches!(device, Device::Phone);
if nav.last_device.is_some() && was_phone == is_phone {
return; // no crossing of the phone boundary
}
let mut acts: Vec<(String, bool)> = Vec::new();
fn walk(p: &Pane, is_phone: bool, acts: &mut Vec<(String, bool)>) {
if p.responsive.drawer && let Fold::Foldable { default_open, .. } = &p.fold {
// Entering phone → folded (drawer shut); leaving → restore default.
acts.push((p.name.clone(), if is_phone { true } else { !*default_open }));
}
if let Content::Children(kids) = &p.content {
for k in kids {
walk(k, is_phone, acts);
}
}
}
walk(&scene.root, is_phone, &mut acts);
for (name, folded) in acts {
nav.set_fold(&name, folded);
}
}
/// The render context threaded through the recursive walk.
struct Ctx<'a> {
nav: &'a mut NavState,
host: &'a mut dyn SceneHost,
/// The scene's root pane name (so `Chrome::Auto` can leave the root bare).
root: String,
/// The ancestor chain of the maximised pane (root → maximised, inclusive), empty
/// when nothing is maximised. A split child NOT on this chain is allotted ZERO.
max_path: std::collections::BTreeSet<String>,
/// The scene's menubar items — so a [`Content::Menubar`] PANE can draw them in place.
menubar: &'a [MenuItem],
/// The menu item a [`Content::Menubar`] pane was clicked on this frame.
menu_hit: Option<String>,
/// The ACTIVE workspace, spliced in at the [`Content::Body`] pane.
workspace: Option<&'a Workspace>,
/// Every workspace, for the [`Content::WorkspaceBar`] strip.
all_workspaces: &'a [Workspace],
/// The workspace a tab click asked for this frame.
switch_workspace: Option<String>,
/// Per-workspace-tab rects, published for robot addressing.
workspace_tabs: BTreeMap<String, [f32; 4]>,
/// Per-pane observable render facts (folded / layout / rect).
panes: serde_json::Map<String, Value>,
/// Per-slot `state_json`.
slots: serde_json::Map<String, Value>,
/// Per-split-pane drag-handle rects `[x, y, w, h]` (robot-addressable for a drag).
sep_rects: serde_json::Map<String, Value>,
/// Per-BODY-TAB header rects `[x, y, w, h]`, keyed by the tab pane's name — the
/// visible-window (watch) analogue of [`workspace_tabs`](Self::workspace_tabs), so a
/// robot can travel to and CLICK a tab header by position (not only by AccessKit
/// label). Empty until a [`Layout::Tabs`] strip has been drawn once.
tab_rects: BTreeMap<String, [f32; 4]>,
}
impl Ctx<'_> {
/// Is a maximise in force this frame?
fn maximizing(&self) -> bool {
!self.max_path.is_empty()
}
/// Is `pane` on the path to the maximised pane (⇒ it keeps its area)?
fn on_max_path(&self, pane: &str) -> bool {
self.max_path.contains(pane)
}
/// Record a pane that was allotted **zero area** — because a sibling is maximised
/// (`"maximize"`) or because the host declared it absent (`"absent"`). It is a real
/// layout fact, not a flag: the rect has zero extent along the split axis, which is
/// what the RED-when-broken assertions read — and the REASON is data too, so a
/// robot can tell "the host says this pane's subject does not exist" apart from
/// "another pane holds the screen".
fn record_hidden(&mut self, pane: &Pane, rect: egui::Rect, by: &str) {
self.panes.insert(
pane.name.clone(),
json!({
"title": pane.header_title(),
"hidden_by": by,
"rect": [rect.min.x, rect.min.y, rect.width(), rect.height()],
"alloc": [rect.min.x, rect.min.y, rect.width(), rect.height()],
"area": rect.width() * rect.height(),
}),
);
}
/// Why `kids[i]` renders nothing this frame, if it does: absence wins the naming
/// (an absent pane is gone whatever else is going on), a maximise elsewhere zeroes
/// the rest.
fn hidden_reason(&self, pane: &Pane) -> &'static str {
if self.nav.is_absent(&pane.name) { "absent" } else { "maximize" }
}
}
/// The thickness (points) of a resizable-split drag handle.
const SPLIT_HANDLE: f32 = 6.0;
/// The smallest fraction a split child may be dragged down to.
const SPLIT_MIN: f32 = 0.05;
/// **The extent (points) a MINIMISED pane collapses to** — the height of its chrome
/// title bar (and, in a horizontal split, the width of the rail it becomes). Public so
/// a test or a host can assert the exact collapsed geometry rather than a boolean.
pub const CHROME_BAR: f32 = 22.0;
/// **The default height (points) of a [`Pane::menubar`] row.** Public so a test can
/// assert the menu bar's exact allotted height instead of "it is there".
pub const MENUBAR_H: f32 = 26.0;
/// The conventional height (points) of a toolbar row — see [`Pane::bar`].
pub const TOOLBAR_H: f32 = 28.0;
/// The conventional height (points) of a status-bar row — see [`Pane::bar`].
pub const STATUSBAR_H: f32 = 22.0;
/// **The smallest an area may usefully be.** A split child below this is a strip nobody
/// can work in; a host clamps a drag against it.
pub const AREA_MIN_PT: f32 = 48.0;
/// Below this width a chrome bar draws in **rail** mode — just the restore control,
/// stacked — because a minimised child of a *horizontal* split is a narrow strip.
const CHROME_RAIL_MAX_W: f32 = 60.0;
/// The chrome glyphs. Every one of these is a character egui's own bundled fonts
/// render (`⏷`/`⏵` are egui's collapsing triangles, `−` its minus, `🗖`/`🗗` live in the
/// same icon-font block as the `🗑` egui itself uses) — asserted by
/// `chrome_glyphs_are_renderable` so a font swap can never silently ship tofu.
const GLYPH_FOLD_OPEN: &str = "⏷";
const GLYPH_FOLD_SHUT: &str = "⏵";
const GLYPH_MINIMIZE: &str = "−";
const GLYPH_MAXIMIZE: &str = "🗖";
const GLYPH_RESTORE: &str = "🗗";
/// **Every chrome glyph**, so a host that swaps egui's fonts can assert coverage the way
/// `chrome_glyphs_are_renderable` does — a missing glyph puts a tofu box in every pane
/// header, and nothing else would notice.
pub const CHROME_GLYPHS: &str = "⏷⏵−🗖🗗";
/// Render a scene through facett-app's own primitives, mutating `nav` from any
/// interaction, and return the whole scene's `state_json` (scene name, active route,
/// per-pane folded state, device class, pane rects, and each slot's own state).
pub fn render(scene: &Scene, nav: &mut NavState, ui: &mut egui::Ui, host: &mut dyn SceneHost) -> Value {
let width = ui.available_width();
let device = classify(width, scene.root.responsive.collapse_below);
// A scene drawn INSIDE another container (a deck pane, a wrapped canvas) is that
// container's content, in draw order; at the top level there is no open scope and
// this is a no-op.
facett_core::navgraph::declare_own_draw_order(ui);
// navgraph phase 2: the scene root is the graph's declared root, unless the host has
// declared an order of its own (its `NavOrder` then names the roots).
if facett_core::navgraph::collecting(ui.ctx()) && !facett_core::navgraph::has_order(ui.ctx()) {
facett_core::navgraph::set_order(
ui.ctx(),
facett_core::navgraph::NavOrder::new().roots([scene.root.name.as_str()]),
);
}
// Responsive drawer transitions: on a device-class change, auto-fold `drawer` panes
// entering phone-class and auto-restore them leaving it (desktop rail → phone drawer).
apply_drawer_transitions(scene, nav, device);
nav.last_device = Some(device);
// The maximise in force this frame: the ancestor chain of the maximised pane. An
// unknown/stale name simply resolves to "nothing maximised" (never a panic).
// The ACTIVE workspace: whatever `nav` says, falling back to the authored default so
// a stale saved name can never leave the body blank.
let active_ws = scene.ws_or_default(Some(nav.active_workspace.as_str()));
if let Some(w) = active_ws && nav.active_workspace != w.name {
nav.active_workspace = w.name.clone();
}
let ws_name = active_ws.map(|w| w.name.clone());
// A maximise resolves over shell ∪ the active workspace, so maximising a BODY pane
// keeps every shell ancestor on the path and the shell chrome stays exactly put.
// …and a maximise naming an ABSENT pane resolves to "nothing maximised": a pane
// that does not exist on screen cannot hold the screen (the maximise itself is NOT
// cleared — presence returning restores it, like every other arrangement fact).
let max_path: std::collections::BTreeSet<String> = nav
.maximized
.clone()
.filter(|m| !nav.is_absent(m))
.and_then(|m| scene.path_to_in(ws_name.as_deref(), &m))
.map(|p| p.into_iter().collect())
.unwrap_or_default();
// Does the tree host the menu bar itself (`Pane::menubar`)? If so the implicit top
// strip stands down — the shell's chrome is a PANE, and drawing both would twin it.
let menu_in_tree = scene.menubar_pane().is_some();
let mut ctx = Ctx {
nav,
host,
root: scene.root.name.clone(),
max_path,
menubar: &scene.menubar,
menu_hit: None,
workspace: active_ws,
all_workspaces: &scene.workspaces,
switch_workspace: None,
workspace_tabs: BTreeMap::new(),
panes: serde_json::Map::new(),
slots: serde_json::Map::new(),
sep_rects: serde_json::Map::new(),
tab_rects: BTreeMap::new(),
};
// ── Menubar (facett-menu — the SAME surface the AppShell uses). ──────────────
let mut menu_hit: Option<String> = None;
if !scene.menubar.is_empty() && !menu_in_tree {
let commands = menubar_commands(&scene.menubar);
egui::Panel::top(ui.id().with(("scene_menubar", &scene.name))).show(ui, |ui| {
menu_hit = facett_menu::menu_bar(ui, &commands);
});
}
if let Some(name) = menu_hit {
apply_menu_hit(&mut ctx, scene, &name);
}
// ── The pane tree (the root claims the remaining central area). ──────────────
egui::CentralPanel::default().show(ui, |ui| {
draw_pane(&mut ctx, &scene.root, width, true, ui);
});
// A hit on the menu bar PANE is only known once the tree has been drawn, so it is
// applied here (it lands one frame later than the implicit strip's — the same
// discipline `Layout::Tabs` already uses for a header that changed this frame).
if let Some(name) = ctx.menu_hit.take() {
apply_menu_hit(&mut ctx, scene, &name);
}
// A workspace tab click is only known once the strip has been drawn. The switch lands
// now and the NEW body draws next frame with a clean layout pass — the same discipline
// `Layout::Tabs` uses for a header that changed this frame.
if let Some(name) = ctx.switch_workspace.take() {
ctx.nav.set_workspace(scene, &name);
}
json!({
"scene": scene.name,
"device": device.tag(),
"width": width,
"route": ctx.nav.router.current(),
"focus": ctx.nav.focus,
"folded": ctx.nav.folded,
"minimized": ctx.nav.minimized,
"maximized": ctx.nav.maximized,
"active_tab": ctx.nav.active_tab,
"panes": Value::Object(ctx.panes),
"slots": Value::Object(ctx.slots),
"workspace": ctx.nav.active_workspace,
"workspaces": scene.workspace_names(),
"workspace_tabs": ctx.workspace_tabs,
"tab_rects": ctx.tab_rects,
"nav": ctx.nav.state_json(),
// The DEV-ID gate for THIS process — `{enabled, env, debug_assertions, source}`.
// korp draws every pane through this one `render`, so `$KORP_STATE` now says
// whether the chips are switched on and which of the env var or the build profile
// decided it. It reports the GATE, never that a chip is on screen: `enabled: true`
// was equally true of both wiring misses. See `facett_core::devid::gate_json`.
"devid": facett_core::devid::gate_json(),
})
}
/// Apply a menubar hit: a declared route wins; otherwise the item's own [`Action`] runs.
/// Shared by the implicit top strip and the menu bar PANE, so both behave identically.
fn apply_menu_hit(ctx: &mut Ctx, scene: &Scene, name: &str) {
if ctx.nav.fire(scene, &Trigger::Menu(name.to_string())) {
return;
}
let Some(item) = scene.menu_item(name) else { return };
match item.action.clone() {
Some(Action::NavigateTo(p)) => {
ctx.nav.router.navigate(p);
}
Some(Action::Toggle(p)) => {
ctx.nav.toggle_fold(&p);
}
Some(Action::Minimize(p)) => {
ctx.nav.toggle_minimize(&p);
}
Some(Action::Maximize(p)) => {
ctx.nav.toggle_maximize(&p);
}
Some(Action::Restore(p)) => {
ctx.nav.restore(&p);
}
Some(Action::SetEditor { pane, editor }) => {
ctx.nav.set_editor(&pane, &editor);
}
Some(Action::Workspace(w)) => {
ctx.nav.set_workspace(scene, &w);
}
Some(Action::Command(id)) => ctx.host.command(&id),
None => {}
}
}
/// Flatten the nested menubar into facett-menu commands (top-level label = group,
/// leaf name = id) — reusing facett-menu's menubar surface (L5, no new menu engine).
fn menubar_commands(menubar: &[MenuItem]) -> Vec<facett_menu::Command> {
let mut out = Vec::new();
fn walk(items: &[MenuItem], group: &str, out: &mut Vec<facett_menu::Command>) {
for it in items {
if it.items.is_empty() {
let g = if group.is_empty() { "Menu" } else { group };
let mut c = facett_menu::Command::new(it.name.clone(), it.label.clone(), g.to_string());
if let Some(s) = &it.shortcut {
c = c.shortcut(s.clone());
}
out.push(c);
} else {
walk(&it.items, &it.label, out);
}
}
}
walk(menubar, "", &mut out);
out
}
/// Draw one pane (recursively). Applies its effective layout; a chromed pane wraps its
/// content in a **window title bar** (fold · minimise · maximise) and, when folded or
/// minimised, draws ONLY that bar. A pane that opted out of chrome falls back to the
/// plain collapsing header (the pre-chrome renderer, unchanged).
fn draw_pane(ctx: &mut Ctx, pane: &Pane, width: f32, bounded: bool, ui: &mut egui::Ui) {
let layout = effective_layout(pane, width);
let foldable = matches!(pane.fold, Fold::Foldable { .. });
let folded = foldable && ctx.nav.is_folded(&pane.name);
let spec = chrome_spec(pane, pane.name == ctx.root);
let maximized = ctx.nav.is_maximized(&pane.name);
// Maximise beats minimise: you asked for the whole screen, you get it.
let minimized = !maximized && ctx.nav.is_minimized(&pane.name);
let body_hidden = folded || minimized;
// Record the observable facts for this pane.
let layout_tag = layout_tag(&layout);
let content_tag = match &pane.content {
Content::Slot(id) => format!("slot:{id}"),
Content::Area { editor, .. } => {
format!("area:{}", ctx.nav.editor_of(&pane.name).unwrap_or(editor))
}
Content::Menubar => "menubar".to_string(),
Content::Body => format!("body:{}", ctx.nav.active_workspace),
Content::WorkspaceBar => "workspace_bar".to_string(),
Content::Children(_) => "children".to_string(),
};
let alloc = ui.available_rect_before_wrap();
// ── navgraph phase 2 (navgraph-design §6): every scene pane is a node, and the scene
// TREE is the declared order — the pane list is data, so no host writes a `NavOrder`
// for it. A children pane declares its kids in authored order; a tab set declares its
// strip first and the active tab in the wildcard slot (the key changes with the tab,
// the number does not — the same rule `FacetDeck` follows); the body splice declares
// its one child, the active workspace's root. Slot/area content belongs to the host
// (phase 3) and numbers in draw order until the host declares it.
let nav_token = facett_core::navgraph::begin(ui, &pane.name, nav_kind(pane, pane.name == ctx.root), pane.header_title());
match &pane.content {
Content::Children(kids) => match &layout {
Layout::Tabs { .. } => {
facett_core::navgraph::declare_children(ui, nav_token, ["tabs", facett_core::navgraph::WILDCARD]);
}
_ => facett_core::navgraph::declare_children(ui, nav_token, kids.iter().map(|k| k.name.as_str())),
},
Content::Body => facett_core::navgraph::declare_children(ui, nav_token, [facett_core::navgraph::WILDCARD]),
// A slot or area is the host's: its own widgets and the dev-id pane number in the
// order they draw, unless the host declares otherwise inside (phase 3).
Content::Slot(_) | Content::Area { .. } => facett_core::navgraph::declare_draw_order(ui, nav_token),
_ => {}
}
let mut chrome_json: Option<Value> = None;
// The rect the pane's BODY actually occupied. Zero-extent when the body was
// omitted — so "the contents left the layout" is a MEASUREMENT, not a flag, and a
// robot can tell fold (body gone, slot kept) from minimise (body AND slot gone).
let mut content_rect = egui::Rect::from_min_size(alloc.min, egui::Vec2::ZERO);
let rect = if spec.bar {
// ── Chromed: ONE title bar carrying the fold toggle AND the window controls.
// (L5 — the bar *hosts* the fold rather than sitting beside a second header.)
let mut buttons = serde_json::Map::new();
let r = ui.scope(|ui| {
let bar_rect = draw_chrome_bar(ctx, pane, spec, minimized, maximized, folded, ui, &mut buttons);
let body = if body_hidden {
egui::Rect::from_min_size(bar_rect.left_bottom(), egui::Vec2::ZERO)
} else {
ui.scope(|ui| draw_content(ctx, pane, &layout, width, bounded, ui)).response.rect
};
(bar_rect, body)
});
let ((bar_rect, body), whole) = (r.inner, r.response.rect);
content_rect = body;
chrome_json = Some(json!({
"spec": spec.json(),
"minimized": minimized,
"maximized": maximized,
"bar_rect": [bar_rect.min.x, bar_rect.min.y, bar_rect.width(), bar_rect.height()],
"bar_height": bar_rect.height(),
"buttons": Value::Object(buttons),
}));
whole
} else if foldable {
// ── Opted out of chrome, but foldable: the ORIGINAL CollapsingHeader (L5 —
// egui's own, like facett-graphnav's ⚙ fold). When folded, `.open(Some(false))`
// means the body closure's content is NOT laid out ⇒ omitted from AccessKit. ──
let icon = match &pane.fold {
Fold::Foldable { icon: Some(i), .. } => format!("{i} "),
_ => String::new(),
};
let arrow = if folded { "▸" } else { "▾" };
let header = format!("{arrow} {icon}{}", pane.header_title());
let resp = egui::CollapsingHeader::new(header)
.id_salt(("scene_fold", &pane.name))
.open(Some(!folded))
.show(ui, |ui| {
draw_content(ctx, pane, &layout, width, bounded, ui);
});
if resp.header_response.clicked() {
ctx.nav.toggle_fold(&pane.name);
}
if let Some(body) = resp.body_response.as_ref() {
content_rect = body.rect;
}
resp.header_response.rect
} else if minimized {
// No chrome bar but minimised anyway (headless drive on an opted-out pane):
// honour the state — the body is omitted.
ui.scope(|_| {}).response.rect
} else {
let r = ui.scope(|ui| draw_content(ctx, pane, &layout, width, bounded, ui));
content_rect = r.response.rect;
r.response.rect
};
let mut facts = json!({
"title": pane.header_title(),
"foldable": foldable,
"folded": folded,
"minimized": minimized,
"maximized": maximized,
"layout": layout_tag,
"content": content_tag,
"rect": [rect.min.x, rect.min.y, rect.width(), rect.height()],
// The rect the PARENT allotted this pane — the geometry oracle a maximise /
// minimise assertion reads (a hidden sibling's is zero-extent).
"alloc": [alloc.min.x, alloc.min.y, alloc.width(), alloc.height()],
"area": alloc.width() * alloc.height(),
// The rect the pane's BODY occupied — exactly zero-extent when the body was
// omitted (folded or minimised). "The contents left the layout" is therefore a
// measurement any robot can assert, not a boolean anyone has to trust.
"content_rect": [content_rect.min.x, content_rect.min.y, content_rect.width(), content_rect.height()],
"content_height": content_rect.height(),
// ── Lock 2, made observable. `overflowed` is the assertion a rect-only test
// cannot make: the allotment held (lock 1) but the PIXELS escaped it. It must be
// false for every bounded leaf, whatever its content does.
"overflow": pane.overflow.resolve(bounded).tag(),
"clipped": !matches!(pane.overflow.resolve(bounded), Overflow::Grow),
"overflowed": content_rect.height() > alloc.height() + 0.5 || content_rect.width() > alloc.width() + 0.5,
});
if let Some(c) = chrome_json {
facts.as_object_mut().unwrap().insert("chrome".into(), c);
}
// An AREA publishes its LIVE editor binding + what it may be switched to, keyed by
// the pane name. A robot reads `panes.<area>.editor.id` before and after a swap and
// `panes.<area>.rect` to prove the rect did not move.
if let Content::Area { editor: default_editor, allow } = &pane.content {
let id = ctx.nav.editor_of(&pane.name).unwrap_or(default_editor).to_string();
let options = allow.filter(ctx.host.editors());
let name = options.iter().find(|(o, _)| *o == id).map(|(_, n)| n.clone());
facts.as_object_mut().unwrap().insert(
"editor".into(),
json!({
"id": id,
"name": name,
"default": default_editor,
"menu_open": ctx.nav.editor_menu_open(&pane.name),
"options": options.iter().map(|(i, n)| json!({"id": i, "name": n})).collect::<Vec<_>>(),
}),
);
}
// For a Split laid out this frame, add its live ratios, whether it's resizable, and
// the drag-handle rects (so a robot can drag a divider by position).
if let Layout::Split { resizable, .. } = &layout {
let obj = facts.as_object_mut().unwrap();
if let Some(ratios) = ctx.nav.split_ratio.get(&pane.name) {
obj.insert("ratios".into(), json!(ratios));
}
obj.insert("resizable".into(), json!(*resizable));
if let Some(seps) = ctx.sep_rects.remove(&pane.name) {
obj.insert("separators".into(), seps);
}
}
ctx.panes.insert(pane.name.clone(), facts);
facett_core::navgraph::end(ui, nav_token, rect);
}
/// The navgraph kind of a scene pane (navgraph-design §1): the root is the `App`, the body
/// splice is the `Surface` a workspace fills, a slot or area (a host widget) is a `Pane`,
/// and every other container (children, menu bar, workspace bar) is a `Group`.
fn nav_kind(pane: &Pane, is_root: bool) -> facett_core::navgraph::NavKind {
use facett_core::navgraph::NavKind;
if is_root {
return NavKind::App;
}
match &pane.content {
Content::Slot(_) | Content::Area { .. } => NavKind::Pane,
Content::Body => NavKind::Surface,
Content::Children(_) | Content::Menubar | Content::WorkspaceBar => NavKind::Group,
}
}
/// Draw a pane's **window title bar** and act on its controls. Returns the bar's rect
/// and fills `buttons` with each control's rect `[x, y, w, h]` — a robot clicks the
/// centre of those rects (the same addressing the split `separators` already use), and
/// each control ALSO carries a unique AccessKit label (`"minimize <pane>"`, …) so
/// `click_label` works too.
///
/// Narrow strips (a minimised child of a *horizontal* split) draw in **rail** mode: the
/// restore control alone, so the pane is always recoverable.
#[allow(clippy::too_many_arguments)]
fn draw_chrome_bar(
ctx: &mut Ctx,
pane: &Pane,
spec: ChromeSpec,
minimized: bool,
maximized: bool,
folded: bool,
ui: &mut egui::Ui,
buttons: &mut serde_json::Map<String, Value>,
) -> egui::Rect {
let avail = ui.available_rect_before_wrap();
let rail = avail.width() < CHROME_RAIL_MAX_W;
// In a top-down parent (a Split child, a Tabs body — every real shell) the bar spans
// the pane's full width and the window controls sit hard right. In a HORIZONTAL
// parent (a `Layout::Flow` row) it must shrink to its own content instead, or the
// first pane in the row would claim the whole row for its title bar.
let stretch = !ui.layout().main_dir().is_horizontal();
let bar_w = avail.width().max(1.0);
let bar_h = CHROME_BAR.min(avail.height().max(1.0));
let size = if rail { egui::vec2(bar_w, avail.height().max(bar_h)) } else { egui::vec2(bar_w, bar_h) };
// A visible bar: a filled strip + a hairline under it, so the operator SEES that
// this pane is a window with controls, not an anonymous region.
// Copy the two values used — cloning the whole `Visuals` here cost more than the bar
// it paints (it is a large struct, and this runs once per pane per frame).
let (fill, edge) = {
let v = ui.visuals();
let fill = if maximized {
v.selection.bg_fill
} else if minimized {
v.widgets.active.bg_fill
} else {
v.widgets.noninteractive.bg_fill
};
(fill, v.widgets.noninteractive.bg_stroke)
};
let layout = if rail {
egui::Layout::top_down(egui::Align::Center)
} else {
egui::Layout::left_to_right(egui::Align::Center)
};
let mut acts: Vec<&'static str> = Vec::new();
// Reserve two slots in the paint list FIRST, fill them once the bar's true rect is
// known. A shrink-to-fit bar does not know its own width until its controls are laid
// out, and painting the strip afterwards would cover them.
let bg = ui.painter().add(egui::Shape::Noop);
let underline = ui.painter().add(egui::Shape::Noop);
let mut draw = |ui: &mut egui::Ui, acts: &mut Vec<&'static str>, buttons: &mut serde_json::Map<String, Value>| {
ui.spacing_mut().item_spacing.x = 2.0;
if rail {
// Narrow rail: the ONE control that gets the pane back.
let resp = ui.add(egui::Button::new(GLYPH_RESTORE).small());
label_button(&resp, "restore", &pane.name);
record_button(buttons, "restore", resp.rect);
if resp.clicked() {
acts.push("restore");
}
return;
}
if spec.fold {
let glyph = if folded { GLYPH_FOLD_SHUT } else { GLYPH_FOLD_OPEN };
let resp = ui.add(egui::Button::new(glyph).small());
label_button(&resp, if folded { "unfold" } else { "fold" }, &pane.name);
record_button(buttons, "fold", resp.rect);
if resp.clicked() {
acts.push("fold");
}
}
// ── The EDITOR-TYPE dropdown — the region this whole header exists for. It
// sits at the head of the SAME bar that carries minimise/maximise; there is no
// second header row (L5 — extend the chrome bar, never stack a twin on it).
draw_editor_picker(ctx, pane, ui, buttons);
// The icon rides INSIDE the title text (one node, as the fold header always
// did) so the pane's title stays a single addressable label. No icon ⇒ no
// allocation: the title is borrowed straight out of the pane.
match &pane.fold {
Fold::Foldable { icon: Some(i), .. } => {
ui.label(egui::RichText::new(format!("{i} {}", pane.header_title())).strong());
}
_ => {
ui.label(egui::RichText::new(pane.header_title()).strong());
}
}
// The window controls sit hard right, like every title bar ever built — unless
// the bar shrank to its content (a Flow row), where they simply follow the title.
let controls = |ui: &mut egui::Ui, acts: &mut Vec<&'static str>, buttons: &mut serde_json::Map<String, Value>| {
if spec.maximize {
let glyph = if maximized { GLYPH_RESTORE } else { GLYPH_MAXIMIZE };
let resp = ui.add(egui::Button::new(glyph).small());
label_button(&resp, if maximized { "restore" } else { "maximize" }, &pane.name);
record_button(buttons, "maximize", resp.rect);
if resp.clicked() {
acts.push("maximize");
}
}
if spec.minimize {
let glyph = if minimized { GLYPH_RESTORE } else { GLYPH_MINIMIZE };
let resp = ui.add(egui::Button::new(glyph).small());
label_button(&resp, if minimized { "restore" } else { "minimize" }, &pane.name);
record_button(buttons, "minimize", resp.rect);
if resp.clicked() {
acts.push("minimize");
}
}
};
if stretch {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| controls(ui, acts, buttons));
} else {
controls(ui, acts, buttons);
}
};
// A stretched bar is allocated at the pane's full width; a shrink-to-fit one lets
// `ui.horizontal` size it to the controls it holds.
let inner_rect = if stretch {
ui.allocate_ui_with_layout(size, layout, |ui| draw(ui, &mut acts, buttons)).response.rect
} else {
ui.horizontal(|ui| draw(ui, &mut acts, buttons)).response.rect
};
// Now the strip's real extent is known — fill the reserved slots BEHIND the controls.
ui.painter().set(bg, egui::Shape::rect_filled(inner_rect, 2.0, fill));
ui.painter().set(
underline,
egui::Shape::LineSegment {
points: [inner_rect.left_bottom(), inner_rect.right_bottom()],
stroke: edge,
},
);
for a in acts {
match a {
"fold" => {
ctx.nav.toggle_fold(&pane.name);
ctx.nav.last_chrome = Some(format!("fold:{}", pane.name));
}
"minimize" => {
if minimized {
ctx.nav.restore(&pane.name);
} else {
ctx.nav.toggle_minimize(&pane.name);
}
}
"maximize" => {
ctx.nav.toggle_maximize(&pane.name);
}
"restore" => {
ctx.nav.restore(&pane.name);
}
_ => {}
}
}
inner_rect
}
/// Draw the **editor-type dropdown** for an AREA pane, inside its chrome title bar.
///
/// This is the load-bearing control of the whole area model: it is where "swap this pane
/// to a different editor" lives. It is a *region of the existing bar*, not a second
/// header — the bar already carried fold/minimise/maximise and now carries this too.
///
/// - Non-area pane ⇒ nothing drawn (returns immediately).
/// - Area pane, host offers no editors ([`SceneHost::editors`] empty) ⇒ a plain label of
/// the current editor id and **no** dropdown: an affordance with nothing behind it is
/// worse than no affordance.
/// - Otherwise: a button showing the current editor's display name, and — while open —
/// a popup of every registered type. Each carries a unique AccessKit label
/// (`"editor <pane>"` / `"editor <pane> <id>"`) **and** a published rect
/// (`buttons["editor"]` / `buttons["editor.<id>"]`), so a robot can drive the swap by
/// label or by position, exactly like every other chrome control.
fn draw_editor_picker(ctx: &mut Ctx, pane: &Pane, ui: &mut egui::Ui, buttons: &mut serde_json::Map<String, Value>) {
let Content::Area { editor: default_editor, allow } = &pane.content else { return };
let current = ctx.nav.editor_of(&pane.name).unwrap_or(default_editor).to_string();
let options = allow.filter(ctx.host.editors());
let label =
options.iter().find(|(id, _)| *id == current).map(|(_, n)| n.clone()).unwrap_or_else(|| current.clone());
if options.is_empty() {
let resp = ui.label(egui::RichText::new(&label).monospace());
record_button(buttons, "editor", resp.rect);
return;
}
// The dropdown indicator is `⏷` — the SAME glyph the fold toggle uses, deliberately:
// it is the one down-caret egui's bundled fonts actually carry
// (`chrome_glyphs_are_renderable` proves `▾`/`▴` are tofu here), and shipping a tofu
// box in every area header would be worse than reusing a caret.
let open = ctx.nav.editor_menu_open(&pane.name);
let btn = ui.add(egui::Button::new(format!("{label} {GLYPH_FOLD_OPEN}")).small());
label_button(&btn, "editor", &pane.name);
record_button(buttons, "editor", btn.rect);
if btn.clicked() {
ctx.nav.set_editor_menu(&pane.name, !open);
}
if !open {
return;
}
let mut chosen: Option<String> = None;
let mut opt_rects: Vec<(String, egui::Rect)> = Vec::new();
egui::Area::new(ui.id().with(("scene_editor_menu", &pane.name)))
.order(egui::Order::Foreground)
.fixed_pos(btn.rect.left_bottom())
.show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(btn.rect.width().max(120.0));
for (id, name) in &options {
let resp = ui.add(egui::Button::selectable(*id == current, name));
resp.widget_info(|| {
egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("editor {} = {id}", pane.name))
});
opt_rects.push((id.clone(), resp.rect));
if resp.clicked() {
chosen = Some(id.clone());
}
}
});
});
for (id, rect) in opt_rects {
record_button(buttons, &format!("editor.{id}"), rect);
}
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
ctx.nav.set_editor_menu(&pane.name, false);
}
if let Some(id) = chosen {
// The SWAP. Nothing about the pane's geometry is touched — its rect, its split
// ratio and its chrome state all survive, because none of them was ever keyed on
// the editor.
ctx.nav.set_editor(&pane.name, &id);
}
}
/// Give a chrome control a UNIQUE AccessKit label (`"<action> <pane>"`), so a robot can
/// `click_label("minimize body")` as well as click its published rect.
fn label_button(resp: &egui::Response, action: &str, pane: &str) {
// Built INSIDE the closure: egui only calls it when it actually needs the node, so a
// release frame with no AccessKit consumer allocates nothing here.
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("{action} {pane}")));
}
/// Publish a control's rect for rect-addressed robot clicks.
fn record_button(buttons: &mut serde_json::Map<String, Value>, key: &str, rect: egui::Rect) {
buttons.insert(key.to_string(), json!([rect.min.x, rect.min.y, rect.width(), rect.height()]));
}
fn layout_tag(l: &Layout) -> &'static str {
match l {
Layout::Flow => "flow",
Layout::Split { .. } => "split",
Layout::Grid { .. } => "grid",
Layout::Tabs { .. } => "tabs",
}
}
/// Draw a pane's content (its layout over children, or its slot widget).
fn draw_content(ctx: &mut Ctx, pane: &Pane, layout: &Layout, width: f32, bounded: bool, ui: &mut egui::Ui) {
match &pane.content {
Content::Slot(id) => {
// Captured BEFORE the slot draws: a slot that consumes its whole `Ui`
// leaves the cursor somewhere unhelpful to anchor a chip to.
let rect = ui.max_rect();
let component = ctx.host.component();
let state = with_overflow(pane, bounded, ui, |ui| ctx.host.slot(id, ui));
// The DEV-ID chip — ONE draw site, so every scene-hosted pane in every
// scene-based app gets it for free (LAW 5) instead of each app painting its
// own. No-op in release builds.
facett_core::devid::badge(ui, rect, component, id);
if !state.is_null() {
ctx.slots.insert(id.clone(), state);
}
}
Content::Area { editor: default_editor, .. } => {
// The LIVE binding wins over the authored default — that is what makes a
// swap a swap. Keyed by the pane NAME in `slots`, never by the editor id, so
// an area's observable state stays at a fixed address across every swap.
let editor = ctx.nav.editor_of(&pane.name).unwrap_or(default_editor).to_string();
let state = with_overflow(pane, bounded, ui, |ui| ctx.host.area(&pane.name, &editor, ui));
if !state.is_null() {
ctx.slots.insert(pane.name.clone(), state);
}
}
Content::Menubar => {
// The shell's menu bar, drawn INSIDE its pane's allotted rect through the
// same facett-menu surface the implicit top strip uses (L5 — one menu
// engine, two placements).
if !ctx.menubar.is_empty() {
let commands = menubar_commands(ctx.menubar);
let hit = with_overflow(pane, bounded, ui, |ui| facett_menu::menu_bar(ui, &commands));
if let Some(hit) = hit {
ctx.menu_hit = Some(hit);
}
}
}
Content::Body => {
// ── THE SPLICE. The active workspace's body tree is drawn HERE, inside the
// rect the shell allotted it. Everything above and below — menu bar,
// workspace bar, toolbar, status bar — was authored once and does not move.
let Some(ws) = ctx.workspace.cloned() else { return };
let root = ws.root.clone();
draw_pane(ctx, &root, width, true, ui);
}
Content::WorkspaceBar => {
// Blender's topbar tabs. Reuses the SAME TabView the scene's `Layout::Tabs`
// uses, with a label prefix so `"workspace data"` can never collide with a
// pane title of the same word.
let tabs: Vec<(String, String)> =
ctx.all_workspaces.iter().map(|w| (w.name.clone(), w.tab_label())).collect();
if tabs.is_empty() {
return;
}
let mut tv = TabView::new(tabs).label_prefix("workspace");
tv.select(&ctx.nav.active_workspace);
let picked = tv.show(ui);
ctx.workspace_tabs = tv.rects().iter().map(|(k, v)| (k.clone(), *v)).collect();
if let Some(name) = picked {
ctx.switch_workspace = Some(name);
}
}
Content::Children(kids) => draw_children(ctx, pane, layout, kids, width, ui),
}
}
/// **Lock 2 — the clip lock.** Draw a leaf's content inside its allotment: clip the
/// painter to the allotted rect (egui hands a child `Ui` its *parent's* clip rect, so a
/// leaf that outgrows its allotment otherwise paints straight over its sibling — the rect
/// assertion passes while the screen is wrong), and, when the resolved [`Overflow`] is
/// `Scroll`, put the content in a scroll area bounded by that rect.
fn with_overflow<R>(pane: &Pane, bounded: bool, ui: &mut egui::Ui, f: impl FnOnce(&mut egui::Ui) -> R) -> R {
let alloc = ui.available_rect_before_wrap();
match pane.overflow.resolve(bounded) {
// The explicit opt-out: the content sizes itself and the parent grows with it.
Overflow::Grow => f(ui),
Overflow::Scroll => {
ui.shrink_clip_rect(alloc);
egui::ScrollArea::both()
.id_salt(("scene_overflow", &pane.name))
.max_height(alloc.height())
.max_width(alloc.width())
.show(ui, f)
.inner
}
_ => {
ui.shrink_clip_rect(alloc);
f(ui)
}
}
}
/// Lay children out by `layout`, reusing facett-app primitives.
fn draw_children(ctx: &mut Ctx, pane: &Pane, layout: &Layout, kids: &[Pane], width: f32, ui: &mut egui::Ui) {
if kids.is_empty() {
return;
}
// While a maximise is in force, only the child on the path to the maximised pane is
// laid out; every sibling is recorded with a ZERO rect (it really was allotted
// nothing) so the "siblings shrink to zero area" assertion has geometry to read.
let max_child: Option<usize> =
if ctx.maximizing() { kids.iter().position(|k| ctx.on_max_path(&k.name)) } else { None };
match layout {
Layout::Flow => {
ui.horizontal_wrapped(|ui| {
for (i, k) in kids.iter().enumerate() {
if ctx.nav.is_absent(&k.name) || max_child.is_some_and(|m| i != m) {
let z = egui::Rect::from_min_size(ui.cursor().min, egui::Vec2::ZERO);
let by = ctx.hidden_reason(k);
ctx.record_hidden(k, z, by);
continue;
}
draw_pane(ctx, k, width, false, ui);
}
});
}
Layout::Split { axis, resizable, .. } => {
draw_split(ctx, pane, *axis, *resizable, kids, ui);
}
Layout::Grid { cols } => {
let cols = (*cols).max(1);
let cell_w = (width / cols as f32).max(1.0);
egui::Grid::new(ui.id().with(("scene_grid", &pane.name))).num_columns(cols).show(ui, |ui| {
for (i, k) in kids.iter().enumerate() {
if ctx.nav.is_absent(&k.name) || max_child.is_some_and(|m| i != m) {
let z = egui::Rect::from_min_size(ui.cursor().min, egui::Vec2::ZERO);
let by = ctx.hidden_reason(k);
ctx.record_hidden(k, z, by);
continue;
}
draw_pane(ctx, k, cell_w, false, ui);
if (i + 1) % cols == 0 {
ui.end_row();
}
}
});
}
Layout::Tabs { active } => {
// Reuse facett-app TabView for the header strip; the active index lives in
// NavState (the renderer reads it, a click mutates it). A maximise inside a
// tab forces that tab to the front — you cannot maximise what isn't shown.
// An ABSENT child does not exist: no header in the strip, no body — and a
// selection resting on it falls to the first present sibling (the active-tab
// index is NOT rewritten, so presence returning restores the exact tab).
let present: Vec<&Pane> = kids.iter().filter(|k| !ctx.nav.is_absent(&k.name)).collect();
for k in kids {
if !ctx.nav.is_absent(&k.name) {
continue;
}
let z = egui::Rect::from_min_size(ui.cursor().min, egui::Vec2::ZERO);
ctx.record_hidden(k, z, "absent");
}
if present.is_empty() {
return;
}
let mut sel = max_child
.unwrap_or_else(|| ctx.nav.active_tab.get(&pane.name).copied().unwrap_or(*active))
.min(kids.len() - 1);
if ctx.nav.is_absent(&kids[sel].name) {
sel = kids.iter().position(|k| !ctx.nav.is_absent(&k.name)).unwrap_or(0);
}
let mut tv = TabView::new(present.iter().map(|k| (k.name.clone(), k.header_title().to_string())))
.tooltips(present.iter().filter_map(|k| k.tooltip.clone().map(|t| (k.name.clone(), t))));
tv.select(&kids[sel].name);
let picked = tv.show(ui);
// Publish each header's on-screen rect (keyed by pane name) so a visible-window
// robot can travel to a tab and CLICK it by position — the SAME geometry the
// workspace strip already publishes as `workspace_tabs`.
for (id, r) in tv.rects() {
ctx.tab_rects.insert(id.clone(), *r);
}
if let Some(new_id) = picked && let Some(idx) = kids.iter().position(|k| k.name == new_id) {
ctx.nav.set_active_tab(&pane.name, idx);
return; // header changed this frame; draw the (new) active next frame
}
ui.separator();
draw_pane(ctx, &kids[sel], width, true, ui);
}
}
}
/// Lay an N-way [`Layout::Split`] out along `axis`, honouring the LIVE per-child ratios
/// in [`NavState`]. When `resizable`, a real drag handle sits between each adjacent pair;
/// dragging it re-apportions those two ratios (written back to `NavState`, so the drag is
/// observable in `state_json`). The handle rects are recorded for robot addressing.
///
/// **Chrome affects the APPORTIONMENT, never the stored ratios** — which is what makes
/// restore exact:
/// - a **minimised** child is allotted exactly [`CHROME_BAR`] points along the axis and
/// the remainder is re-apportioned among the rest by their unchanged ratios;
/// - while a **maximise** is in force, the child on the path to it takes the WHOLE
/// extent, every sibling is allotted zero (and recorded as such), and the dividers
/// stand down.
fn draw_split(ctx: &mut Ctx, pane: &Pane, axis: Axis, resizable: bool, kids: &[Pane], ui: &mut egui::Ui) {
let n = kids.len();
if n == 0 {
return;
}
let ratios = normalized_ratios(
&ctx.nav.split_ratio.get(&pane.name).cloned().unwrap_or_else(|| vec![1.0 / n as f32; n]),
n,
);
// Keep NavState seeded even if this split was authored without a pre-seed.
ctx.nav.split_ratio.entry(pane.name.clone()).or_insert_with(|| ratios.clone());
let avail = ui.available_rect_before_wrap();
let max_child: Option<usize> =
if ctx.maximizing() { kids.iter().position(|k| ctx.on_max_path(&k.name)) } else { None };
// No dividers while maximised — there is nothing left to apportion.
// A divider between two children only means something if BOTH can give ground: a
// FIXED row (the menu bar, the status bar) has no ratio to re-apportion, so no handle
// is drawn beside it — a handle that cannot move is a promise the layout will not keep.
let movable = |i: usize| kids.get(i).is_some_and(|k| k.fixed.is_none());
// …and a divider beside an ABSENT child stands down too: there is nothing on the
// other side of it to give ground, so a handle there would drag against a pane
// that does not exist.
let here = |i: usize| kids.get(i).is_some_and(|k| !ctx.nav.is_absent(&k.name));
let sep_at: Vec<bool> =
(0..n.saturating_sub(1)).map(|i| movable(i) && movable(i + 1) && here(i) && here(i + 1)).collect();
let sep_count = if resizable && max_child.is_none() { sep_at.iter().filter(|s| **s).count() } else { 0 };
let handle = if sep_count > 0 { SPLIT_HANDLE } else { 0.0 };
let draw_seps = resizable && max_child.is_none();
let mut new_ratios = ratios.clone();
let mut changed = false;
let mut seps: Vec<Value> = Vec::new();
match axis {
Axis::Horizontal => {
let total = (avail.width() - handle * sep_count as f32).max(1.0);
let extents = split_extents(ctx.nav, kids, &ratios, total, max_child);
let (top, h) = (avail.min.y, avail.height());
let mut x = avail.min.x;
for i in 0..n {
let w = extents[i];
let rect = egui::Rect::from_min_size(egui::pos2(x, top), egui::vec2(w, h));
if w <= 0.0 {
let by = ctx.hidden_reason(&kids[i]);
ctx.record_hidden(&kids[i], egui::Rect::from_min_size(egui::pos2(x, top), egui::vec2(0.0, h)), by);
continue;
}
ui.scope_builder(egui::UiBuilder::new().max_rect(rect).layout(egui::Layout::top_down(egui::Align::Min)), |ui| {
draw_pane(ctx, &kids[i], w, true, ui);
});
x += w;
if draw_seps && i + 1 < n && sep_at[i] {
let hrect = egui::Rect::from_min_size(egui::pos2(x, top), egui::vec2(handle, h));
let resp = ui.allocate_rect(hrect, egui::Sense::drag());
paint_handle(ui, hrect, true, resp.hovered(), resp.dragged());
if resp.hovered() || resp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
}
seps.push(json!([hrect.min.x, hrect.min.y, hrect.width(), hrect.height()]));
if resp.dragged() {
adjust_pair(&mut new_ratios, i, resp.drag_delta().x / total);
changed = true;
}
x += handle;
}
}
ui.allocate_rect(egui::Rect::from_min_max(avail.min, egui::pos2(x, top + h)), egui::Sense::hover());
}
Axis::Vertical => {
let total = (avail.height() - handle * sep_count as f32).max(1.0);
let extents = split_extents(ctx.nav, kids, &ratios, total, max_child);
let (left, w) = (avail.min.x, avail.width());
let mut y = avail.min.y;
for i in 0..n {
let ch = extents[i];
let rect = egui::Rect::from_min_size(egui::pos2(left, y), egui::vec2(w, ch));
if ch <= 0.0 {
let by = ctx.hidden_reason(&kids[i]);
ctx.record_hidden(&kids[i], egui::Rect::from_min_size(egui::pos2(left, y), egui::vec2(w, 0.0)), by);
continue;
}
ui.scope_builder(egui::UiBuilder::new().max_rect(rect).layout(egui::Layout::top_down(egui::Align::Min)), |ui| {
draw_pane(ctx, &kids[i], w, true, ui);
});
y += ch;
if draw_seps && i + 1 < n && sep_at[i] {
let hrect = egui::Rect::from_min_size(egui::pos2(left, y), egui::vec2(w, handle));
let resp = ui.allocate_rect(hrect, egui::Sense::drag());
paint_handle(ui, hrect, false, resp.hovered(), resp.dragged());
if resp.hovered() || resp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
}
seps.push(json!([hrect.min.x, hrect.min.y, hrect.width(), hrect.height()]));
if resp.dragged() {
adjust_pair(&mut new_ratios, i, resp.drag_delta().y / total);
changed = true;
}
y += handle;
}
}
ui.allocate_rect(egui::Rect::from_min_max(avail.min, egui::pos2(left + w, y)), egui::Sense::hover());
}
}
if !seps.is_empty() {
ctx.sep_rects.insert(pane.name.clone(), Value::Array(seps));
}
if changed {
ctx.nav.set_split_ratios(&pane.name, new_ratios);
}
}
/// Apportion `total` points among a split's children — **the whole minimise/maximise/
/// fixed-row geometry, in one pure function** (so it is unit-testable without a GUI in
/// the loop).
///
/// - `max_child = Some(k)` ⇒ child `k` takes all of `total` **except what the FIXED rows
/// keep**, and every other flexible child gets exactly `0.0` (RED-when-broken: siblings
/// measurably shrink to zero area). A fixed row — the menu bar, the workspace bar, the
/// status bar — is not a sibling competing for space; it is the shell. Maximising a
/// graph must not make the status bar vanish, for the same reason switching workspace
/// must not move the menu: shell chrome that comes and goes is chrome nobody can find.
/// Maximising the fixed row itself still gives it everything.
/// - otherwise, in order: each MINIMISED child takes exactly [`CHROME_BAR`] (its title
/// bar); each child with a [`Pane::fixed`](struct.Pane.html#structfield.fixed) extent
/// takes exactly that (the menu bar / toolbar / status bar rows); and the remainder is
/// shared among the rest **in their unchanged ratios** — which is why restoring
/// returns the exact prior layout, and why growing the body never moves the status bar.
fn split_extents(nav: &NavState, kids: &[Pane], ratios: &[f32], total: f32, max_child: Option<usize>) -> Vec<f32> {
let n = kids.len();
// An ABSENT child is allotted exactly nothing, whatever else is going on — not the
// CHROME_BAR a minimise keeps (there is no title bar to keep: the pane does not
// exist on screen) and no fixed extent either. Its share is re-apportioned among
// its siblings by their UNCHANGED ratios, the maximise discipline — which is what
// makes presence returning restore the exact prior layout.
let gone: Vec<bool> = kids.iter().map(|k| nav.is_absent(&k.name)).collect();
if let Some(k) = max_child {
let kept: Vec<Option<f32>> = (0..n)
.map(|i| {
if gone[i] {
Some(0.0)
} else if i == k {
None
} else {
kids[i].fixed.map(|f| f.clamp(0.0, total.max(1.0)))
}
})
.collect();
let fixed: f32 = kept.iter().flatten().sum();
let rest = (total - fixed).max(1.0);
return (0..n).map(|i| if i == k && !gone[i] { rest } else { kept[i].unwrap_or(0.0) }).collect();
}
let mini: Vec<bool> = kids.iter().map(|k| nav.is_minimized(&k.name)).collect();
let bar = CHROME_BAR.min(total / n as f32);
// Each child's non-negotiable extent, if it has one: absence wins over everything,
// a minimise wins over a fixed row (you asked for it to collapse), and a fixed row
// never exceeds the whole split.
let pinned: Vec<Option<f32>> = (0..n)
.map(|i| {
if gone[i] {
Some(0.0)
} else if mini[i] {
Some(bar)
} else {
kids[i].fixed.map(|f| f.clamp(0.0, total.max(1.0)))
}
})
.collect();
let fixed: f32 = pinned.iter().flatten().sum();
let flex_sum: f32 = ratios.iter().zip(&pinned).filter(|(_, p)| p.is_none()).map(|(r, _)| *r).sum();
let flex_total = (total - fixed).max(1.0);
let open = pinned.iter().filter(|p| p.is_none()).count().max(1) as f32;
(0..n)
.map(|i| match pinned[i] {
Some(p) => p,
None if flex_sum > f32::EPSILON => (flex_total * ratios[i] / flex_sum).max(1.0),
None => (flex_total / open).max(1.0),
})
.collect()
}
/// Move `delta` (a fraction of the split's total extent) from child `i+1` into child `i`
/// (or the reverse for a negative delta), clamping both to [`SPLIT_MIN`].
fn adjust_pair(ratios: &mut [f32], i: usize, delta: f32) {
if i + 1 >= ratios.len() {
return;
}
let pair = ratios[i] + ratios[i + 1];
let a = (ratios[i] + delta).clamp(SPLIT_MIN, pair - SPLIT_MIN);
ratios[i] = a;
ratios[i + 1] = pair - a;
}
/// Paint a split drag handle: a line down the gutter PLUS a three-dot grip at its
/// middle, brighter when hovered/active. The grip is what tells an operator at a glance
/// that the line is draggable — a hairline nobody recognises is a handle nobody grabs.
fn paint_handle(ui: &egui::Ui, rect: egui::Rect, horizontal: bool, hovered: bool, dragged: bool) {
let v = ui.visuals();
let color = if dragged {
v.selection.bg_fill
} else if hovered {
v.widgets.hovered.bg_fill
} else {
v.widgets.noninteractive.fg_stroke.color.gamma_multiply(0.55)
};
// A slim line centred in the handle gutter.
let bar = if horizontal { rect.shrink2(egui::vec2(rect.width() * 0.35, 0.0)) } else { rect.shrink2(egui::vec2(0.0, rect.height() * 0.35)) };
ui.painter().rect_filled(bar, 1.0, color);
// The grip: three dots at the centre of the gutter, the universal "drag me".
let c = rect.center();
let step = 4.0;
let r = if hovered || dragged { 1.6 } else { 1.2 };
for k in -1i32..=1 {
let p = if horizontal { egui::pos2(c.x, c.y + k as f32 * step) } else { egui::pos2(c.x + k as f32 * step, c.y) };
ui.painter().circle_filled(p, r, color);
}
}
#[cfg(test)]
#[path = "scene_tests.rs"]
mod scene_tests;