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

commitab38a273f731cf5c144eadf7411b330fe97ecdb0
parentf06adeb9c1
authorLucas Galante <[email protected]>
date2026-08-23 13:08
feat: reattach a detached pane from the parent's corner menu

Detaching used to hide the pane in the parent, which left nothing to click:
the pane was gone and the only window that knew about it was the child. The
parent now keeps a STUB for each pane it handed out, labelled "<pane> —
detached" so it cannot be mistaken for a collapsed one, and that stub's corner
menu offers Reattach and nothing else. Reattach closes the child window and
puts the pane back.

The stub also has to be exempt from the corner control's minimum-span guard —
the same trap collapse hit, and the same test shape caught it again.

A window the user closes themselves is reclaimed too, from the frame tick. That
poll uses `Child::try_wait`, NOT `kill(pid, 0)`: the child is ours and unreaped,
so once it exits it is a ZOMBIE — still in the process table, so the signal
probe reports a closed window as running forever and the pane is stranded as a
stub nothing can revive. Live testing found this; the first unit test missed it
because it reaped the child itself before probing, which is the one case the
broken probe gets right. The test now hands the poll a live child and makes it
do the reaping.

set_pane_detached joins set_pane_collapsed on the MCP surface, which is how all
of the above was driven headlessly.

 CLAUDE.md           |   7 +-
 src/api.rs          |  12 ++++
 src/app.rs          |  33 ++++++++--
 src/main.rs         |  75 +++++++++++++++++++++-
 src/plate_corner.rs | 181 +++++++++++++++++++++++++++++++++++++++++++++-------
 src/render.rs       |   4 +-
 src/window.rs       |  12 ++++
 7 files changed, 291 insertions(+), 33 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 2cb58b2..e0c0b3c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -44,7 +44,12 @@ kernels and need a working OpenCL runtime; they are not pure-CPU tests.
   and no 3D canvas; `--detached-network` stays its own flag because that window is
   CIRCULAR, with a radial border resize no rectangular pane wants. All of them share
   the one `default_project.json` sync channel, and only the main window runs the MCP
-  server.
+  server. The parent keeps a stub for each pane it handed out — that stub's corner
+  control is the only way to Reattach — and reaps its children with `try_wait` from
+  the frame tick, so a window the user closes hands its pane back. NOT `kill(pid, 0)`:
+  an unreaped exited child is a zombie, which that probe calls alive forever.
+  Note that detaching REWRITES `default_project.json` in the source tree, since that
+  file is the sync channel; it is versioned, so check `git status` after testing.
 
 ### MCP automation server
 
diff --git a/src/api.rs b/src/api.rs
index a4eb687..356fe62 100644
--- a/src/api.rs
+++ b/src/api.rs
@@ -186,6 +186,18 @@ pub(crate) fn mcp_tools() -> Vec<McpTool> {
                 "required": ["pane", "collapsed"],
             }),
         ),
