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