facett-app/src/workspace.rs
//! **`workspace` — Blender's WORKSPACE TABS: named body arrangements inside ONE shell.**
//!
//! In Blender the top bar carries *Layout · Modeling · Shading · …*: each is a saved
//! screen arrangement over the same data, and switching returns you to exactly the
//! arrangement you left. The shell around them — the topbar, the status bar — does not
//! move.
//!
//! ## A workspace is NOT a [`Scene`](crate::scene::Scene)
//!
//! One `Scene` per app, N workspaces inside it. `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. The pane in the shell whose
//! content is [`Content::Body`](crate::scene::Content::Body) is where the active
//! workspace's `root` is spliced at render time.
//!
//! Two consequences, and they are the reason for this shape:
//!
//! 1. **Shell chrome cannot drift.** A change to the menu bar is made once, not once per
//! workspace. Seventeen copies of a menu bar is seventeen chances for the topbar to
//! move when you switch workspace — the exact Eclipse failure this model rejects.
//! 2. **Pane ADDRESSES are stable across a switch.** `shell.menubar`, `shell.statusbar`
//! and every path through the shell mean the same thing in every workspace, so a saved
//! route, a persisted layout key and a robot's `panes["shell.statusbar"]` do not shift
//! underneath the caller. Only the body subtree varies.
//!
//! ## 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.
//! [`Scene::validate`](crate::scene::Scene::validate) checks each workspace against the
//! shell independently.
//!
//! **So how does a persisted layout tell `main` in *investigate* from `main` in *data*?**
//! By where the entry is stored, not by the key. [`NavState`] holds the flat maps for
//! **shell ∪ the ACTIVE workspace's body** — which is exactly the set of panes on screen,
//! so nothing collides — and parks every *inactive* workspace's body state in
//! [`NavState::workspaces`], a `BTreeMap<workspace name, WorkspaceSlot>`. Switching parks
//! the outgoing body and un-parks the incoming one. In the layout file that reads:
//!
//! ```text
//! active_workspace: "investigate"
//! split_ratio: { "shell.root": …, "ws.root": … } ← shell ∪ investigate's body
//! workspaces: { "data": WorkspaceSlot { split_ratio: { "ws.root": … }, … } }
//! ```
//!
//! Unqualified inside a workspace, disambiguated by the workspace it is parked under.
//! Robot addresses stay unqualified, which is the whole point.
use crate::scene::{Content, NavState, Pane};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::BTreeMap;
/// **One named body arrangement** — a workspace tab.
///
/// `root` is the body tree (splits + areas) spliced into the shell's [`Content::Body`]
/// pane. `editors` seeds each area's default editor id, overriding what the area pane
/// itself declares — so one archetype tree can be reused across workspaces with different
/// editors mounted in it.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Workspace {
/// Unique id — the tab's stable handle, the persistence key, the switch target.
pub name: String,
/// The tab's display label (falls back to `name`).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub label: String,
/// Optional tab glyph, rendered before the label.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
/// The BODY tree spliced into the shell's [`Content::Body`] pane.
pub root: Pane,
/// Per-area default editor id overrides: area pane name → editor id.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub editors: BTreeMap<String, String>,
}
impl Workspace {
/// A workspace from an id, a label and its body tree.
pub fn new(name: impl Into<String>, label: impl Into<String>, root: Pane) -> Self {
Self { name: name.into(), label: label.into(), icon: None, root, editors: BTreeMap::new() }
}
/// Set the tab glyph (builder).
pub fn icon(mut self, icon: impl Into<String>) -> Self {
self.icon = Some(icon.into());
self
}
/// Override an area's default editor for THIS workspace (builder).
pub fn editor(mut self, area: impl Into<String>, editor: impl Into<String>) -> Self {
self.editors.insert(area.into(), editor.into());
self
}
/// The tab text: `"<icon> <label>"`, falling back to the name.
pub fn tab_label(&self) -> String {
let base = if self.label.is_empty() { &self.name } else { &self.label };
match &self.icon {
Some(i) => format!("{i} {base}"),
None => base.clone(),
}
}
/// Every pane name in this workspace's body, in preorder.
pub fn pane_names(&self) -> 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(&self.root, &mut out);
out
}
/// A body pane by name.
pub fn 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)
}
/// The path from this workspace's body root down to `name`, inclusive.
pub fn path_to(&self, 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) { Some(acc) } else { None }
}
/// The editor id area `pane` defaults to in this workspace: the workspace's own
/// override if it has one, else what the area pane itself declares.
pub fn default_editor(&self, pane: &str) -> Option<&str> {
if let Some(e) = self.editors.get(pane) {
return Some(e);
}
match self.pane(pane).map(|p| &p.content) {
Some(Content::Area { editor, .. }) => Some(editor.as_str()),
_ => None,
}
}
}
/// **The parked body state of a workspace that is not showing.**
///
/// The active workspace's body state lives in [`NavState`]'s own flat maps (alongside the
/// shell's), because those are exactly the panes on screen. Everything else is parked
/// here, keyed by workspace name — which is how two workspaces can both call a pane
/// `main` and still keep distinct persisted state.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct WorkspaceSlot {
/// Per-pane folded flags.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub folded: BTreeMap<String, bool>,
/// Per-pane minimised flags.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub minimized: BTreeMap<String, bool>,
/// The maximised body pane, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub maximized: Option<String>,
/// Per-tab-pane active child index.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub active_tab: BTreeMap<String, usize>,
/// Per-split live ratios.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub split_ratio: BTreeMap<String, Vec<f32>>,
/// Per-area editor binding.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub area_editor: BTreeMap<String, String>,
}
impl WorkspaceSlot {
/// Seed a slot from a workspace's authored tree — the state it starts life in.
pub fn seeded(ws: &Workspace) -> Self {
let (folded, minimized, active_tab, split_ratio, mut area_editor) = NavState::seed_maps(&ws.root);
for (area, editor) in &ws.editors {
if area_editor.contains_key(area) {
area_editor.insert(area.clone(), editor.clone());
}
}
Self { folded, minimized, maximized: None, active_tab, split_ratio, area_editor }
}
/// This slot as JSON.
pub fn state_json(&self) -> Value {
json!({
"folded": self.folded,
"minimized": self.minimized,
"maximized": self.maximized,
"active_tab": self.active_tab,
"split_ratio": self.split_ratio,
"area_editor": self.area_editor,
})
}
}
/// The height (points) of the workspace tab bar. Public so a test asserts the exact strip
/// geometry rather than a boolean.
pub const WORKSPACEBAR_H: f32 = 26.0;
#[cfg(test)]
#[path = "workspace_tests.rs"]
mod workspace_tests;