git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commitd970c63302e310e33439cebb27c85d0026c111c7
parenta9fd543fbf
authorLucas Galante <[email protected]>
date2026-08-31 14:59
feat: plates support tabs, managed through the corner menus

A dock can now hold several panes as TABS: one dock rect, one active
pane laid out (dock_panes, always a member of the dock's new dock_tabs
list), the rest waiting. The plate corner menu manages them — 'Tab:
<name>' fronts a waiting sibling, 'Add Tab: <name>' pulls a pane in
from another dock (its old dock fronts its next tab or empties to
NO_PANE, rect reserved), 'Move To Own Plate' sends the active pane
back out to the empty dock. A corner-dot dock drag moves the whole
group. The arrangement rides ProjectViewState.dock_tabs (names, active
first), accepted on load only whole — three lists covering each docked
pane exactly once — so older or damaged saves keep the current layout
rather than loading half of one.

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/app.rs          | 84 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/main.rs         | 38 ++++++++++++++++++++++++
 src/plate_corner.rs | 43 +++++++++++++++++++++++++++
 src/project.rs      | 53 +++++++++++++++++++++++++++++++++
 4 files changed, 218 insertions(+)

diff --git a/src/app.rs b/src/app.rs
index 991c5d4..401c6d6 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -174,6 +174,12 @@ pub struct ProjectViewState {
     /// window size. None in older saves keeps the live positions.
     #[serde(default)]
     pub splitters: Option<(f32, f32)>,
+    /// The docks' tab groups, Left/Right/Bottom order, pane names with the
+    /// ACTIVE tab first. Empty (older saves) keeps the default one-pane-per-
+    /// dock arrangement; a list that does not name each docked pane exactly
+    /// once across the three groups is ignored the same way.
+    #[serde(default)]
+    pub dock_tabs: Vec<Vec<String>>,
 }
 
 fn default_camera() -> String {
@@ -770,6 +776,11 @@ pub enum Dock {
     Bottom,
 }
 
+/// `dock_panes` entry for a dock whose tabs were all pulled elsewhere: no
+/// slot index, so `pane_shown` reads it as hidden and the dock lays out
+/// nothing. Never a valid `positions[..]` index.
+pub const NO_PANE: usize = usize::MAX;
+
 #[derive(Clone, Copy, Debug, PartialEq)]
 pub enum AppDrag {
     NetworkResize { dir: ResizeDirection, start_rect: (f32, f32, f32, f32), start_mouse: (f32, f32) },
@@ -919,7 +930,15 @@ pub struct State {
     /// or drag-repositions-plate: `(slot, press_x, press_y)`.
     pub corner_press: Option<(usize, f32, f32)>,
     /// Dock occupancy, indexed Left/Right/Bottom. Swapped by dot drags.
+    /// With tabs this names each dock's ACTIVE pane — always a member of the
+    /// dock's `dock_tabs` list — or [`NO_PANE`] for a dock whose tabs were
+    /// all pulled elsewhere.
     pub dock_panes: [usize; 3],
+    /// The panes tabbed into each dock, indexed Left/Right/Bottom. One dock
+    /// rect, several panes: only the active one (`dock_panes`) is laid out;
+    /// the rest wait as tabs, switched and moved through the plate corner
+    /// menus. Every docked pane lives in exactly ONE dock's list.
+    pub dock_tabs: [Vec<usize>; 3],
     /// The dock a live DockDrag would drop into — the render pass highlights it.
     pub dock_drag_target: Option<Dock>,
 
@@ -1491,17 +1510,77 @@ impl State {
 
     /// Move a plate to a dock, swapping with the pane that held it. The
     /// dock-owned dimensions stay put, so the geometry survives the swap.
+    /// With tabs, the whole GROUPS trade places — a dot drag moves the
+    /// plate and every tab riding it, exactly what the drag shows moving.
     pub fn move_pane_to_dock(&mut self, slot: usize, dock: Dock) {
         let Some(from) = self.dock_of_pane(slot) else { return };
         if from == dock {
             return;
         }
         self.dock_panes.swap(from as usize, dock as usize);
+        self.dock_tabs.swap(from as usize, dock as usize);
         self.rebuild_positions();
         self.apply_layout();
         self.read_panel_offsets();
     }
 
+    /// The dock whose TAB LIST holds `slot` — its home whether or not it is
+    /// the active tab there ([`Self::dock_of_pane`] finds only actives).
+    pub fn tab_dock_of_pane(&self, slot: usize) -> Option<Dock> {
+        [Dock::Left, Dock::Right, Dock::Bottom]
+            .into_iter()
+            .find(|&d| self.dock_tabs[d as usize].contains(&slot))
+    }
+
+    /// Bring one of a dock's tabs to the front (the corner menu's tab switch).
+    pub fn show_dock_tab(&mut self, dock: Dock, slot: usize) {
+        if !self.dock_tabs[dock as usize].contains(&slot) || self.dock_panes[dock as usize] == slot {
+            return;
+        }
+        self.dock_panes[dock as usize] = slot;
+        self.rebuild_positions();
+        self.apply_layout();
+        self.read_panel_offsets();
+    }
+
+    /// Pull `slot` out of its current dock and tab it into `dock`, active.
+    /// The dock it leaves fronts its next remaining tab, or empties
+    /// ([`NO_PANE`]) — its rect stays reserved by the dock-owned dimensions
+    /// either way, ready for a tab to move back.
+    pub fn add_dock_tab(&mut self, dock: Dock, slot: usize) {
+        let Some(from) = self.tab_dock_of_pane(slot) else { return };
+        if from == dock {
+            self.show_dock_tab(dock, slot);
+            return;
+        }
+        let f = from as usize;
+        self.dock_tabs[f].retain(|&s| s != slot);
+        if self.dock_panes[f] == slot {
+            self.dock_panes[f] = self.dock_tabs[f].first().copied().unwrap_or(NO_PANE);
+        }
+        self.dock_tabs[dock as usize].push(slot);
+        self.dock_panes[dock as usize] = slot;
+        self.rebuild_positions();
+        self.apply_layout();
+        self.read_panel_offsets();
+    }
+
+    /// Move the active tab of `slot`'s dock out to the first EMPTY dock —
+    /// the corner menu's inverse of Add Tab. No empty dock, no move (with
+    /// three panes on three docks, one is empty whenever any dock holds two).
+    pub fn split_dock_tab(&mut self, slot: usize) {
+        if self.tab_dock_of_pane(slot).is_none() {
+            return;
+        }
+        let Some(empty) = [Dock::Left, Dock::Right, Dock::Bottom]
+            .into_iter()
+            .find(|&d| self.dock_tabs[d as usize].is_empty())
+        else {
+            return;
+        };
+        self.add_dock_tab(empty, slot);
+    }
+
     pub fn floating_spreadsheet_rect(&self) -> (f32, f32, f32, f32) {
         let gap = 18.0_f32;
         let fx = gap;
@@ -3246,6 +3325,11 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             collapsed_panes: [false; WIDGET_COUNT],
             corner_press: None,
             dock_panes: [NETWORK_PANEL_IDX, PARAM_IDX, SPREADSHEET_IDX],
+            dock_tabs: [
+                vec![NETWORK_PANEL_IDX],
+                vec![PARAM_IDX],
+                vec![SPREADSHEET_IDX],
+            ],
             dock_drag_target: None,
             drag_widget: None,
             drag_press_cursor: None,
diff --git a/src/main.rs b/src/main.rs
index aae1c3a..9fe2672 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -627,6 +627,44 @@ mod tests {
         assert_eq!(m.match_action(&ctrl_shift, &lower), Some(Action::SaveAs));
     }
 
+    /// Plates support tabs: a pane pulled into another dock rides it as a
+    /// tab (one laid out, the rest waiting), switching fronts a waiting tab,
+    /// splitting moves the active one back out to the empty dock, and the
+    /// arrangement rides the project view state active-first.
+    #[test]
+    fn test_plate_tabs_share_a_dock() {
+        use crate::app::{Dock, NO_PANE};
+        use crate::slots::{PARAM_IDX, SPREADSHEET_IDX};
+        let mut state = State::new(false);
+
+        // Pull the spreadsheet into the right dock: it fronts, the params
+        // wait as a tab, and the bottom dock empties.
+        state.add_dock_tab(Dock::Right, SPREADSHEET_IDX);
+        assert_eq!(state.pane_in_dock(Dock::Right), SPREADSHEET_IDX);
+        assert!(state.dock_tabs[Dock::Right as usize].contains(&PARAM_IDX));
+        assert_eq!(state.pane_in_dock(Dock::Bottom), NO_PANE);
+        // The waiting tab is laid out nowhere but keeps its home dock.
+        assert_eq!(state.dock_of_pane(PARAM_IDX), None);
+        assert_eq!(state.tab_dock_of_pane(PARAM_IDX), Some(Dock::Right));
+
+        // Switching fronts the waiting tab without evicting the other.
+        state.show_dock_tab(Dock::Right, PARAM_IDX);
+        assert_eq!(state.pane_in_dock(Dock::Right), PARAM_IDX);
+        assert!(state.dock_tabs[Dock::Right as usize].contains(&SPREADSHEET_IDX));
+
+        // The view state carries the groups, active first.
+        let vs = state.project_view_state();
+        assert_eq!(vs.dock_tabs[1][0], "parameters");
+        assert!(vs.dock_tabs[1].contains(&"spreadsheet".to_string()));
+        assert!(vs.dock_tabs[2].is_empty());
+
+        // Splitting moves the active pane to the empty dock; the tab left
+        // behind fronts.
+        state.split_dock_tab(PARAM_IDX);
+        assert_eq!(state.pane_in_dock(Dock::Bottom), PARAM_IDX);
+        assert_eq!(state.pane_in_dock(Dock::Right), SPREADSHEET_IDX);
+    }
+
     /// Pane state rides save files: visibility through the meta→View subnet
     /// params (synced at save, applied on load), collapse and splitter
     /// proportions through view_state. A fresh State loading the file must
diff --git a/src/plate_corner.rs b/src/plate_corner.rs
index f564ddc..56a12d4 100644
--- a/src/plate_corner.rs
+++ b/src/plate_corner.rs
@@ -42,6 +42,12 @@ pub enum PlateMenuAction {
     FullWidth,
     /// Spreadsheet: back to the strip between the network and params panes.
     BetweenPanes,
+    /// Bring this dock's named tab to the front.
+    ShowTab(usize),
+    /// Pull the named pane out of its dock and tab it into this one, active.
+    AddTab(usize),
+    /// Move this pane out of its shared dock into the first empty one.
+    SplitTab,
 }
 
 impl State {
@@ -140,6 +146,32 @@ impl State {
             }
         }
 
+        // Tabs — only on docked plates (the playbar's strip is not a dock).
+        // The dock's other tabs switch to the front; panes docked elsewhere
+        // can be pulled in as tabs; a pane sharing its dock can move back
+        // out to the empty dock its arrival left behind.
+        if let Some(d) = self.dock_of_pane(idx) {
+            for &t in &self.dock_tabs[d as usize] {
+                if t != idx {
+                    options.push(format!("Tab: {}", plate_title(t)));
+                    actions.push(PlateMenuAction::ShowTab(t));
+                }
+            }
+            for other in [NETWORK_PANEL_IDX, PARAM_IDX, SPREADSHEET_IDX] {
+                if other != idx
+                    && !self.dock_tabs[d as usize].contains(&other)
+                    && !self.pane_is_detached(other)
+                {
+                    options.push(format!("Add Tab: {}", plate_title(other)));
+                    actions.push(PlateMenuAction::AddTab(other));
+                }
+            }
+            if self.dock_tabs[d as usize].len() > 1 {
+                options.push("Move To Own Plate".to_string());
+                actions.push(PlateMenuAction::SplitTab);
+            }
+        }
+
         let target = self.slots.get_dyn(idx).base().id();
         cce_ui::widget::context_menu::show(cx - CORNER_R, cy + CORNER_R, options, 0, target);
         self.plate_menu_slot = Some(idx);
@@ -184,6 +216,17 @@ impl State {
             PlateMenuAction::Reattach => self.reattach_plate(idx),
             PlateMenuAction::FullWidth => self.set_spreadsheet_full_width(true),
             PlateMenuAction::BetweenPanes => self.set_spreadsheet_full_width(false),
+            PlateMenuAction::ShowTab(t) => {
+                if let Some(d) = self.dock_of_pane(idx) {
+                    self.show_dock_tab(d, t);
+                }
+            }
+            PlateMenuAction::AddTab(o) => {
+                if let Some(d) = self.dock_of_pane(idx) {
+                    self.add_dock_tab(d, o);
+                }
+            }
+            PlateMenuAction::SplitTab => self.split_dock_tab(idx),
         }
     }
 
diff --git a/src/project.rs b/src/project.rs
index 46bfb4c..927efd3 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -99,6 +99,25 @@ impl State {
         } else {
             None
         };
+        // Each dock's tabs by name, active first — the order the loader
+        // reads back (first = front).
+        let dock_tabs = (0..3)
+            .map(|d| {
+                let active = self.dock_panes[d];
+                let mut names: Vec<String> = Vec::new();
+                if let Some(n) = crate::plate_corner::pane_name_from_slot(active) {
+                    names.push(n.to_string());
+                }
+                for &t in &self.dock_tabs[d] {
+                    if t != active {
+                        if let Some(n) = crate::plate_corner::pane_name_from_slot(t) {
+                            names.push(n.to_string());
+                        }
+                    }
+                }
+                names
+            })
+            .collect();
         ProjectViewState {
             active_camera: self.active_camera.clone(),
             pan: (self.pan_x, self.pan_y),
@@ -106,6 +125,7 @@ impl State {
             selected_node: self.graph().selected_node(),
             collapsed_panes,
             splitters,
+            dock_tabs,
         }
     }
 
@@ -200,6 +220,39 @@ impl State {
                 .map_or(false, |n| vs.collapsed_panes.iter().any(|c| c == n));
             self.set_pane_collapsed(idx, desired);
         }
+        // Dock tab groups: accepted only whole — three lists whose names
+        // resolve and cover each docked pane exactly once. Anything else
+        // (older saves' empty list included) keeps the current arrangement
+        // rather than loading half a layout.
+        if vs.dock_tabs.len() == 3 {
+            let resolved: Vec<Vec<usize>> = vs
+                .dock_tabs
+                .iter()
+                .map(|names| {
+                    names
+                        .iter()
+                        .filter_map(|n| crate::plate_corner::pane_slot_from_name(n))
+                        .collect()
+                })
+                .collect();
+            let mut all: Vec<usize> = resolved.iter().flatten().copied().collect();
+            all.sort_unstable();
+            let mut expected = vec![
+                crate::slots::NETWORK_PANEL_IDX,
+                crate::slots::PARAM_IDX,
+                crate::slots::SPREADSHEET_IDX,
+            ];
+            expected.sort_unstable();
+            if all == expected {
+                for d in 0..3 {
+                    self.dock_tabs[d] = resolved[d].clone();
+                    self.dock_panes[d] =
+                        resolved[d].first().copied().unwrap_or(crate::app::NO_PANE);
+                }
+                self.rebuild_positions();
+                self.apply_layout();
+            }
+        }
         if let Some((f1, f2)) = vs.splitters {
             if self.width > 1.0 && f1 > 0.02 && f2 < 0.98 && f1 < f2 {
                 self.splitter_layout.splitter1_x = f1 * self.width;