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

commitf06adeb9c141174a444fe8f6007e348dd43af752
parentde97df81a2
authorLucas Galante <[email protected]>
date2026-08-23 12:52
feat: detach any plate into its own window

The corner menu's Detach now works for the params, spreadsheet and playbar
plates, not just the network one. Each gets a --detached-<pane> flag following
the network pattern: the parent saves the shared default_project.json, spawns
itself with the flag, and the two sync through that file's mtime — no socket,
and only the main window runs the MCP server.

--detached-network stays its own flag rather than folding into the new
Option<usize>: that window is not merely detached, it is CIRCULAR, with a
radial border resize and custom CSD that no rectangular pane wants. The new
windows take standard CSD by falling through the same is_detached_network
check that already gated it.

apply_detached_panes is a post-pass over positions[..], like the collapse one,
so detaching means one thing across all three layout branches: in the CHILD the
pane claims the window and every other slot goes dark; in the PARENT the panes
handed out stop being laid out. The 3D scene is gated off in a detached window
too — stage_frame keyed only on is_detached_network, so the scene staged behind
the pane and showed through its translucent plate.

The spawn only records the pane as detached if the child actually started;
otherwise the pane would vanish into a window that does not exist.

 CLAUDE.md           |   7 ++++
 src/app.rs          |  17 +++++++-
 src/application.rs  |  27 ++++++++++--
 src/main.rs         |  51 +++++++++++++++++++++++
 src/plate_corner.rs | 115 +++++++++++++++++++++++++++++++++++++++++++++++++++-
 5 files changed, 211 insertions(+), 6 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index a675453..2cb58b2 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -38,6 +38,13 @@ kernels and need a working OpenCL runtime; they are not pure-CPU tests.
 - `cce-designer --detached-network` — a separate network-pane-only window. It syncs
   with the main window by autosaving/polling `default_project.json` mtime (see the
   main loop in `src/main.rs`) — there is no socket between the two.
+- `cce-designer --detached-params` / `--detached-spreadsheet` / `--detached-playbar`
+  — the same idea for the other plates (`plate_corner::pane_detach_flag`), spawned by
+  the plate corner menu's Detach. These windows are plain rectangles with standard CSD
+  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.
 
 ### MCP automation server
 
diff --git a/src/app.rs b/src/app.rs
index 2f3c58c..a3fbc3b 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -731,6 +731,15 @@ pub struct State {
     pub circular_network_layout: cce_ui::layout::CircularPaneLayout,
     pub is_detached_network: bool,
     pub detached_circular_network: bool,
+    /// This process IS the detached window for one pane — the generic sibling
+    /// of `is_detached_network`, which stays its own flag because the network's
+    /// detached window is not merely detached: it is CIRCULAR, with a radial
+    /// 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
+    /// network's equivalent.
+    pub detached_panes: [bool; WIDGET_COUNT],
     pub last_project_mod_time: Option<std::time::SystemTime>,
     pub last_project_check: std::time::Instant,
     pub needs_autosave: bool,
@@ -2666,6 +2675,8 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             circular_network_layout: cce_ui::layout::CircularPaneLayout::new(250.0, 300.0, 180.0),
             is_detached_network,
             detached_circular_network: false,
+            detached_pane: None,
+            detached_panes: [false; WIDGET_COUNT],
             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()
@@ -3384,6 +3395,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             }
         }
 
+        self.apply_detached_panes();
         self.apply_collapsed_panes();
     }
 
@@ -5222,7 +5234,9 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
         // 3D canvas: stage the scene into the renderer's backdrop when the
         // viewport is visible and its inputs changed; unstaged frames reuse the
         // previous backdrop (the renderer's equivalent of the old cached pass).
