graphic design tool
git clone https://git.lucas.co/cce-designer.git
fix: a detached window's autosave no longer resets the main window's view
The main window reloads default_project.json whenever a detached window
writes it, and that reload applied the whole view state in the file —
the detached window's own defaults: plate sizes, pane visibility,
collapses, docks, display settings and the camera. Every autosave from a
detached pane therefore reset the main window's layout, and cancelled any
pane-edge drag in progress. The sync-channel load is now
load_sync_channel(path, keep_own_view); the main window's poll passes
true and takes only the tree and navigation (path, selection, pan,
active camera).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 4 +++
src/application.rs | 5 ++-
src/main.rs | 39 ++++++++++++++++++++
src/project.rs | 103 ++++++++++++++++++++++++++++++++---------------------
4 files changed, 109 insertions(+), 42 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index a053cfa..713cbae 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -61,6 +61,10 @@ gone.)
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.
+ The main window's reload of that channel takes the TREE and navigation only
+ (`load_sync_channel(path, keep_own_view: true)`), never the view state a
+ detached window wrote — that is the detached window's defaults, and applying
+ it reset the main window's plate sizes, panes and camera on every autosave.
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.
diff --git a/src/application.rs b/src/application.rs
index 9a632bb..027583d 100644
--- a/src/application.rs
+++ b/src/application.rs
@@ -123,7 +123,10 @@ impl State {
if let Ok(mod_time) = m.modified() {
if Some(mod_time) != self.last_project_mod_time {
self.last_project_mod_time = Some(mod_time);
- if let Err(e) = self.load_from_file(&path) {
+ // The main window keeps its own view: what changed
+ // on disk was a detached window's edit to the tree.
+ let keep_own_view = !self.is_detached_network && self.detached_pane.is_none();
+ if let Err(e) = self.load_sync_channel(&path, keep_own_view) {
eprintln!("Failed to auto-reload project: {:?}", e);
} else {
redraw = true;
diff --git a/src/main.rs b/src/main.rs
index 63c862d..b8f10ea 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1396,6 +1396,45 @@ mod tests {
assert!((s.positions[crate::slots::PARAM_IDX].2 - 650.0).abs() < 0.5, "drawn param width: {:?}", s.positions[crate::slots::PARAM_IDX]);
}
+ /// The main window's sync reload takes a detached window's TREE edit and
+ /// keeps its own view. It used to apply the whole view state the detached
+ /// window wrote — that window's default plates, panes and camera — so
+ /// every autosave from it reset the main window's layout.
+ #[test]
+ fn a_detached_windows_save_does_not_reset_the_main_layout() {
+ let dir = std::env::temp_dir().join(format!("cce-designer-sync-view-test-{}", std::process::id()));
+ let _ = fs::remove_dir_all(&dir);
+ fs::create_dir_all(&dir).unwrap();
+ let channel = dir.join("default_project.json");
+
+ let mut main = State::new(false);
+ main.resize(1600.0, 900.0, 1.0);
+ main.floating_network_layout.2 = 700.0;
+ main.floating_param_width = 650.0;
+ main.floating_spreadsheet_height = 420.0;
+ main.viewport_mut().rotation_x = 0.7;
+ main.rebuild_positions();
+
+ let mut child = State::new(false);
+ child.detached_pane = Some(crate::slots::PARAM_IDX);
+ child.resize(400.0, 300.0, 1.0);
+ child.rebuild_positions();
+ child.fs_root.children[0].name = "synced_edit".to_string();
+ child.save_to_file(&channel).expect("child writes the channel");
+
+ main.app_drag = Some(crate::app::AppDrag::ParamResize { start_w: 650.0, start_mouse_x: 0.0 });
+ main.load_sync_channel(&channel, true).expect("main reloads");
+
+ assert!(main.fs_root.children.iter().any(|c| c.name == "synced_edit"), "the tree edit must sync");
+ assert!((main.floating_network_layout.2 - 700.0).abs() < 0.5, "network width: {}", main.floating_network_layout.2);
+ assert!((main.floating_param_width - 650.0).abs() < 0.5, "param width: {}", main.floating_param_width);
+ assert!((main.floating_spreadsheet_height - 420.0).abs() < 0.5, "spreadsheet height: {}", main.floating_spreadsheet_height);
+ assert!((main.viewport().rotation_x - 0.7).abs() < 1e-6, "camera: {}", main.viewport().rotation_x);
+ assert!(main.app_drag.is_some(), "a plate drag in progress survives the reload");
+
+ let _ = fs::remove_dir_all(&dir);
+ }
+
/// A dragged plate edge is an unsaved change — the save file carries the
/// plate geometry, so the title's asterisk must follow it, and clear on
/// save. A window resize alone must NOT dirty it.
diff --git a/src/project.rs b/src/project.rs
index 6a4f1b4..68e6b2c 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -382,56 +382,77 @@ impl State {
}
}
- pub(crate) fn load_from_file(&mut self, path: &Path) -> Result<(), Box<dyn std::error::Error>> {
- if path.file_name().map_or(false, |n| n == "default_project.json") {
- let content = fs::read_to_string(path)?;
- let mut proj: Project = serde_json::from_str(&content)?;
- proj.sanitize_node_names();
- proj.migrate_param_refs();
- crate::app::merge_template_defs(&mut proj.root, &self.node_templates);
- self.fs_root = proj.root;
- self.migrate_meta_settings_node();
+ /// Load the bundled `default_project.json`, which is also the detached
+ /// windows' sync channel. `keep_own_view` is the main window reloading
+ /// what a DETACHED window wrote: it takes the tree and where the user is
+ /// in it (path, selection, pan, active camera) and keeps its own layout,
+ /// display settings and camera view. A detached window has no plates,
+ /// docks or viewport of its own worth the name, so the view state it
+ /// writes is its defaults — and applying that on every one of its
+ /// autosaves reset the main window's plate sizes, pane visibility,
+ /// collapses and camera, and cancelled any pane-edge drag in progress.
+ pub(crate) fn load_sync_channel(&mut self, path: &Path, keep_own_view: bool) -> Result<(), Box<dyn std::error::Error>> {
+ let content = fs::read_to_string(path)?;
+ let mut proj: Project = serde_json::from_str(&content)?;
+ proj.sanitize_node_names();
+ proj.migrate_param_refs();
+ crate::app::merge_template_defs(&mut proj.root, &self.node_templates);
+ self.fs_root = proj.root;
+ self.migrate_meta_settings_node();
+ if !keep_own_view {
// Before the default view, whose camera-node rule has the last
// word on the square aspect and the pivot marker.
if let Some(d) = &proj.view_state.display {
self.apply_display_settings(d);
}
self.apply_pane_state_from_project(&proj.view_state);
- self.set_active_camera(proj.view_state.active_camera);
- self.pan_x = proj.view_state.pan.0;
- self.pan_y = proj.view_state.pan.1;
- self.pan_velocity_x = 0.0;
- self.pan_velocity_y = 0.0;
- self.last_frame_pan_x = self.pan_x;
- self.last_frame_pan_y = self.pan_y;
- self.current_path = proj.view_state.current_path;
+ }
+ self.set_active_camera(proj.view_state.active_camera);
+ self.pan_x = proj.view_state.pan.0;
+ self.pan_y = proj.view_state.pan.1;
+ self.pan_velocity_x = 0.0;
+ self.pan_velocity_y = 0.0;
+ self.last_frame_pan_x = self.pan_x;
+ self.last_frame_pan_y = self.pan_y;
+ self.current_path = proj.view_state.current_path;
+ if !keep_own_view {
self.apply_default_view_from_project(proj.view_state.default_view);
+ }
- let sel = proj.view_state.selected_node;
- self.graph_mut().set_selected_node(sel);
- if sel.is_some() {
- self.focused_widget = Some(CONTENT_IDX);
- } else {
- self.focused_widget = None;
- }
- self.drag_widget = None;
+ let sel = proj.view_state.selected_node;
+ self.graph_mut().set_selected_node(sel);
+ if sel.is_some() {
+ self.focused_widget = Some(CONTENT_IDX);
+ } else {
+ self.focused_widget = None;
+ }
+ self.drag_widget = None;
+ if !keep_own_view {
+ // A pane-edge or dock drag is layout, which a sync reload no
+ // longer touches — the drag in hand stays in hand.
self.app_drag = None;
- self.last_click = None;
-
- self.sync_grid_settings();
- self.sync_nodes();
- self.sync_cursor_and_selection_from_loaded();
- self.sync_cursor_and_selection();
- self.sync_parameters_pane();
-
- self.rebuild_scene_geometry();
- self.rebuild_positions();
- self.apply_layout();
- self.update_panel_bounds();
- self.loaded_project_path = None;
- self.mark_saved();
- self.update_window_title();
- return Ok(());
+ }
+ self.last_click = None;
+
+ self.sync_grid_settings();
+ self.sync_nodes();
+ self.sync_cursor_and_selection_from_loaded();
+ self.sync_cursor_and_selection();
+ self.sync_parameters_pane();
+
+ self.rebuild_scene_geometry();
+ self.rebuild_positions();
+ self.apply_layout();
+ self.update_panel_bounds();
+ self.loaded_project_path = None;
+ self.mark_saved();
+ self.update_window_title();
+ Ok(())
+ }
+
+ pub(crate) fn load_from_file(&mut self, path: &Path) -> Result<(), Box<dyn std::error::Error>> {
+ if path.file_name().map_or(false, |n| n == "default_project.json") {
+ return self.load_sync_channel(path, false);
}
let (state_file_path, project_dir) = if path.is_dir() {