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

commit3fe27a1bbcd7212956029cceeea9e383106f1aac
parentc23436fee0
authorLucas Galante <[email protected]>
date2026-09-06 11:57
fix(title): a plate resize, collapse or re-dock dirties the title

`has_unsaved_changes` compared only the node tree against its saved
copy, so nothing the view state carries could ever star the title —
drag a plate edge and the window said it was saved. A second baseline,
`pane_layout_json`, keys the saved pane layout (plates in px, splitters
as rounded fractions, collapses, dock tabs, pins) minus navigation, so a
window resize still dirties nothing; `mark_saved` sets both on every
save and load path.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 src/app.rs     | 56 +++++++++++++++++++++++++++++++++++++++++++++++++++-----
 src/main.rs    | 35 +++++++++++++++++++++++++++++++++++
 src/project.rs | 10 +++++-----
 3 files changed, 91 insertions(+), 10 deletions(-)

diff --git a/src/app.rs b/src/app.rs
index 204f7c9..10a10a2 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1162,6 +1162,11 @@ pub struct State {
     /// which reconstructs DesignSettings from live state — can carry it.
     pub default_project_setting: Option<String>,
     pub last_saved_root_json: String,
+    /// The pane layout as of the last save — [`State::pane_layout_json`] —
+    /// so a dragged plate edge, a collapse or a re-dock dirties the title
+    /// like an edit to the tree: the save file carries them, so unsaved
+    /// they are unsaved changes.
+    pub last_saved_layout_json: String,
     pub recent_files: Vec<std::path::PathBuf>,
     pub viewport_dirty: bool,
     pub last_status_text: String,
@@ -1332,11 +1337,46 @@ impl State {
     pub fn path_mut(&mut self) -> &mut dyn cce_ui::widget::PathController { self.slots.path_mut() }
 
     pub fn has_unsaved_changes(&self) -> bool {
-        if let Ok(current_json) = serde_json::to_string(&self.fs_root) {
-            current_json != self.last_saved_root_json
-        } else {
-            false
-        }
+        let tree_changed = match serde_json::to_string(&self.fs_root) {
+            Ok(current_json) => current_json != self.last_saved_root_json,
+            Err(_) => false,
+        };
+        tree_changed || self.pane_layout_json() != self.last_saved_layout_json
+    }
+
+    /// The pane layout the save file carries, keyed for the unsaved-changes
+    /// check: what `project_view_state` records MINUS navigation (pan, path,
+    /// selection, camera — moving around a project is not editing it).
+    /// Plates key in px, which a window resize leaves alone; splitters as
+    /// rounded fractions, which a resize scales proportionally — so
+    /// resizing the window dirties nothing.
+    pub fn pane_layout_json(&self) -> String {
+        let vs = self.project_view_state();
+        let splitters = vs.splitters.map(|(a, b)| ((a * 1000.0).round(), (b * 1000.0).round()));
+        let plates = (
+            self.floating_network_layout.2.round(),
+            self.floating_param_width.round(),
+            self.floating_spreadsheet_height.round(),
+            self.floating_spreadsheet_inset_left.round(),
+            self.floating_spreadsheet_inset_right.round(),
+        );
+        serde_json::to_string(&(
+            vs.collapsed_panes,
+            splitters,
+            vs.dock_tabs,
+            vs.viewport_pin,
+            vs.params_pin,
+            vs.spreadsheet_pin,
+            plates,
+        ))
+        .unwrap_or_default()
+    }
+
+    /// Record the live tree and pane layout as the saved baseline — every
+    /// save and load path calls this, so the title's asterisk clears.
+    pub fn mark_saved(&mut self) {
+        self.last_saved_root_json = serde_json::to_string(&self.fs_root).unwrap_or_default();
+        self.last_saved_layout_json = self.pane_layout_json();
     }
 
 
@@ -3784,6 +3824,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             loaded_project_path: None,
             default_project_setting: settings.default_project.clone(),
             last_saved_root_json: serde_json::to_string(&fs_root).unwrap_or_default(),
+            last_saved_layout_json: String::new(),
             recent_files,
             viewport_dirty: true,
             last_status_text: String::new(),
@@ -3891,6 +3932,11 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             let ptr = w as *mut (dyn WidgetHost + 'static);
             state.ui_context.register_widget(id, ptr);
         }
+        // The layout baseline waits for the layout pass above (it clamps the
+        // plate fields), and the title computed earlier must be re-read
+        // against it or a fresh window opens starred.
+        state.last_saved_layout_json = state.pane_layout_json();
+        state.update_window_title();
         state
     }
 
diff --git a/src/main.rs b/src/main.rs
index 70a4940..40f0865 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -977,6 +977,41 @@ mod tests {
         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.
+    #[test]
+    fn test_plate_resize_dirties_the_title_until_saved() {
+        let dir = std::env::temp_dir().join(format!("cce-designer-plate-dirty-test-{}", std::process::id()));
+        let _ = fs::remove_dir_all(&dir);
+
+        let mut state = State::new(false);
+        state.ensure_menubar_subnets();
+        // The tree baseline is taken before the meta-node migrations run
+        // (startup's project load re-baselines); this test is about the
+        // layout half, so baseline here.
+        state.mark_saved();
+        assert!(!state.has_unsaved_changes());
+        state.resize(1600.0, 900.0, 1.0);
+        assert!(!state.has_unsaved_changes(), "a window resize is not an edit");
+
+        state.floating_param_width += 60.0;
+        state.rebuild_positions();
+        state.update_window_title();
+        assert!(state.has_unsaved_changes(), "a plate resize must dirty the title");
+        assert!(state.title.ends_with('*'), "title: {}", state.title);
+
+        state.save_to_file(&dir).expect("save");
+        assert!(!state.has_unsaved_changes(), "saving clears it");
+        state.update_window_title(); // the event loop's refresh, after the save event
+        assert!(!state.title.ends_with('*'), "title: {}", state.title);
+
+        state.set_pane_collapsed(crate::slots::PARAM_IDX, true);
+        assert!(state.has_unsaved_changes(), "a collapse is saved state too");
+
+        let _ = fs::remove_dir_all(&dir);
+    }
+
     /// The playbar transport chords: plain arrows drive the timeline (Up =
     /// play/pause, Left/Right = step), and a held modifier must NOT match —
     /// modified arrows stay free for other bindings.
diff --git a/src/project.rs b/src/project.rs
index 318af3a..bf33abe 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -185,7 +185,7 @@ impl State {
             };
             let content = serde_json::to_string_pretty(&proj)?;
             fs::write(path, content)?;
-            self.last_saved_root_json = serde_json::to_string(&self.fs_root).unwrap_or_default();
+            self.mark_saved();
             return Ok(());
         }
 
@@ -206,7 +206,7 @@ impl State {
         };
         let content = serde_json::to_string_pretty(&proj)?;
         fs::write(&state_file_path, content)?;
-        self.last_saved_root_json = serde_json::to_string(&self.fs_root).unwrap_or_default();
+        self.mark_saved();
         Ok(())
     }
 
@@ -375,7 +375,7 @@ impl State {
             self.apply_layout();
             self.update_panel_bounds();
             self.loaded_project_path = None;
-            self.last_saved_root_json = serde_json::to_string(&self.fs_root).unwrap_or_default();
+            self.mark_saved();
             self.update_window_title();
             return Ok(());
         }
@@ -431,7 +431,7 @@ impl State {
         self.apply_layout();
         self.update_panel_bounds();
         self.loaded_project_path = Some(project_dir);
-        self.last_saved_root_json = serde_json::to_string(&self.fs_root).unwrap_or_default();
+        self.mark_saved();
         self.update_window_title();
         Ok(())
     }
@@ -512,7 +512,7 @@ impl State {
         self.apply_layout();
         self.update_panel_bounds();
         self.loaded_project_path = None;
-        self.last_saved_root_json = serde_json::to_string(&self.fs_root).unwrap_or_default();
+        self.mark_saved();
         self.update_window_title();
     }