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

commitbe6aea179243133af15365dba453e2287c3024b6
parent9d02e3ca35
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-25 23:21
fix: hide Move To Own Plate when no dock is free

Four tab candidates share three docks, so a dock can hold two tabs with
none left empty; the corner menu offered the split anyway and the row did
nothing. first_empty_dock is now the one answer both the menu and
split_dock_tab read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 src/app.rs          | 22 +++++++++++++---------
 src/main.rs         | 40 ++++++++++++++++++++++++++++++++++++++++
 src/plate_corner.rs |  4 +++-
 3 files changed, 56 insertions(+), 10 deletions(-)

diff --git a/src/app.rs b/src/app.rs
index 248be24..4b7e8a4 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -3020,19 +3020,23 @@ impl State {
         self.after_dock_change();
     }
 
-    /// 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).
+    /// The dock a split would move a tab to: the first EMPTY one, Left,
+    /// Right, Bottom. There are four tab candidates for three docks, so two
+    /// docks can each hold two with none left free — the corner menu reads
+    /// this to leave Move To Own Plate out rather than offer a dead row.
+    pub fn first_empty_dock(&self) -> Option<Dock> {
+        [Dock::Left, Dock::Right, Dock::Bottom]
+            .into_iter()
+            .find(|&d| self.dock_tabs[d as usize].is_empty())
+    }
+
+    /// Move `slot` out of its shared dock to the first EMPTY dock — the
+    /// corner menu's inverse of Add Tab. No empty dock, no move.
     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;
-        };
+        let Some(empty) = self.first_empty_dock() else { return };
         self.add_dock_tab(empty, slot);
     }
 
diff --git a/src/main.rs b/src/main.rs
index b3aeabc..f3968f1 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1161,6 +1161,46 @@ mod tests {
         assert_eq!(state.pane_in_dock(Dock::Right), SPREADSHEET_IDX);
     }
 
+    /// Move To Own Plate is offered only while a dock is free to take the
+    /// pane. Four tab candidates share three docks, so a dock can hold two
+    /// with none empty — and there the row used to show and do nothing.
+    #[test]
+    fn move_to_own_plate_needs_an_empty_dock() {
+        use crate::app::Dock;
+        use crate::plate_corner::PlateMenuAction;
+        use crate::slots::{NETWORK_PANEL2_IDX, NETWORK_PANEL_IDX, PARAM_IDX, SPREADSHEET_IDX};
+        let mut state = State::new(false);
+        state.resize(1600.0, 900.0, 1.0);
+
+        // The second editor tabs in beside the first: left holds two, and
+        // params and spreadsheet keep the other two docks, so none is free.
+        state.add_dock_tab(Dock::Left, NETWORK_PANEL2_IDX);
+        state.show_dock_tab(Dock::Left, NETWORK_PANEL_IDX);
+        assert_eq!(state.first_empty_dock(), None);
+        state.open_plate_menu(NETWORK_PANEL_IDX);
+        assert!(!state.plate_menu_actions.contains(&PlateMenuAction::SplitTab),
+            "no dock is free, so the row is left out");
+        assert!(state.plate_menu_actions.contains(&PlateMenuAction::ShowTab(NETWORK_PANEL2_IDX)),
+            "the tab list itself still shows");
+        state.close_plate_menu();
+
+        // Pulling the spreadsheet in beside the params frees the bottom dock,
+        // and the row comes back — on both shared docks.
+        state.add_dock_tab(Dock::Right, SPREADSHEET_IDX);
+        state.show_dock_tab(Dock::Right, PARAM_IDX);
+        assert_eq!(state.first_empty_dock(), Some(Dock::Bottom));
+        for idx in [NETWORK_PANEL_IDX, PARAM_IDX] {
+            state.open_plate_menu(idx);
+            assert!(state.plate_menu_actions.contains(&PlateMenuAction::SplitTab),
+                "a shared dock with a free one offers the split");
+            state.close_plate_menu();
+        }
+
+        // And the split lands there.
+        state.split_dock_tab(PARAM_IDX);
+        assert_eq!(state.pane_in_dock(Dock::Bottom), PARAM_IDX);
+    }
+
     /// The second network editor: joins a dock from nowhere through the tab
     /// machinery, dives on its OWN path while the primary stays put, clamps
     /// a stale path instead of panicking, and Close removes it entirely.
diff --git a/src/plate_corner.rs b/src/plate_corner.rs
index 69535dc..00e8211 100644
--- a/src/plate_corner.rs
+++ b/src/plate_corner.rs
@@ -235,7 +235,9 @@ impl State {
                 options.push("Add Tab".to_string());
                 actions.push(PlateMenuAction::AddTabMenu);
             }
-            if self.dock_tabs[d as usize].len() > 1 {
+            // Only while a dock is free to take it: with none, the split
+            // has nowhere to go and the row would do nothing.
+            if self.dock_tabs[d as usize].len() > 1 && self.first_empty_dock().is_some() {
                 manage_row(&mut options, &mut actions);
                 options.push("Move To Own Plate".to_string());
                 actions.push(PlateMenuAction::SplitTab);