-        if !self.is_detached_network && self.show_viewport {
+        // A detached pane's window has no 3D canvas: the scene would stage
+        // behind the pane and show through its translucent plate.
+        if !self.is_detached_network && self.detached_pane.is_none() && self.show_viewport {
             let cx_logical = 0.0;
             let cy_logical = HEADER_H;
             let cw_logical = self.width;
@@ -5451,6 +5465,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
         // The engine draws the frame; keep frames coming while the path
         // tracer is still refining.
         !self.is_detached_network
+            && self.detached_pane.is_none()
             && self.show_viewport
             && self.viewport().rt_mode
             && renderer.rt_accumulating()
diff --git a/src/application.rs b/src/application.rs
index cad9b4d..1bfff82 100644
--- a/src/application.rs
+++ b/src/application.rs
@@ -91,7 +91,10 @@ impl State {
     /// window wrote it. Returns true when a reload happened.
     fn poll_shared_project(&mut self) -> bool {
         let mut redraw = false;
-        let syncing = self.is_detached_network || self.detached_circular_network;
+        let syncing = self.is_detached_network
+            || self.detached_circular_network
+            || self.detached_pane.is_some()
+            || self.detached_panes.iter().any(|d| *d);
         if !syncing {
             return false;
         }
@@ -133,7 +136,12 @@ impl State {
     }
 
     pub(crate) fn autosave_on_exit(&mut self) {
-        if self.needs_autosave && (self.is_detached_network || self.detached_circular_network) {
+        if self.needs_autosave
+            && (self.is_detached_network
+                || self.detached_circular_network
+                || self.detached_pane.is_some()
+                || self.detached_panes.iter().any(|d| *d))
+        {
             let _ = self.save_to_file(&Self::default_project_path());
         }
     }
@@ -147,9 +155,20 @@ impl Application for State {
         sender: calloop::channel::Sender<CustomEvent>,
     ) -> Self {
         let is_detached_network = std::env::args().any(|arg| arg == "--detached-network");
+        let detached_pane = std::env::args()
+            .find_map(|arg| crate::plate_corner::pane_from_detach_flag(&arg));
         let mut state = State::new(is_detached_network);
+        if let Some(idx) = detached_pane {
+            // Set after construction, so the layout that `State::new` already
+            // ran has to be redone against the detached shape.
+            state.detached_pane = Some(idx);
+            state.rebuild_positions();
+            state.apply_layout();
+        }
         state.event_sender = Some(sender.clone());
-        if !is_detached_network {
+        // One MCP server per project: the detached windows are satellites of the
+        // main one and would only collide on the port.
+        if !is_detached_network && detached_pane.is_none() {
             start_mcp_server(sender);
         }
         state
@@ -158,6 +177,8 @@ impl Application for State {
     fn settings(&self) -> WindowSettings {
         let (app_id, min_size) = if self.is_detached_network {
             ("circular-network-pane", (200, 200))
+        } else if let Some(idx) = self.detached_pane {
+            (crate::plate_corner::pane_app_id(idx), (240, 160))
         } else {
             ("cce-designer", (480, 320))
         };
diff --git a/src/main.rs b/src/main.rs
index cdb4a8a..d2332b5 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -130,6 +130,57 @@ mod tests {
         assert!(state.slots.get_dyn(CONTENT_IDX).visible(), "graph did not come back");
     }
 
+    /// Both sides of a detach. The child must show ONE pane and nothing else —
+    /// a stray visible slot would paint over it — and the parent must stop
+    /// laying the pane out, or the space it held is never released.
+    #[test]
+    fn test_detached_pane_claims_its_window_and_leaves_the_parent() {
+        use crate::plate_corner::DETACHED_MARGIN;
+        use crate::slots::{PARAM_IDX, VIEWPORT_IDX, WIDGET_COUNT};
+
+        // Child: the detached window.
+        let mut child = State::new(false);
+        child.detached_pane = Some(PARAM_IDX);
+        child.resize(600.0, 400.0, 1.0);
+        for i in 0..WIDGET_COUNT {
+            let visible = child.slots.get_dyn(i).visible();
+            assert_eq!(visible, i == PARAM_IDX, "slot {i} visibility in a detached window");
+        }
+        let (x, y, w, h) = child.slots.get_dyn(PARAM_IDX).rect();
+        assert_eq!((x, y), (DETACHED_MARGIN, DETACHED_MARGIN));
+        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.
+        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");
+        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");
+        assert!(parent.slots.get_dyn(VIEWPORT_IDX).visible(), "the rest of the parent survived");
+    }
+
+    /// 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]
+    fn test_detach_is_only_offered_where_it_works() {
+        use crate::slots::{PARAM_IDX, SPREADSHEET_IDX};
+        let mut state = State::new(false);
+        state.resize(1600.0, 900.0, 1.0);
+        assert!(state.plate_can_detach(PARAM_IDX), "params should be detachable");
+
+        state.detached_panes[PARAM_IDX] = true;
+        assert!(!state.plate_can_detach(PARAM_IDX), "params offered detach twice");
+        assert!(state.plate_can_detach(SPREADSHEET_IDX), "one detach blocked the others");
+
+        let mut child = State::new(false);
+        child.detached_pane = Some(PARAM_IDX);
+        child.resize(600.0, 400.0, 1.0);
+        assert!(!child.plate_can_detach(PARAM_IDX), "a detached window offered to detach again");
+    }
+
     /// The menu is contextual, and the two states are mutually exclusive: a
     /// collapsed plate must offer Expand and NOT Collapse, or the item that
     /// restores it is unreachable.
diff --git a/src/plate_corner.rs b/src/plate_corner.rs
index c77855a..a186a05 100644
--- a/src/plate_corner.rs
+++ b/src/plate_corner.rs
@@ -13,7 +13,7 @@
 //! here on a fully-round radius so the trough reads as a ring.
 
 use crate::app::State;
-use crate::slots::{NETWORK_PANEL_IDX, PARAM_IDX, PLAYBAR_IDX, SPREADSHEET_IDX};
+use crate::slots::{NETWORK_PANEL_IDX, PARAM_IDX, PLAYBAR_IDX, SPREADSHEET_IDX, WIDGET_COUNT};
 
 /// Radius of the control itself.
 pub const CORNER_R: f32 = 8.0;
@@ -187,12 +187,45 @@ impl State {
     /// as that path is generalized, and until then they simply do not offer
     /// the item rather than offering one that does nothing.
     pub fn plate_can_detach(&self, idx: usize) -> bool {
-        idx == NETWORK_PANEL_IDX && !self.is_detached_network
+        // A detached window never offers to detach its own pane again, and a
+        // pane already handed out cannot be handed out twice.
+        if self.is_detached_network || self.detached_pane.is_some() {
+            return false;
+        }
+        match idx {
+            NETWORK_PANEL_IDX => !self.detached_circular_network,
+            other => pane_detach_flag(other).is_some() && !self.detached_panes[other],
+        }
     }
 
     fn detach_plate(&mut self, idx: usize) {
         if idx == NETWORK_PANEL_IDX {
             self.execute_action(crate::shortcut::Action::DetachCircularWindow);
+            return;
+        }
+        let Some(flag) = pane_detach_flag(idx) else { return };
+
+        // The detached window reads the pane out of the shared project file and
+        // then syncs through it, exactly as the network window does — so it has
+        // to be on disk BEFORE the child starts.
+        let shared = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("default_project.json");
+        if let Err(e) = self.save_to_file(&shared) {
+            eprintln!("Failed to save shared project before detaching: {e:?}");
+            return;
+        }
+
+        match std::env::current_exe() {
+            Ok(exe) => match std::process::Command::new(exe).arg(flag).spawn() {
+                Ok(_) => {
+                    self.detached_panes[idx] = true;
+                    self.rebuild_positions();
+                    self.apply_layout();
+                }
+                // Leave the pane in place if the child never started, rather
+                // than hiding it into a window that does not exist.
+                Err(e) => eprintln!("Failed to spawn detached {}: {e:?}", plate_title(idx)),
+            },
+            Err(e) => eprintln!("Cannot locate own executable to detach: {e:?}"),
         }
     }
 }
@@ -242,3 +275,81 @@ impl State {
         PLATE_SLOTS.contains(&idx) && self.collapsed_panes[idx]
     }
 }
+
+/// The CLI flag that runs this pane as its own window, e.g. `--detached-params`.
+/// The network keeps `--detached-network`, handled separately: its detached
+/// window is circular, not merely detached.
+pub fn pane_detach_flag(idx: usize) -> Option<&'static str> {
+    match idx {
+        PARAM_IDX => Some("--detached-params"),
+        SPREADSHEET_IDX => Some("--detached-spreadsheet"),
+        PLAYBAR_IDX => Some("--detached-playbar"),
+        _ => None,
+    }
+}
+
+/// The pane an argv entry asks for, if any — the inverse of [`pane_detach_flag`].
+pub fn pane_from_detach_flag(arg: &str) -> Option<usize> {
+    PLATE_SLOTS
+        .iter()
+        .copied()
+        .find(|&idx| pane_detach_flag(idx) == Some(arg))
+}
+
+/// The detached window's `app_id`, which the compositor keys window rules off.
+pub fn pane_app_id(idx: usize) -> &'static str {
+    match idx {
+        PARAM_IDX => "cce-designer-params",
+        SPREADSHEET_IDX => "cce-designer-spreadsheet",
+        PLAYBAR_IDX => "cce-designer-playbar",
+        _ => "cce-designer",
+    }
+}
+
+/// Inset of a detached pane inside its own window, so the plate keeps a visible
+/// edge of its own instead of fusing with the window border.
+pub const DETACHED_MARGIN: f32 = 8.0;
+
+impl State {
+    /// Resolve the detached-window arrangement, both sides of it.
+    ///
+    /// A post-pass for the same reason `apply_collapsed_panes` is one: detaching
+    /// means one thing regardless of which of the three layout branches just
+    /// ran. In the CHILD process the detached pane claims the whole window and
+    /// every other slot goes dark; in the PARENT the panes it has handed out
+    /// stop being laid out, so the space they held is released.
+    pub(crate) fn apply_detached_panes(&mut self) {
+        if let Some(idx) = self.detached_pane {
+            for i in 0..WIDGET_COUNT {
+                if i == idx {
+                    continue;
+                }
+                self.positions[i] = (0.0, 0.0, 0.0, 0.0);
+                self.slots.get_dyn_mut(i).set_visible(false);
+            }
+            let m = DETACHED_MARGIN;
+            self.positions[idx] = (
+                m,
+                m,
+                (self.width - 2.0 * m).max(0.0),
+                (self.height - 2.0 * m).max(0.0),
+            );
+            self.slots.get_dyn_mut(idx).set_visible(true);
+            return;
+        }
+
+        for idx in PLATE_SLOTS {
+            if !self.detached_panes[idx] {
+                continue;
+            }
+            self.positions[idx] = (0.0, 0.0, 0.0, 0.0);
+            self.slots.get_dyn_mut(idx).set_visible(false);
+            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);
+                }
+            }
+        }
+    }
+}