graphic design tool
git clone https://git.lucas.co/cce-designer.git
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;