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