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

commit845387530576c81730e4c4ff83d20cd27c0d24b3
parente57adc8745
authorLucas Galante <[email protected]>
date2026-09-01 12:13
feat: the parameters pane and spreadsheet can pin to one editor

Each pane gets its own pin (params_pin / spreadsheet_pin), set from a
radio group in its plate corner menu — shown only while a second
editor exists — resolving pin-else-active exactly like the viewport's.
The four params paths read the params binding; the spreadsheet refresh
(and the group markers with it) reads its own. Closing the second
editor clears pins to it; pins ride view_state beside the viewport's.

The bug this flushed out: sync_cursor_and_selection ran on every
changed event and, because both editors share the LEFT_MENUBAR focus
domain, a click in the second editor forced pane 1's selection to
whatever sat under pane 1's grid cursor — usually nothing — wiping the
selection a pinned pane was reading. The grid cursor is pane 1's
concept: the sync now gates on the primary being the active editor.

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

 src/app.rs          | 82 ++++++++++++++++++++++++++++++++++++++++++-----------
 src/main.rs         | 57 +++++++++++++++++++++++++++++++++++++
 src/plate_corner.rs | 47 ++++++++++++++++++++++++++++++
 src/project.rs      | 46 +++++++++++++++++++-----------
 4 files changed, 199 insertions(+), 33 deletions(-)

