Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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;