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