diff --git a/src/app.rs b/src/app.rs
index f346464..2738a42 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -189,6 +189,12 @@ pub struct ProjectViewState {
     /// unresolvable follows the active editor.
     #[serde(default)]
     pub viewport_pin: Option<String>,
+    /// The parameters pane's pin, same encoding.
+    #[serde(default)]
+    pub params_pin: Option<String>,
+    /// The spreadsheet's pin, same encoding.
+    #[serde(default)]
+    pub spreadsheet_pin: Option<String>,
 }
 
 fn default_camera() -> String {
@@ -910,6 +916,12 @@ pub struct State {
     /// CONTENT2_IDX) locks the scene to that editor's level regardless of
     /// where clicks land. Set from the viewport's right-click menu.
     pub viewport_pin: Option<usize>,
+    /// The parameters pane's pin — same shape, set from its plate's corner
+    /// menu. Pinned, the pane shows and edits the pinned editor's selection
+    /// no matter where clicks land.
+    pub params_pin: Option<usize>,
+    /// The spreadsheet's pin — same shape, set from its plate's corner menu.
+    pub spreadsheet_pin: Option<usize>,
     pub node_clipboard: Option<FsNode>,
     pub last_click: Option<(Instant, usize)>,
     pub last_frame: Instant,
@@ -1610,8 +1622,10 @@ impl State {
         let Some(from) = self.tab_dock_of_pane(slot) else { return };
         // A closed editor cannot hold the viewport or the params pane.
         if slot == crate::slots::NETWORK_PANEL2_IDX {
-            if self.viewport_pin == Some(crate::slots::CONTENT2_IDX) {
-                self.viewport_pin = None;
+            for pin in [&mut self.viewport_pin, &mut self.params_pin, &mut self.spreadsheet_pin] {
+                if *pin == Some(crate::slots::CONTENT2_IDX) {
+                    *pin = None;
+                }
             }
             if self.param_editor == crate::slots::CONTENT2_IDX {
                 self.param_editor = CONTENT_IDX;
@@ -1812,9 +1826,9 @@ impl State {
         node
     }
 
-    /// The selected slot in the editor the parameters pane follows.
-    pub fn param_editor_selected(&self) -> Option<usize> {
-        if self.param_editor == crate::slots::CONTENT2_IDX {
+    /// One editor's selected slot (CONTENT_IDX / CONTENT2_IDX).
+    pub fn editor_selected_of(&self, editor: usize) -> Option<usize> {
+        if editor == crate::slots::CONTENT2_IDX {
             use cce_ui::widget::GraphController as _;
             self.slots.content2.selected_node()
         } else {
@@ -1822,18 +1836,18 @@ impl State {
         }
     }
 
-    /// The level that editor is showing — where its selection resolves.
-    pub fn param_editor_dir(&self) -> &FsNode {
-        if self.param_editor == crate::slots::CONTENT2_IDX {
+    /// One editor's displayed level.
+    pub fn editor_dir_of(&self, editor: usize) -> &FsNode {
+        if editor == crate::slots::CONTENT2_IDX {
             self.dir_at(&self.current_path2)
         } else {
             self.current_dir()
         }
     }
 
-    /// [`Self::param_editor_dir`], mutable — the param writeback target.
-    pub fn param_editor_dir_mut(&mut self) -> &mut FsNode {
-        if self.param_editor == crate::slots::CONTENT2_IDX {
+    /// [`Self::editor_dir_of`], mutable.
+    pub fn editor_dir_of_mut(&mut self, editor: usize) -> &mut FsNode {
+        if editor == crate::slots::CONTENT2_IDX {
             let p2 = self.current_path2.clone();
             self.dir_at_mut(&p2)
         } else {
@@ -1841,6 +1855,32 @@ impl State {
         }
     }
 
+    /// The editor the parameters pane is bound to: its pin, else the active
+    /// (last-clicked) editor.
+    pub fn params_editor(&self) -> usize {
+        self.params_pin.unwrap_or(self.param_editor)
+    }
+
+    /// The editor the spreadsheet is bound to — same resolution.
+    pub fn spreadsheet_editor(&self) -> usize {
+        self.spreadsheet_pin.unwrap_or(self.param_editor)
+    }
+
+    /// The selected slot in the editor the parameters pane follows.
+    pub fn param_editor_selected(&self) -> Option<usize> {
+        self.editor_selected_of(self.params_editor())
+    }
+
+    /// The level that editor is showing — where its selection resolves.
+    pub fn param_editor_dir(&self) -> &FsNode {
+        self.editor_dir_of(self.params_editor())
+    }
+
+    /// [`Self::param_editor_dir`], mutable — the param writeback target.
+    pub fn param_editor_dir_mut(&mut self) -> &mut FsNode {
+        self.editor_dir_of_mut(self.params_editor())
+    }
+
     /// The editor whose level the VIEWPORT renders: the pin when set, else
     /// the active (last-clicked) editor.
     pub fn viewport_editor(&self) -> usize {
@@ -3221,13 +3261,14 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
         let path_strs = self.current_path_names();
         self.path_mut().set_path(&path_strs);
 
-        // The spreadsheet (and the group markers below) follow the SAME
-        // selection the parameters pane does: whichever editor took the
-        // last node click, at its own level.
+        // The spreadsheet (and the group markers with it) read the
+        // SPREADSHEET's binding: its pin when set, else the active editor —
+        // exactly the parameters pane's rule with its own pin.
         let mut selected_node = None;
         if !self.is_detached_network {
-            if let Some(slot_idx) = self.param_editor_selected() {
-                let dir = self.param_editor_dir();
+            let se = self.spreadsheet_editor();
+            if let Some(slot_idx) = self.editor_selected_of(se) {
+                let dir = self.editor_dir_of(se);
                 if slot_idx < dir.children.len() {
                     selected_node = Some(&dir.children[slot_idx]);
                 }
@@ -3523,6 +3564,8 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             current_path2: Vec::new(),
             param_editor: CONTENT_IDX,
             viewport_pin: None,
+            params_pin: None,
+            spreadsheet_pin: None,
             node_clipboard: None,
             last_click: None,
             last_frame: Instant::now(),
@@ -4695,7 +4738,12 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
     }
 
     pub fn sync_cursor_and_selection(&mut self) {
-        if self.focused_pane != LEFT_MENUBAR_IDX {
+        // The grid cursor is PANE 1's concept: both network editors share
+        // the LEFT_MENUBAR focus domain, so without the param_editor gate a
+        // click in the second editor ran this and forced pane 1's selection
+        // to whatever sat under its cursor cell — wiping the selection a
+        // pinned params pane or spreadsheet was reading.
+        if self.focused_pane != LEFT_MENUBAR_IDX || self.param_editor != CONTENT_IDX {
             return;
         }
         let dir = self.current_dir();
diff --git a/src/main.rs b/src/main.rs
index 2eb98e1..25425a2 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -701,6 +701,63 @@ mod tests {
     }
 
 
+    /// Pinning: the spreadsheet bound to pane 1 keeps reading pane 1's
+    /// selection while clicks land in the second editor, and the cursor-
+    /// selection sync no longer wipes pane 1's selection on second-editor
+    /// interactions (the regression that made pins look broken).
+    #[test]
+    fn test_pane_pins_bind_selection_sources() {
+        use crate::app::Dock;
+        use crate::slots::{CONTENT2_IDX, CONTENT_IDX, LEFT_MENUBAR_IDX, NETWORK_PANEL2_IDX};
+        use cce_ui::widget::GraphController as _;
+        let mut state = State::new(false);
+        state.add_dock_tab(Dock::Left, NETWORK_PANEL2_IDX);
+
+        let sphere = state.fs_root.children.iter().position(|c| c.name == "Sphere 1").unwrap();
+        let camera = state.fs_root.children.iter().position(|c| c.name == "Camera 1").unwrap();
+
+        // Pane 1 selects the sphere; the spreadsheet pins to pane 1.
+        state.graph_mut().set_selected_node(Some(sphere));
+        state.spreadsheet_pin = Some(CONTENT_IDX);
+        // A click in the second editor selects the camera and takes the
+        // active-editor role.
+        state.slots.content2.set_selected_node(Some(camera));
+        state.param_editor = CONTENT2_IDX;
+
+        // The params pane (unpinned) follows the second editor...
+        assert_eq!(state.param_editor_selected(), Some(camera));
+        // ...the spreadsheet's binding stays on pane 1's sphere.
+        let se = state.spreadsheet_editor();
+        assert_eq!(se, CONTENT_IDX);
+        assert_eq!(state.editor_selected_of(se), Some(sphere));
+
+        // The cursor-selection sync must NOT wipe pane 1's selection while
+        // the second editor is active — the grid cursor is pane 1's concept.
+        state.focused_pane = LEFT_MENUBAR_IDX;
+        state.grid_cursor_col = -50;
+        state.grid_cursor_row = -50;
+        state.sync_cursor_and_selection();
+        assert_eq!(
+            state.graph().selected_node(),
+            Some(sphere),
+            "second-editor activity must not clear pane 1's selection"
+        );
+
+        // And the spreadsheet refresh keys off the pinned selection.
+        state.show_spreadsheet = true;
+        state.sync_nodes();
+        let sphere_id = state.fs_root.children[sphere].id.clone();
+        assert_eq!(
+            state.last_spreadsheet_node_name.as_deref(),
+            Some(sphere_id.as_str()),
+            "spreadsheet must refresh against the PINNED editor's selection"
+        );
+
+        // A params pin binds the pane the other way.
+        state.params_pin = Some(CONTENT_IDX);
+        assert_eq!(state.param_editor_selected(), Some(sphere));
+    }
+
     /// 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
diff --git a/src/plate_corner.rs b/src/plate_corner.rs
index 9767c77..da2d34c 100644
--- a/src/plate_corner.rs
+++ b/src/plate_corner.rs
@@ -64,6 +64,10 @@ pub enum PlateMenuAction {
     SplitTab,
     /// Remove a closable pane (the second network editor) from the docks.
     CloseTab,
+    /// Bind this pane to whichever editor takes the last node click.
+    PinFollow,
+    /// Bind this pane to one editor (CONTENT_IDX / CONTENT2_IDX).
+    PinTo(usize),
     /// A "-" row: engraved, inert — keeps `plate_menu_actions` aligned with
     /// the option rows so a click on the line dispatches nothing.
     Separator,
@@ -178,6 +182,30 @@ impl State {
             }
         }
 
+        // Selection binding — the params pane and spreadsheet can pin to
+        // one editor (the viewport's right-click radio, on the plates that
+        // follow selection). Only while a second editor exists: with one,
+        // following IS pinned.
+        if (idx == PARAM_IDX || idx == SPREADSHEET_IDX)
+            && self.tab_dock_of_pane(NETWORK_PANEL2_IDX).is_some()
+        {
+            separate(&mut options, &mut actions);
+            let pin = if idx == PARAM_IDX { self.params_pin } else { self.spreadsheet_pin };
+            let mark = |on: bool| if on { "●" } else { "○" };
+            options.push(format!("{} Follow Active Editor", mark(pin.is_none())));
+            actions.push(PlateMenuAction::PinFollow);
+            options.push(format!(
+                "{} Pin: Network",
+                mark(pin == Some(crate::slots::CONTENT_IDX))
+            ));
+            actions.push(PlateMenuAction::PinTo(crate::slots::CONTENT_IDX));
+            options.push(format!(
+                "{} Pin: Network 2",
+                mark(pin == Some(crate::slots::CONTENT2_IDX))
+            ));
+            actions.push(PlateMenuAction::PinTo(crate::slots::CONTENT2_IDX));
+        }
+
         // Tabs — only on docked plates (the playbar's strip is not a dock).
         // The dock's other tabs switch to the front; panes docked elsewhere
         // can be pulled in as tabs; a pane sharing its dock can move back
@@ -316,10 +344,29 @@ impl State {
             PlateMenuAction::BackToMain => self.open_plate_menu(idx),
             PlateMenuAction::SplitTab => self.split_dock_tab(idx),
             PlateMenuAction::CloseTab => self.close_dock_tab(idx),
+            PlateMenuAction::PinFollow => self.set_pane_pin(idx, None),
+            PlateMenuAction::PinTo(e) => self.set_pane_pin(idx, Some(e)),
             PlateMenuAction::Separator => {}
         }
     }
 
+    /// Apply a plate's selection-binding pick and refresh what it feeds.
+    fn set_pane_pin(&mut self, idx: usize, pin: Option<usize>) {
+        match idx {
+            PARAM_IDX => {
+                self.params_pin = pin;
+                self.sync_parameters_pane();
+            }
+            SPREADSHEET_IDX => {
+                self.spreadsheet_pin = pin;
+                // The spreadsheet refresh lives in sync_nodes, keyed by the
+                // bound selection's node id — rebinding changes the key.
+                self.sync_nodes();
+            }
+            _ => {}
+        }
+    }
+
     /// Spreadsheet span: full width sets both tuck insets to their maxima
     /// (the rect derivation clamps to the usable span), between-panes clears
     /// them. The neighbors' heights follow through the existing tuck interlock.
diff --git a/src/project.rs b/src/project.rs
index d7a7854..0ca3f2a 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -127,11 +127,31 @@ impl State {
             splitters,
             dock_tabs,
             current_path2: self.current_path2.clone(),
-            viewport_pin: match self.viewport_pin {
-                Some(crate::slots::CONTENT2_IDX) => Some("network2".to_string()),
-                Some(_) => Some("network".to_string()),
-                None => None,
-            },
+            viewport_pin: Self::pin_name(self.viewport_pin),
+            params_pin: Self::pin_name(self.params_pin),
+            spreadsheet_pin: Self::pin_name(self.spreadsheet_pin),
+        }
+    }
+
+    /// A pin as its saved pane name.
+    fn pin_name(pin: Option<usize>) -> Option<String> {
+        match pin {
+            Some(crate::slots::CONTENT2_IDX) => Some("network2".to_string()),
+            Some(_) => Some("network".to_string()),
+            None => None,
+        }
+    }
+
+    /// The inverse: a saved pin name, honored only when its editor exists.
+    fn pin_from_name(&self, name: Option<&str>) -> Option<usize> {
+        match name {
+            Some("network") => Some(crate::slots::CONTENT_IDX),
+            Some("network2")
+                if self.tab_dock_of_pane(crate::slots::NETWORK_PANEL2_IDX).is_some() =>
+            {
+                Some(crate::slots::CONTENT2_IDX)
+            }
+            _ => None,
         }
     }
 
@@ -269,17 +289,11 @@ impl State {
         // a stale save must degrade to the deepest valid ancestor.
         self.current_path2 = vs.current_path2.clone();
         self.clamp_path2();
-        // The viewport pin: "network2" only holds if the loaded arrangement
-        // actually carries the second editor.
-        self.viewport_pin = match vs.viewport_pin.as_deref() {
-            Some("network") => Some(crate::slots::CONTENT_IDX),
-            Some("network2")
-                if self.tab_dock_of_pane(crate::slots::NETWORK_PANEL2_IDX).is_some() =>
-            {
-                Some(crate::slots::CONTENT2_IDX)
-            }
-            _ => None,
-        };
+        // Pins: "network2" only holds if the loaded arrangement actually
+        // carries the second editor.
+        self.viewport_pin = self.pin_from_name(vs.viewport_pin.as_deref());
+        self.params_pin = self.pin_from_name(vs.params_pin.as_deref());
+        self.spreadsheet_pin = self.pin_from_name(vs.spreadsheet_pin.as_deref());
         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;