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

commit874b0d01a15d10a0521c3e329206b5a46a41e55a
parent4eda26aa79
authorLucas Galante <[email protected]>
date2026-08-26 12:17
feat: pane state persists in project saves

Pane visibility already rode save files as the meta→View subnet params,
but was deliberately session-owned: ensure_menubar_subnets refreshed the
toggles from live state on load, clobbering what the file said, and
nothing applied them. Now project-owned: save_to_file syncs the mirror
before cloning the tree, load_from_file reads the loaded values first
and applies the diffs through the same execute_menu_action path the View
toggles use (slots, checkmarks, focus fixup included).

Collapse and splitter proportions join view_state as additive fields
(collapsed_panes by pane name; splitters as fractions of window width,
so proportions survive different window sizes). Older saves load with
every pane expanded and live splitters kept.

Detached windows are excluded on BOTH sides: they neither stamp their
single-pane layout into the sync channel nor let it re-shape them.

pane_name_from_slot/pane_slot_from_name centralize the pane-name
mapping; the MCP pane tools now share it.

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

 src/app.rs          |  10 +++++
 src/main.rs         |  43 ++++++++++++++++++
 src/plate_corner.rs |  23 ++++++++++
 src/project.rs      | 123 +++++++++++++++++++++++++++++++++++++++++++++-------
 src/window.rs       |  25 +++--------
 5 files changed, 189 insertions(+), 35 deletions(-)

