graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: tab and pane state ride save files completely
Two gaps closed: the dock_tabs loader rejected any arrangement carrying
the second network editor (its whole-coverage check expected exactly
the three core panes) — it now accepts network2 at most once, and its
presence in a list is what recreates the editor on load (absent, it
stays closed, replacing even a live one: the file's arrangement IS the
arrangement). And the second editor's own path now rides view_state
(current_path2), clamped against the loaded tree so a save whose graph
changed shape degrades to the deepest valid ancestor. The round-trip
test saves a fronted, dived second editor and loads it back whole.
Co-Authored-By: Claude Fable 5 <[email protected]>
src/app.rs | 9 +++++++--
src/main.rs | 23 +++++++++++++++++++++++
src/project.rs | 23 +++++++++++++++++------
3 files changed, 47 insertions(+), 8 deletions(-)
diff --git a/src/app.rs b/src/app.rs
index 3996c51..c6d7200 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -176,10 +176,15 @@ pub struct ProjectViewState {
pub splitters: Option<(f32, f32)>,
/// The docks' tab groups, Left/Right/Bottom order, pane names with the
/// ACTIVE tab first. Empty (older saves) keeps the default one-pane-per-
- /// dock arrangement; a list that does not name each docked pane exactly
- /// once across the three groups is ignored the same way.
+ /// dock arrangement; a list that does not name each core docked pane
+ /// exactly once (plus "network2" at most once — its presence recreates
+ /// the second editor) is ignored the same way.
#[serde(default)]
pub dock_tabs: Vec<Vec<String>>,
+ /// The second network editor's own path. Clamped on load, so a save
+ /// whose tree changed shape degrades to the deepest valid ancestor.
+ #[serde(default)]
+ pub current_path2: Vec<usize>,
}
fn default_camera() -> String {
diff --git a/src/main.rs b/src/main.rs
index 61c1f79..2eb98e1 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -720,6 +720,16 @@ mod tests {
a.set_pane_collapsed(PARAM_IDX, true);
a.splitter_layout.splitter1_x = 400.0;
a.splitter_layout.splitter2_x = 1200.0;
+ // Tab state: a second network editor tabbed beside the first (and
+ // fronted), dived one level down its own path.
+ a.add_dock_tab(crate::app::Dock::Left, crate::slots::NETWORK_PANEL2_IDX);
+ let sphere = a
+ .fs_root
+ .children
+ .iter()
+ .position(|c| c.name == "Sphere 1")
+ .expect("default project has Sphere 1");
+ a.current_path2 = vec![sphere];
a.save_to_file(&dir).expect("save");
let mut b = State::new(false);
@@ -732,6 +742,19 @@ mod tests {
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);
+ // The tab arrangement rides the file: the second editor exists,
+ // fronted in the left dock with the primary waiting, on its own path.
+ assert_eq!(
+ b.pane_in_dock(crate::app::Dock::Left),
+ crate::slots::NETWORK_PANEL2_IDX,
+ "the fronted second editor must load fronted"
+ );
+ assert_eq!(
+ b.tab_dock_of_pane(crate::slots::NETWORK_PANEL_IDX),
+ Some(crate::app::Dock::Left),
+ "the primary must load as the waiting tab"
+ );
+ assert_eq!(b.current_path2, vec![sphere], "the second editor's path must round-trip");
// A detached pane window must ignore the same file's pane state.
let mut d = State::new(true);
diff --git a/src/project.rs b/src/project.rs
index 927efd3..75b76c7 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -126,6 +126,7 @@ impl State {
collapsed_panes,
splitters,
dock_tabs,
+ current_path2: self.current_path2.clone(),
}
}
@@ -221,9 +222,12 @@ impl State {
self.set_pane_collapsed(idx, desired);
}
// Dock tab groups: accepted only whole — three lists whose names
- // resolve and cover each docked pane exactly once. Anything else
- // (older saves' empty list included) keeps the current arrangement
- // rather than loading half a layout.
+ // resolve, cover each CORE docked pane exactly once, and carry the
+ // second network editor at most once (its presence in a list is what
+ // recreates it; absent, it stays closed — including replacing a live
+ // one, since the file's arrangement is the arrangement). Anything
+ // else (older saves' empty list included) keeps the current layout
+ // rather than loading half of one.
if vs.dock_tabs.len() == 3 {
let resolved: Vec<Vec<usize>> = vs
.dock_tabs
@@ -235,15 +239,18 @@ impl State {
.collect()
})
.collect();
- let mut all: Vec<usize> = resolved.iter().flatten().copied().collect();
- all.sort_unstable();
+ let all: Vec<usize> = resolved.iter().flatten().copied().collect();
+ let n2 = crate::slots::NETWORK_PANEL2_IDX;
+ let n2_count = all.iter().filter(|&&s| s == n2).count();
+ let mut core: Vec<usize> = all.iter().copied().filter(|&s| s != n2).collect();
+ core.sort_unstable();
let mut expected = vec![
crate::slots::NETWORK_PANEL_IDX,
crate::slots::PARAM_IDX,
crate::slots::SPREADSHEET_IDX,
];
expected.sort_unstable();
- if all == expected {
+ if core == expected && n2_count <= 1 {
for d in 0..3 {
self.dock_tabs[d] = resolved[d].clone();
self.dock_panes[d] =
@@ -253,6 +260,10 @@ impl State {
self.apply_layout();
}
}
+ // The second editor's own path, clamped against the loaded tree —
+ // a stale save must degrade to the deepest valid ancestor.
+ self.current_path2 = vs.current_path2.clone();
+ self.clamp_path2();
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;