+        tool(
+            "set_pane_detached",
+            "Move a pane into its own window, or take it back.",
+            json!({
+                "type": "object",
+                "properties": {
+                    "pane": { "type": "string", "description": "network | parameters | spreadsheet | playbar" },
+                    "detached": { "type": "boolean", "description": "true to detach, false to reattach" },
+                },
+                "required": ["pane", "detached"],
+            }),
+        ),
         tool(
             "menu_click",
             "Click a menubar item by indices (widget_idx must be a menubar widget slot).",
diff --git a/src/app.rs b/src/app.rs
index a3fbc3b..ba7b338 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -213,6 +213,9 @@ pub enum McpAction {
     /// Collapse a pane to its title stub, or restore it — the plate corner
     /// menu's Collapse/Expand, reachable without driving the pointer.
     SetPaneCollapsed { pane: String, collapsed: bool },
+    /// Move a pane out into its own window, or take it back — the corner menu's
+    /// Detach/Reattach.
+    SetPaneDetached { pane: String, detached: bool },
 }
 
 #[derive(Debug, Clone)]
@@ -737,9 +740,18 @@ pub struct State {
     /// border resize and custom CSD that no rectangular pane wants.
     pub detached_pane: Option<usize>,
     /// The MAIN window's record of which panes it has handed to a detached
-    /// window, so it stops laying them out. `detached_circular_network` is the
+    /// window, so it lays them out as stubs. `detached_circular_network` is the
     /// network's equivalent.
     pub detached_panes: [bool; WIDGET_COUNT],
+    /// The detached child PROCESS per pane — so Reattach can close the window it
+    /// is taking the pane back from, and so the parent can notice a child the
+    /// user closed themselves and take the pane back on its own.
+    ///
+    /// The handle, not a bare pid: an exited child the parent never waits on is
+    /// a ZOMBIE, and `kill(pid, 0)` succeeds for zombies — a pid-based liveness
+    /// probe reports a closed window as still running, forever. `try_wait`
+    /// reaps and reports for real.
+    pub detached_children: std::collections::HashMap<usize, std::process::Child>,
     pub last_project_mod_time: Option<std::time::SystemTime>,
     pub last_project_check: std::time::Instant,
     pub needs_autosave: bool,
@@ -2677,6 +2689,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             detached_circular_network: false,
             detached_pane: None,
             detached_panes: [false; WIDGET_COUNT],
+            detached_children: std::collections::HashMap::new(),
             last_project_mod_time: {
                 let default_proj_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("default_project.json");
                 std::fs::metadata(&default_proj_path).and_then(|m| m.modified()).ok()
@@ -3554,9 +3567,17 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                 self.menu_mut(HEADER_IDX).set_item_checked(2, 3, val);
 
                 if self.detached_circular_network {
-                    let _ = std::process::Command::new(std::env::current_exe().unwrap())
+                    match std::process::Command::new(std::env::current_exe().unwrap())
                         .arg("--detached-network")
-                        .spawn();
+                        .spawn()
+                    {
+                        Ok(child) => {
+                            self.detached_children.insert(NETWORK_PANEL_IDX, child);
+                        }
+                        Err(e) => eprintln!("Failed to spawn detached network: {e:?}"),
+                    }
+                } else {
+                    self.detached_children.remove(&NETWORK_PANEL_IDX);
                 }
 
                 self.rebuild_positions();
@@ -4931,6 +4952,10 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
         let now = Instant::now();
         self.last_frame = now;
 
+        // A detached window the user closed hands its pane back here, so a
+        // closed window cannot strand the pane as a stub nothing can revive.
+        let reclaimed = self.poll_detached_children();
+
         if now.duration_since(self.last_config_read).as_secs_f32() > 2.0 {
             self.last_config_read = now;
             let config_paths = [
@@ -5137,7 +5162,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             self.read_panel_offsets();
         }
 
-        tick_changed || panned
+        tick_changed || panned || reclaimed
     }
 
     /// Flush CPU-staged mesh updates to the renderer's persistent meshes.
diff --git a/src/main.rs b/src/main.rs
index d2332b5..7076fed 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -151,17 +151,88 @@ mod tests {
         assert_eq!(w, 600.0 - 2.0 * DETACHED_MARGIN);
         assert_eq!(h, 400.0 - 2.0 * DETACHED_MARGIN);
 
-        // Parent: the window that handed the pane out.
+        // Parent: the window that handed the pane out keeps a STUB, because the
+        // stub carries the corner control that is the only way to reattach.
+        use crate::plate_corner::STUB_H;
         let mut parent = State::new(false);
         parent.resize(1600.0, 900.0, 1.0);
         assert!(parent.slots.get_dyn(PARAM_IDX).visible(), "params starts in the parent");
+        let (_, _, _, full_h) = parent.slots.get_dyn(PARAM_IDX).rect();
+
         parent.detached_panes[PARAM_IDX] = true;
         parent.rebuild_positions();
         parent.apply_layout();
-        assert!(!parent.slots.get_dyn(PARAM_IDX).visible(), "params still laid out after detaching");
+
+        let (_, _, _, stub_h) = parent.slots.get_dyn(PARAM_IDX).rect();
+        assert!(full_h > stub_h, "detaching did not shrink the pane in the parent");
+        assert_eq!(stub_h, STUB_H, "the parent's leftover is not a stub");
+        assert!(parent.plate_corner_center(PARAM_IDX).is_some(),
+            "the stub has no corner control — nothing can reattach the pane");
+        // Collapsed and detached stubs must not read the same.
+        let label = parent.pane_stub_label(PARAM_IDX).expect("a detached pane is stubbed");
+        assert!(label.contains("detached"), "stub does not say the pane is detached: {label}");
         assert!(parent.slots.get_dyn(VIEWPORT_IDX).visible(), "the rest of the parent survived");
     }
 
+    /// The way back. A detached pane's menu offers Reattach and nothing else,
+    /// and reattaching restores the pane in full.
+    #[test]
+    fn test_reattach_brings_a_detached_pane_back() {
+        use crate::plate_corner::PlateMenuAction;
+        use crate::slots::PARAM_IDX;
+        let mut state = State::new(false);
+        state.resize(1600.0, 900.0, 1.0);
+        let (_, _, _, full_h) = state.slots.get_dyn(PARAM_IDX).rect();
+
+        state.detached_panes[PARAM_IDX] = true;
+        state.rebuild_positions();
+        state.apply_layout();
+
+        state.open_plate_menu(PARAM_IDX);
+        assert_eq!(state.plate_menu_actions, vec![PlateMenuAction::Reattach],
+            "a detached pane must offer Reattach and only Reattach");
+        state.close_plate_menu();
+
+        state.reattach_plate(PARAM_IDX);
+        assert!(!state.pane_is_detached(PARAM_IDX), "still marked detached after reattach");
+        assert!(state.pane_stub_label(PARAM_IDX).is_none(), "still a stub after reattach");
+        let (_, _, _, back_h) = state.slots.get_dyn(PARAM_IDX).rect();
+        assert_eq!(back_h, full_h, "reattach did not restore the pane height");
+    }
+
+    /// A detached window the user closes themselves must not strand its pane as
+    /// a stub the parent thinks is still elsewhere. Uses a REAL child, reaped
+    /// before the poll, so the liveness probe is the one that runs in the app.
+    #[test]
+    fn test_parent_reclaims_a_pane_whose_window_exited() {
+        use crate::slots::SPREADSHEET_IDX;
+        let mut state = State::new(false);
+        state.resize(1600.0, 900.0, 1.0);
+
+        // Deliberately NOT reaped here: an unreaped exited child is a zombie,
+        // which is exactly the state a closed window leaves behind. A pid-based
+        // `kill(pid, 0)` probe calls a zombie alive and never reclaims the pane
+        // — this test only bites if the poll reaps for itself.
+        let child = std::process::Command::new("true").spawn().expect("spawn a short-lived child");
+        state.detached_children.insert(SPREADSHEET_IDX, child);
+        state.detached_panes[SPREADSHEET_IDX] = true;
+        state.rebuild_positions();
+        state.apply_layout();
+        assert!(state.pane_is_detached(SPREADSHEET_IDX));
+
+        let mut reclaimed = false;
+        for _ in 0..200 {
+            if state.poll_detached_children() {
+                reclaimed = true;
+                break;
+            }
+            std::thread::sleep(std::time::Duration::from_millis(10));
+        }
+        assert!(reclaimed, "poll never noticed the exited child");
+        assert!(!state.pane_is_detached(SPREADSHEET_IDX), "pane stayed detached after its window exited");
+        assert!(!state.detached_children.contains_key(&SPREADSHEET_IDX), "stale child handle kept");
+    }
+
     /// Detach must not be offered where it cannot work: twice for one pane, or
     /// from inside a detached window (which would fork the pane again).
     #[test]
diff --git a/src/plate_corner.rs b/src/plate_corner.rs
index a186a05..7688f4a 100644
--- a/src/plate_corner.rs
+++ b/src/plate_corner.rs
@@ -39,6 +39,8 @@ pub enum PlateMenuAction {
     Expand,
     /// Move the pane out into its own window.
     Detach,
+    /// Take a detached pane back, closing the window that held it.
+    Reattach,
 }
 
 impl State {
@@ -68,10 +70,11 @@ impl State {
         if pw < MIN_PLATE_SPAN {
             return None;
         }
-        if self.collapsed_panes[idx] {
-            // The stub is BUILT to carry the control, and is shorter than the
+        if self.pane_is_stubbed(idx) {
+            // A stub is BUILT to carry the control, and is shorter than the
             // minimum span a full pane must clear — applying that guard here
-            // deleted the only control that can expand the pane again.
+            // deleted the only control that can bring the pane back, whether it
+            // was collapsed or detached.
             return Some((x + pw - CORNER_INSET, y + ph / 2.0));
         }
         if ph < MIN_PLATE_SPAN {
@@ -114,6 +117,22 @@ impl State {
         let mut options: Vec<String> = Vec::new();
         let mut actions: Vec<PlateMenuAction> = Vec::new();
 
+        // A detached pane lives in another window: collapsing the stub it left
+        // behind would mean nothing, so the only thing to offer is taking it back.
+        if self.pane_is_detached(idx) {
+            let target = self.slots.get_dyn(idx).base().id();
+            cce_ui::widget::context_menu::show(
+                cx - CORNER_R,
+                cy + CORNER_R,
+                vec!["Reattach".to_string()],
+                0,
+                target,
+            );
+            self.plate_menu_slot = Some(idx);
+            self.plate_menu_actions = vec![PlateMenuAction::Reattach];
+            return;
+        }
+
         if self.collapsed_panes[idx] {
             options.push("Expand".to_string());
             actions.push(PlateMenuAction::Expand);
@@ -168,6 +187,7 @@ impl State {
             PlateMenuAction::Collapse => self.set_pane_collapsed(idx, true),
             PlateMenuAction::Expand => self.set_pane_collapsed(idx, false),
             PlateMenuAction::Detach => self.detach_plate(idx),
+            PlateMenuAction::Reattach => self.reattach_plate(idx),
         }
     }
 
@@ -216,7 +236,8 @@ impl State {
 
         match std::env::current_exe() {
             Ok(exe) => match std::process::Command::new(exe).arg(flag).spawn() {
-                Ok(_) => {
+                Ok(child) => {
+                    self.detached_children.insert(idx, child);
                     self.detached_panes[idx] = true;
                     self.rebuild_positions();
                     self.apply_layout();
@@ -253,19 +274,7 @@ impl State {
             if !self.collapsed_panes[idx] {
                 continue;
             }
-            let (x, y, w, h) = self.positions[idx];
-            if w <= 0.0 || h <= 0.0 {
-                // Already laid out as hidden — collapse has nothing to say.
-                continue;
-            }
-            self.positions[idx] = (x, y, w, STUB_H.min(h));
-
-            if idx == NETWORK_PANEL_IDX {
-                for child in [crate::slots::CONTENT_IDX, crate::slots::BREADCRUMB_IDX] {
-                    self.positions[child] = (0.0, 0.0, 0.0, 0.0);
-                    self.slots.get_dyn_mut(child).set_visible(false);
-                }
-            }
+            self.stub_slot(idx);
         }
     }
 
@@ -338,18 +347,142 @@ impl State {
             return;
         }
 
+        // The parent keeps a STUB for each pane it handed out rather than
+        // dropping it: the stub carries the corner control, which is the only
+        // way back. Hiding the pane outright left no way to reattach it.
+        for idx in PLATE_SLOTS {
+            if self.detached_panes[idx] {
+                self.stub_slot(idx);
+            }
+        }
+    }
+
+    /// Shrink one slot to its title stub, taking any separate body slots with it.
+    /// Shared by collapse and by the parent side of a detach.
+    fn stub_slot(&mut self, idx: usize) {
+        let (x, y, w, h) = self.positions[idx];
+        if w <= 0.0 || h <= 0.0 {
+            // Already laid out as hidden — there is no stub to make.
+            return;
+        }
+        self.positions[idx] = (x, y, w, STUB_H.min(h));
+        if idx == NETWORK_PANEL_IDX {
+            for child in [crate::slots::CONTENT_IDX, crate::slots::BREADCRUMB_IDX] {
+                self.positions[child] = (0.0, 0.0, 0.0, 0.0);
+                self.slots.get_dyn_mut(child).set_visible(false);
+            }
+        }
+    }
+}
+
+impl State {
+    /// Is this pane currently living in a detached window? The network's flag
+    /// is separate because its detached window is the circular one.
+    pub fn pane_is_detached(&self, idx: usize) -> bool {
+        if idx == NETWORK_PANEL_IDX {
+            return self.detached_circular_network;
+        }
+        PLATE_SLOTS.contains(&idx) && self.detached_panes[idx]
+    }
+
+    /// Is this pane drawn as a stub rather than in full — collapsed, or left
+    /// behind by a detach?
+    pub fn pane_is_stubbed(&self, idx: usize) -> bool {
+        self.pane_is_detached(idx) || self.pane_is_collapsed(idx)
+    }
+
+    /// The label a stubbed pane shows, or `None` when the pane is drawn in full.
+    /// Collapsed and detached both stub, and they must not look alike: one is
+    /// one click from expanding, the other is somewhere else entirely.
+    pub fn pane_stub_label(&self, idx: usize) -> Option<String> {
+        if self.pane_is_detached(idx) {
+            Some(format!("{} — detached", plate_title(idx)))
+        } else if self.pane_is_collapsed(idx) {
+            Some(plate_title(idx).to_string())
+        } else {
+            None
+        }
+    }
+
+    /// Detach or reattach a pane — the corner menu's two window actions, also
+    /// the MCP surface's, so pane placement is scriptable like collapse is.
+    pub fn set_pane_detached(&mut self, idx: usize, detached: bool) {
+        if detached {
+            if self.plate_can_detach(idx) {
+                self.detach_plate(idx);
+            }
+        } else {
+            self.reattach_plate(idx);
+        }
+    }
+
+    /// Take a detached pane back and close the window that held it.
+    pub fn reattach_plate(&mut self, idx: usize) {
+        if !self.pane_is_detached(idx) {
+            return;
+        }
+        self.close_detached_child(idx);
+
+        if idx == NETWORK_PANEL_IDX {
+            // Toggling the action back off is the network's own reattach — it
+            // clears the flag and re-lays out without spawning anything.
+            self.execute_action(crate::shortcut::Action::DetachCircularWindow);
+            return;
+        }
+
+        self.detached_panes[idx] = false;
+        self.rebuild_positions();
+        self.apply_layout();
+    }
+
+    /// Close the detached child and reap it. Best-effort: a child the user
+    /// already closed is simply gone, and reattaching must work anyway.
+    fn close_detached_child(&mut self, idx: usize) {
+        if let Some(mut child) = self.detached_children.remove(&idx) {
+            let _ = child.kill();
+            // Reap it, or the process table keeps a zombie for the rest of the
+            // session — the same trap the liveness probe fell into.
+            let _ = child.wait();
+        }
+    }
+
+    /// Notice detached children the user closed themselves and take their panes
+    /// back, so a closed window does not strand its pane as a dead stub. Called
+    /// from the frame tick.
+    ///
+    /// `try_wait`, NOT `kill(pid, 0)`: the child is ours and unreaped, so once
+    /// it exits it is a zombie — still present in the process table, so the
+    /// signal probe reports it alive forever and the pane is never reclaimed.
+    pub(crate) fn poll_detached_children(&mut self) -> bool {
+        let mut reclaimed = false;
         for idx in PLATE_SLOTS {
-            if !self.detached_panes[idx] {
+            if !self.pane_is_detached(idx) {
                 continue;
             }
-            self.positions[idx] = (0.0, 0.0, 0.0, 0.0);
-            self.slots.get_dyn_mut(idx).set_visible(false);
+            let exited = match self.detached_children.get_mut(&idx) {
+                // `Ok(None)` is the only "still running" answer; an Err handle
+                // is no more useful than an exited one.
+                Some(child) => !matches!(child.try_wait(), Ok(None)),
+                None => continue,
+            };
+            if !exited {
+                continue;
+            }
+            self.detached_children.remove(&idx);
             if idx == NETWORK_PANEL_IDX {
-                for child in [crate::slots::CONTENT_IDX, crate::slots::BREADCRUMB_IDX] {
-                    self.positions[child] = (0.0, 0.0, 0.0, 0.0);
-                    self.slots.get_dyn_mut(child).set_visible(false);
-                }
+                self.detached_circular_network = false;
+                let val = false;
+                self.menu_mut(crate::slots::LEFT_MENUBAR_IDX).set_item_checked(2, 3, val);
+                self.menu_mut(crate::slots::HEADER_IDX).set_item_checked(2, 3, val);
+            } else {
+                self.detached_panes[idx] = false;
             }
+            reclaimed = true;
+        }
+        if reclaimed {
+            self.rebuild_positions();
+            self.apply_layout();
         }
+        reclaimed
     }
 }
diff --git a/src/render.rs b/src/render.rs
index 614fd51..49ce665 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -221,7 +221,7 @@ impl State {
         // Returning here is what suppresses the body — the params rows, the
         // spreadsheet grid, the transport controls — rather than relying on
         // each pane's own clip to hide content taller than the stub.
-        if self.pane_is_collapsed(idx) {
+        if let Some(stub_label) = self.pane_stub_label(idx) {
             let (sx, sy, sw, sh) = w.rect();
             append_widget_plate_radii(w, pc, self.plate_focus_tint(idx), self.pane_plate_radii(sx, sy, sw, sh));
             let font_size = 12.0;
@@ -229,7 +229,7 @@ impl State {
             // Bounds stop at the corner control so a long name cannot run under it.
             let text_right = sx + sw - 2.0 * crate::plate_corner::CORNER_INSET;
             pc.text_with(
-                crate::plate_corner::plate_title(idx),
+                stub_label,
                 sx + 12.0,
                 ty,
                 font_size,
diff --git a/src/window.rs b/src/window.rs
index 765fa26..920d760 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -882,6 +882,18 @@ impl State {
                 needs_redraw = true;
                 Ok(format!("{pane} collapsed={collapsed}"))
             }
+            McpAction::SetPaneDetached { pane, detached } => {
+                let idx = match pane.to_ascii_lowercase().as_str() {
+                    "network" => crate::slots::NETWORK_PANEL_IDX,
+                    "parameters" | "params" => crate::slots::PARAM_IDX,
+                    "spreadsheet" => crate::slots::SPREADSHEET_IDX,
+                    "playbar" => crate::slots::PLAYBAR_IDX,
+                    other => return Err(format!("unknown pane: {other}")),
+                };
+                state.set_pane_detached(idx, detached);
+                needs_redraw = true;
+                Ok(format!("{pane} detached={}", state.pane_is_detached(idx)))
+            }
             McpAction::ToggleCircularPane => {
                 state.circular_network_pane = !state.circular_network_pane;
                 let val = state.circular_network_pane;