diff --git a/src/app.rs b/src/app.rs
index 73b8951..d915075 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -165,6 +165,16 @@ pub struct ProjectViewState {
     pub current_path: Vec<usize>,
     #[serde(default)]
     pub selected_node: Option<usize>,
+    /// Collapsed plate panes by name ("network", "parameters", "spreadsheet",
+    /// "playbar"). Absent from older saves — an empty list expands everything,
+    /// so loading is deterministic either way.
+    #[serde(default)]
+    pub collapsed_panes: Vec<String>,
+    /// Splitter positions as fractions of the window width (splitter1,
+    /// splitter2), so a project restores its column proportions at any
+    /// window size. None in older saves keeps the live positions.
+    #[serde(default)]
+    pub splitters: Option<(f32, f32)>,
 }
 
 fn default_camera() -> String {
diff --git a/src/main.rs b/src/main.rs
index 65ebbdc..36ca896 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -594,6 +594,48 @@ mod tests {
         assert_eq!(m.match_action(&ctrl_shift, &lower), Some(Action::SaveAs));
     }
 
+    /// Pane state rides save files: visibility through the meta→View subnet
+    /// params (synced at save, applied on load), collapse and splitter
+    /// proportions through view_state. A fresh State loading the file must
+    /// come out shaped like the one that saved it.
+    #[test]
+    fn test_pane_state_round_trips_through_save() {
+        use crate::slots::PARAM_IDX;
+        let dir = std::env::temp_dir().join(format!("cce-designer-pane-state-test-{}", std::process::id()));
+        let _ = fs::remove_dir_all(&dir);
+
+        let mut a = State::new(false);
+        a.width = 1600.0;
+        a.ensure_menubar_subnets();
+        assert!(a.show_viewport && !a.show_spreadsheet, "test assumes the default pane set");
+        a.execute_menu_action("Show Viewport Pane");
+        a.execute_menu_action("Show Spreadsheet Pane");
+        a.set_pane_collapsed(PARAM_IDX, true);
+        a.splitter_layout.splitter1_x = 400.0;
+        a.splitter_layout.splitter2_x = 1200.0;
+        a.save_to_file(&dir).expect("save");
+
+        let mut b = State::new(false);
+        b.width = 800.0;
+        b.ensure_menubar_subnets();
+        b.load_from_file(&dir).expect("load");
+        assert!(!b.show_viewport, "viewport hidden in the save must load hidden");
+        assert!(b.show_spreadsheet, "spreadsheet shown in the save must load shown");
+        assert!(b.collapsed_panes[PARAM_IDX], "param pane collapse must round-trip");
+        assert!((b.splitter_layout.splitter1_x - 200.0).abs() < 1.0,
+            "splitters restore as fractions: 400/1600 of an 800-wide window = 200, got {}",
+            b.splitter_layout.splitter1_x);
+
+        // A detached pane window must ignore the same file's pane state.
+        let mut d = State::new(true);
+        d.ensure_menubar_subnets();
+        let vp_before = d.show_viewport;
+        d.load_from_file(&dir).expect("load detached");
+        assert_eq!(d.show_viewport, vp_before, "detached windows keep their own pane layout");
+
+        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.
@@ -1830,6 +1872,7 @@ mod tests {
             pan: (1.5, -2.5),
             current_path: vec![0],
             selected_node: Some(2),
+            ..Default::default()
         };
         let proj = Project {
             name: "Test Project".to_string(),
diff --git a/src/plate_corner.rs b/src/plate_corner.rs
index 6af92dc..f564ddc 100644
--- a/src/plate_corner.rs
+++ b/src/plate_corner.rs
@@ -304,6 +304,29 @@ pub fn pane_detach_flag(idx: usize) -> Option<&'static str> {
     }
 }
 
+/// The stable external name of a plate pane — the identity used by the MCP
+/// pane tools and by pane state persisted in project files.
+pub fn pane_name_from_slot(idx: usize) -> Option<&'static str> {
+    match idx {
+        NETWORK_PANEL_IDX => Some("network"),
+        PARAM_IDX => Some("parameters"),
+        SPREADSHEET_IDX => Some("spreadsheet"),
+        PLAYBAR_IDX => Some("playbar"),
+        _ => None,
+    }
+}
+
+/// The inverse of [`pane_name_from_slot`], accepting the "params" shorthand.
+pub fn pane_slot_from_name(name: &str) -> Option<usize> {
+    match name.to_ascii_lowercase().as_str() {
+        "network" => Some(NETWORK_PANEL_IDX),
+        "parameters" | "params" => Some(PARAM_IDX),
+        "spreadsheet" => Some(SPREADSHEET_IDX),
+        "playbar" => Some(PLAYBAR_IDX),
+        _ => 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
diff --git a/src/project.rs b/src/project.rs
index 1ed1862..46bfb4c 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -80,17 +80,50 @@ impl State {
 
 
 
+    /// The view-state block every save and snapshot shares. Pane visibility
+    /// is NOT here — it lives in the root meta node's View subnet params,
+    /// which ride `fs_root` into the file; this carries the rest of the pane
+    /// state (collapse + splitter proportions) beside the camera/pan fields.
+    pub(crate) fn project_view_state(&self) -> ProjectViewState {
+        let collapsed_panes = crate::plate_corner::PLATE_SLOTS
+            .iter()
+            .filter(|&&i| self.collapsed_panes[i])
+            .filter_map(|&i| crate::plate_corner::pane_name_from_slot(i))
+            .map(str::to_string)
+            .collect();
+        let splitters = if self.width > 1.0 {
+            Some((
+                self.splitter_layout.splitter1_x / self.width,
+                self.splitter_layout.splitter2_x / self.width,
+            ))
+        } else {
+            None
+        };
+        ProjectViewState {
+            active_camera: self.active_camera.clone(),
+            pan: (self.pan_x, self.pan_y),
+            current_path: self.current_path.clone(),
+            selected_node: self.graph().selected_node(),
+            collapsed_panes,
+            splitters,
+        }
+    }
+
     pub(crate) fn save_to_file(&mut self, path: &Path) -> Result<(), Box<dyn std::error::Error>> {
+        // The View subnet params mirror the live pane flags, but nothing
+        // refreshes them on a pane toggle — sync the mirror now so the saved
+        // tree carries the pane state that is actually on screen. Main window
+        // only: a detached pane window writing the sync channel would stamp
+        // its single-pane layout into the file, and the main window's next
+        // reload would apply it (the load side is gated the same way).
+        if !self.is_detached_network && self.detached_pane.is_none() {
+            self.ensure_menubar_subnets();
+        }
         if path.file_name().map_or(false, |n| n == "default_project.json") {
             let proj = Project {
                 name: "Default Project".to_string(),
                 root: self.fs_root.clone(),
-                view_state: ProjectViewState {
-                    active_camera: self.active_camera.clone(),
-                    pan: (self.pan_x, self.pan_y),
-                    current_path: self.current_path.clone(),
-                    selected_node: self.graph().selected_node(),
-                },
+                view_state: self.project_view_state(),
             };
             let content = serde_json::to_string_pretty(&proj)?;
             fs::write(path, content)?;
@@ -111,12 +144,7 @@ impl State {
         let proj = Project {
             name: project_name,
             root: self.fs_root.clone(),
-            view_state: ProjectViewState {
-                active_camera: self.active_camera.clone(),
-                pan: (self.pan_x, self.pan_y),
-                current_path: self.current_path.clone(),
-                selected_node: self.graph().selected_node(),
-            },
+            view_state: self.project_view_state(),
         };
         let content = serde_json::to_string_pretty(&proj)?;
         fs::write(&state_file_path, content)?;
@@ -124,15 +152,75 @@ impl State {
         Ok(())
     }
 
+    /// The pane-visibility toggles as saved in a project tree's meta→View
+    /// subnet. Read them off the LOADED tree before `ensure_menubar_subnets`
+    /// runs — it refreshes those params from live state, clobbering what the
+    /// file said.
+    fn project_pane_visibility(root: &FsNode) -> Vec<(String, bool)> {
+        root.children
+            .iter()
+            .find(|c| c.node_type == "meta")
+            .and_then(|m| m.children.iter().find(|c| c.name == "View"))
+            .map(|v| {
+                v.params
+                    .iter()
+                    .filter(|p| p.param_type == "toggle" && p.name.starts_with("Show ") && p.name.ends_with(" Pane"))
+                    .filter_map(|p| p.default.parse::<bool>().ok().map(|b| (p.name.clone(), b)))
+                    .collect()
+            })
+            .unwrap_or_default()
+    }
+
+    /// Apply a loaded project's pane state: visibility diffs fire the same
+    /// menu actions the View toggles use (slots, checkmarks, focus fixup all
+    /// included), then collapse and splitter proportions. Main window only —
+    /// detached windows own their single-pane layout, and the sync channel
+    /// must not re-shape them.
+    fn apply_pane_state_from_project(&mut self, visibility: &[(String, bool)], vs: &ProjectViewState) {
+        if self.is_detached_network || self.detached_pane.is_some() {
+            return;
+        }
+        for (name, desired) in visibility {
+            let cur = match name.as_str() {
+                "Show Network Pane" => Some(self.show_network),
+                "Show Viewport Pane" => Some(self.show_viewport),
+                "Show Parameters Pane" => Some(self.show_parameters),
+                "Show Spreadsheet Pane" => Some(self.show_spreadsheet),
+                "Show Playbar Pane" => Some(self.show_playbar),
+                _ => None,
+            };
+            if cur == Some(!*desired) {
+                self.execute_menu_action(name);
+            }
+        }
+        // Absent names expand: an older save (no collapse list) loads with
+        // every pane open rather than inheriting this session's collapses.
+        for &idx in crate::plate_corner::PLATE_SLOTS.iter() {
+            let desired = crate::plate_corner::pane_name_from_slot(idx)
+                .map_or(false, |n| vs.collapsed_panes.iter().any(|c| c == n));
+            self.set_pane_collapsed(idx, desired);
+        }
+        if let Some((f1, f2)) = vs.splitters {
+            if self.width > 1.0 && f1 > 0.02 && f2 < 0.98 && f1 < f2 {
+                self.splitter_layout.splitter1_x = f1 * self.width;
+                self.splitter_layout.splitter2_x = f2 * self.width;
+                self.rebuild_positions();
+                self.apply_layout();
+            }
+        }
+    }
+
     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)?;
             crate::app::merge_template_defs(&mut proj.root, &self.node_templates);
             crate::app::ensure_meta_children(&mut proj.root);
+            let saved_pane_vis = Self::project_pane_visibility(&proj.root);
             self.fs_root = proj.root;
             self.ensure_menubar_subnets();
             self.apply_settings_from_menubar_subnets();
+            self.apply_pane_state_from_project(&saved_pane_vis, &proj.view_state);
             self.active_camera = proj.view_state.active_camera;
             self.pan_x = proj.view_state.pan.0;
             self.pan_y = proj.view_state.pan.1;
@@ -186,9 +274,11 @@ impl State {
         let mut proj: Project = serde_json::from_str(&content)?;
         crate::app::merge_template_defs(&mut proj.root, &self.node_templates);
         crate::app::ensure_meta_children(&mut proj.root);
+        let saved_pane_vis = Self::project_pane_visibility(&proj.root);
         self.fs_root = proj.root;
         self.ensure_menubar_subnets();
         self.apply_settings_from_menubar_subnets();
+        self.apply_pane_state_from_project(&saved_pane_vis, &proj.view_state);
         self.active_camera = proj.view_state.active_camera;
         self.pan_x = proj.view_state.pan.0;
         self.pan_y = proj.view_state.pan.1;
@@ -636,9 +726,12 @@ impl State {
 
         // 2. View subnet — the pane-visibility switches, migrated off Main's
         // View section (the Guides pattern: a setting's home is a utility
-        // node; the header menu items stay as command access). Pane state is
-        // session-owned and never applied from the project, so the toggles
-        // seed and refresh from live state.
+        // node; the header menu items stay as command access). The toggles
+        // refresh from live state — mid-session, the live flags are the
+        // authority — but they are ALSO the persisted pane state: save_to_file
+        // syncs this mirror before cloning the tree, and load_from_file reads
+        // the loaded values (before this refresh clobbers them) and applies
+        // the diffs via apply_pane_state_from_project.
         let view_node = find_or_create_subnet(&mut self.fs_root.children[session_idx], "View", "utility", (0.0, 2.0));
         view_node.children.clear();
         ensure_param(view_node, "Panes", "section", "", &[], None, None, None);
diff --git a/src/window.rs b/src/window.rs
index f196ba9..72424f0 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -608,12 +608,7 @@ impl State {
         Project {
             name: "Project".to_string(),
             root: self.fs_root.clone(),
-            view_state: ProjectViewState {
-                active_camera: self.active_camera.clone(),
-                pan: (self.pan_x, self.pan_y),
-                current_path: self.current_path.clone(),
-                selected_node: self.graph().selected_node(),
-            },
+            view_state: self.project_view_state(),
         }
     }
 
@@ -905,25 +900,15 @@ impl State {
                 }
             }
             McpAction::SetPaneCollapsed { pane, collapsed } => {
-                let idx = match pane.to_ascii_lowercase().as_str() {
-                    "network" => crate::slots::NETWORK_PANEL_IDX,
-                    "parameters" | "params" => crate::slots::PARAM_IDX,
-                    "spreadsheet" => crate::slots::SPREADSHEET_IDX,
-                    "playbar" => crate::slots::PLAYBAR_IDX,
-                    other => return Err(format!("unknown pane: {other}")),
-                };
+                let idx = crate::plate_corner::pane_slot_from_name(&pane)
+                    .ok_or_else(|| format!("unknown pane: {pane}"))?;
                 state.set_pane_collapsed(idx, collapsed);
                 needs_redraw = true;
                 Ok(format!("{pane} collapsed={collapsed}"))
             }
             McpAction::SetPaneDetached { pane, detached } => {
-                let idx = match pane.to_ascii_lowercase().as_str() {
-                    "network" => crate::slots::NETWORK_PANEL_IDX,
-                    "parameters" | "params" => crate::slots::PARAM_IDX,
-                    "spreadsheet" => crate::slots::SPREADSHEET_IDX,
-                    "playbar" => crate::slots::PLAYBAR_IDX,
-                    other => return Err(format!("unknown pane: {other}")),
-                };
+                let idx = crate::plate_corner::pane_slot_from_name(&pane)
+                    .ok_or_else(|| format!("unknown pane: {pane}"))?;
                 state.set_pane_detached(idx, detached);
                 needs_redraw = true;
                 Ok(format!("{pane} detached={}", state.pane_is_detached(idx)))