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

commitc23436fee0bac9da234a30c1bd80e9c4dc6447c0
parent70b81b725a
authorLucas Galante <[email protected]>
date2026-09-06 11:51
feat(project): save plate sizes and positions in the view state

The floating layout's five user-dragged edges — network and parameter
plate widths, the spreadsheet's height and its tucks under both
neighbors — ride the save file as `view_state.plates`, window fractions
like the splitters, so a project comes back with the plates where they
were at any window size. The layout pass clamps them exactly as a drag
would; a save with a nonsense value keeps the live geometry, and
detached pane windows ignore it like the rest of the pane state.

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

 src/app.rs     | 23 +++++++++++++++++++++++
 src/main.rs    | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/project.rs | 31 ++++++++++++++++++++++++++++++-
 3 files changed, 109 insertions(+), 1 deletion(-)

diff --git a/src/app.rs b/src/app.rs
index 0848b09..204f7c9 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -217,6 +217,29 @@ pub struct ProjectViewState {
     /// The spreadsheet's pin, same encoding.
     #[serde(default)]
     pub spreadsheet_pin: Option<String>,
+    /// The floating layout's plate geometry — every edge the user can drag
+    /// — as window fractions, so a project restores its plate sizes and
+    /// positions at any window size (the splitter convention). Absent in
+    /// older saves keeps the live geometry.
+    #[serde(default)]
+    pub plates: Option<PlateGeometry>,
+}
+
+/// The user-dragged plate edges of the floating layout, each as a fraction
+/// of the window dimension it spans: widths and side insets of the width,
+/// the spreadsheet height of the height. The remaining plate coordinates
+/// (the network plate's top-left, the parameter plate's right anchor, the
+/// spreadsheet's bottom) are derived by `rebuild_positions`, so these five
+/// numbers fix every plate's size and position.
+#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)]
+pub struct PlateGeometry {
+    pub network_width: f32,
+    pub params_width: f32,
+    pub spreadsheet_height: f32,
+    /// How far the spreadsheet's left/right edge tucks under its neighbor
+    /// (0 = flush beside it) — see `floating_spreadsheet_inset_left`.
+    pub spreadsheet_inset_left: f32,
+    pub spreadsheet_inset_right: f32,
 }
 
 fn default_camera() -> String {
diff --git a/src/main.rs b/src/main.rs
index 264e634..70a4940 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -921,6 +921,62 @@ mod tests {
         let _ = fs::remove_dir_all(&dir);
     }
 
+    /// Plate geometry rides save files too: every edge the user can drag —
+    /// the network and parameter plate widths, the spreadsheet's height and
+    /// its tucks under both neighbors — comes back at the size it was saved
+    /// at, and, as fractions, scales onto a differently sized window.
+    #[test]
+    fn test_plate_geometry_round_trips_through_save() {
+        let dir = std::env::temp_dir().join(format!("cce-designer-plate-geometry-test-{}", std::process::id()));
+        let _ = fs::remove_dir_all(&dir);
+
+        let mut a = State::new(false);
+        a.resize(1600.0, 900.0, 1.0);
+        a.ensure_menubar_subnets();
+        a.execute_menu_action("Show Spreadsheet Pane");
+        assert!(a.show_spreadsheet);
+        a.floating_network_layout.2 = 520.0;
+        a.floating_param_width = 360.0;
+        a.floating_spreadsheet_height = 300.0;
+        a.set_spreadsheet_full_width(true);
+        a.rebuild_positions();
+        let insets = (a.floating_spreadsheet_inset_left, a.floating_spreadsheet_inset_right);
+        assert!(insets.0 > 0.0 && insets.1 > 0.0, "full width must set both tucks");
+        assert!((a.floating_network_layout.2 - 520.0).abs() < 0.5 && (a.floating_param_width - 360.0).abs() < 0.5);
+        a.save_to_file(&dir).expect("save");
+
+        let plates = a.project_view_state().plates.expect("a sized window records its plates");
+        assert!((plates.network_width - 520.0 / 1600.0).abs() < 1e-4, "widths save as window fractions");
+
+        let mut b = State::new(false);
+        b.resize(1600.0, 900.0, 1.0);
+        b.ensure_menubar_subnets();
+        b.load_from_file(&dir).expect("load");
+        assert!((b.floating_network_layout.2 - 520.0).abs() < 0.5, "network width: {}", b.floating_network_layout.2);
+        assert!((b.floating_param_width - 360.0).abs() < 0.5, "param width: {}", b.floating_param_width);
+        assert!((b.floating_spreadsheet_height - 300.0).abs() < 0.5, "spreadsheet height: {}", b.floating_spreadsheet_height);
+        assert!((b.floating_spreadsheet_inset_left - insets.0).abs() < 0.5 && (b.floating_spreadsheet_inset_right - insets.1).abs() < 0.5,
+            "tucks: {:?} vs {:?}", (b.floating_spreadsheet_inset_left, b.floating_spreadsheet_inset_right), insets);
+        assert!(b.spreadsheet_tucks_left() && b.spreadsheet_tucks_right(), "the full-width tuck must load tucked");
+
+        // Half the window: the same fractions land at half the pixels.
+        let mut c = State::new(false);
+        c.resize(800.0, 450.0, 1.0);
+        c.ensure_menubar_subnets();
+        c.load_from_file(&dir).expect("load half-size");
+        assert!((c.floating_network_layout.2 - 260.0).abs() < 0.5, "scaled network width: {}", c.floating_network_layout.2);
+        assert!((c.floating_param_width - 180.0).abs() < 0.5, "scaled param width: {}", c.floating_param_width);
+
+        // A detached pane window keeps its own plates.
+        let mut d = State::new(true);
+        d.ensure_menubar_subnets();
+        let before = d.floating_param_width;
+        d.load_from_file(&dir).expect("load detached");
+        assert_eq!(d.floating_param_width, before);
+
+        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 33a56e0..318af3a 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -1,7 +1,7 @@
 use std::fs;
 use std::path::Path;
 
-use crate::app::{State, Project, FsNode, ProjectViewState, ParamDef};
+use crate::app::{State, Project, FsNode, ProjectViewState, PlateGeometry, ParamDef};
 use crate::slots::CONTENT_IDX;
 
 fn color_to_hex(rgb: [f32; 3]) -> String {
@@ -118,6 +118,17 @@ impl State {
                 names
             })
             .collect();
+        let plates = if self.width > 1.0 && self.height > 1.0 {
+            Some(PlateGeometry {
+                network_width: self.floating_network_layout.2 / self.width,
+                params_width: self.floating_param_width / self.width,
+                spreadsheet_height: self.floating_spreadsheet_height / self.height,
+                spreadsheet_inset_left: self.floating_spreadsheet_inset_left / self.width,
+                spreadsheet_inset_right: self.floating_spreadsheet_inset_right / self.width,
+            })
+        } else {
+            None
+        };
         ProjectViewState {
             active_camera: self.active_camera.clone(),
             pan: (self.pan_x, self.pan_y),
@@ -130,6 +141,7 @@ impl State {
             viewport_pin: Self::pin_name(self.viewport_pin),
             params_pin: Self::pin_name(self.params_pin),
             spreadsheet_pin: Self::pin_name(self.spreadsheet_pin),
+            plates,
         }
     }
 
@@ -302,6 +314,23 @@ impl State {
                 self.apply_layout();
             }
         }
+        // Plate geometry: the fractions scale back onto this window, and
+        // the layout pass clamps them exactly as a drag would (minimum
+        // widths, the spreadsheet's tuck limits). A save with a nonsense
+        // value keeps the live geometry rather than loading half of one.
+        if let Some(pg) = vs.plates {
+            let sane = |f: f32| f.is_finite() && (0.0..=1.0).contains(&f);
+            let all = [pg.network_width, pg.params_width, pg.spreadsheet_height, pg.spreadsheet_inset_left, pg.spreadsheet_inset_right];
+            if self.width > 1.0 && self.height > 1.0 && all.iter().all(|&f| sane(f)) {
+                self.floating_network_layout.2 = pg.network_width * self.width;
+                self.floating_param_width = pg.params_width * self.width;
+                self.floating_spreadsheet_height = pg.spreadsheet_height * self.height;
+                self.floating_spreadsheet_inset_left = pg.spreadsheet_inset_left * self.width;
+                self.floating_spreadsheet_inset_right = pg.spreadsheet_inset_right * self.width;
+                self.rebuild_positions();
+                self.apply_layout();
+            }
+        }
     }
 
     pub(crate) fn load_from_file(&mut self, path: &Path) -> Result<(), Box<dyn std::error::Error>> {