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