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

commit5381d5ff07f8290ef125b8b4001d83e694a4f6b0
parent46ad44df8d
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-25 15:53
fix: a cursor step no longer writes one node's params into the next

The params pane's rows are written back by name into whatever is
selected. sync_layout flushes them (via sync_pane_focus) after a
keyboard cursor step has moved the selection but before the post-event
pass reloads the pane, so stepping from output1 onto detangle1 wrote
output1's Input into detangle1, and the next step carried detangle1's
Input and Iterations into relax1.

sync_parameters_pane now records which node the rows came from (editor,
level, slot and id, since ids can be empty), and the write-back does
nothing when the selection is no longer that node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 src/app.rs  | 44 ++++++++++++++++++++++++++++++++++-
 src/main.rs | 76 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 119 insertions(+), 1 deletion(-)

diff --git a/src/app.rs b/src/app.rs
index 95185c2..db666ae 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -252,6 +252,19 @@ pub(crate) fn regenerate_node_ids(n: &mut FsNode) {
     }
 }
 
+/// Which node the params pane is showing: the editor feeding it, that
+/// editor's level, the slot there, and the node's id. All four, because no
+/// one of them is an identity alone — an id is empty on nodes a bundled file
+/// was saved without, a slot is only meaningful at a level, and a level is
+/// only meaningful per editor.
+#[derive(Clone, PartialEq, Debug)]
+pub(crate) struct ParamPaneTarget {
+    editor: usize,
+    path: Vec<usize>,
+    slot: usize,
+    id: String,
+}
+
 fn default_node_type() -> String { "node".to_string() }
 fn default_node_geometry_visible() -> bool { true }
 fn default_node_position() -> (f32, f32) { (0.0, 0.0) }
@@ -1852,6 +1865,11 @@ pub struct State {
     /// writeback): CONTENT_IDX or CONTENT2_IDX — whichever took the last
     /// node click. Selection itself stays per-editor.
     pub param_editor: usize,
+    /// The node the params pane's rows were loaded FROM — set by
+    /// `sync_parameters_pane`, checked by `sync_parameters_to_project`, which
+    /// writes nothing when the selection has moved on since. See
+    /// [`State::param_pane_target`].
+    pub(crate) param_pane_source: Option<ParamPaneTarget>,
     /// The viewport's pin: None follows `param_editor`; Some(CONTENT_IDX /
     /// CONTENT2_IDX) locks the scene to that editor's level regardless of
     /// where clicks land. Set from the viewport's right-click menu.
@@ -3259,9 +3277,31 @@ impl State {
         }
     }
 
+    /// The node a params-pane load would show right now — see
+    /// [`ParamPaneTarget`]. None when nothing is selected.
+    pub(crate) fn param_pane_target(&self) -> Option<ParamPaneTarget> {
+        let editor = self.params_editor();
+        let slot = self.param_editor_selected()?;
+        let node = self.param_editor_dir().children.get(slot)?;
+        let path = if editor == crate::slots::CONTENT2_IDX { &self.current_path2 } else { &self.current_path };
+        Some(ParamPaneTarget { editor, path: path.clone(), slot, id: node.id.clone() })
+    }
+
+    /// Write the params pane's rows back into the node they were loaded from.
+    ///
+    /// Rows are matched to params by NAME, so rows from one node written into
+    /// another land wherever the two share a name — and every node has an
+    /// `Input`. The pane is not reloaded the instant the selection moves:
+    /// `sync_layout` reaches here through `sync_pane_focus` before the
+    /// post-event pass gets to `sync_parameters_pane`, so a cursor step from
+    /// output1 onto detangle1 wrote output1's `Input` into detangle1, and the
+    /// next step carried detangle1's `Input` and `Iterations` into relax1
+    /// (2026-09-25, a simnet rewired by pressing k twice). Hence the guard:
+    /// rows that were not loaded from the selected node are stale, and
+    /// writing nothing is the only right thing to do with them.
     pub fn sync_parameters_to_project(&mut self) {
         let mut file_to_open = None;
-        if !self.is_detached_network {
+        if !self.is_detached_network && self.param_pane_source == self.param_pane_target() {
             if let Some(slot_idx) = self.param_editor_selected() {
                 let updated_params = self.param().node_params();
                 // Live pane state, so a pane toggle only fires the visibility
@@ -3750,6 +3790,7 @@ impl State {
         };
         let params = self.add_pick_lists(params);
         self.param_mut().set_display_params(&params);
+        self.param_pane_source = if self.is_detached_network { None } else { self.param_pane_target() };
     }
 
     /// Upgrade a selected group/attribute node's group- and attribute-name
@@ -5564,6 +5605,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             current_path,
             current_path2: Vec::new(),
             param_editor: CONTENT_IDX,
+            param_pane_source: None,
             viewport_pin: None,
             params_pin: None,
             spreadsheet_pin: None,
diff --git a/src/main.rs b/src/main.rs
index 8cbe6af..99a5b8e 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -5970,6 +5970,82 @@ mod tests {
     /// gated on the network pane having focus — the bare family used to be the
     /// one that was not, so the cursor drifted invisibly while you looked at
     /// the viewport.
+    /// Stepping the grid cursor from node to node must not carry one node's
+    /// parameters into the next.
+    ///
+    /// The pane's rows are written back by NAME, and until 2026-09-25 they
+    /// were written into whatever was selected — `sync_layout` flushed them
+    /// through `sync_pane_focus` after a cursor step had moved the selection
+    /// but before the post-event pass reloaded the pane. Every node has an
+    /// `Input`, so pressing k twice from the bottom of a chain pointed each
+    /// node it landed on at the chain's end, and carried Iterations from one
+    /// node into the next on the way. Real key events through
+    /// `process_window_event`, because the bug lived in that pass's order and
+    /// `run_command` alone skips it.
+    #[test]
+    fn stepping_the_cursor_does_not_rewire_the_nodes_it_lands_on() {
+        use crate::window::WindowEvent;
+        fn key(state: cce_ui::widget::ElementState) -> cce_ui::widget::KeyEvent {
+            cce_ui::widget::KeyEvent {
+                state,
+                logical_key: Key::Character("k".into()),
+                text: Some("k".into()),
+                repeat: false,
+                ctrl: false,
+                shift: false,
+                alt: false,
+            }
+        }
+        fn params(s: &State) -> Vec<(String, Vec<(String, String)>)> {
+            s.current_dir()
+                .children
+                .iter()
+                .map(|c| (c.name.clone(), c.params.iter().map(|p| (p.name.clone(), p.default.clone())).collect()))
+                .collect()
+        }
+        let mut s = State::new(false);
+        let mut r = false;
+        let base = s.current_dir().children.len();
+        // A column of its own, clear of the bundled project's nodes: the
+        // chain's shape from the report, Output at the bottom.
+        for (t, y) in [("Attribute", 3.0), ("Relax", 5.0), ("Detangle", 6.0), ("Output", 7.0)] {
+            s.apply_action(McpAction::AddNode { template_name: t.into(), name: None, x: 13.0, y }, &mut r).unwrap();
+        }
+        let names: Vec<String> = s.current_dir().children[base..].iter().map(|c| c.name.clone()).collect();
+        for i in 1..4 {
+            s.apply_action(McpAction::SetParam { slot: base + i, name: "Input".into(), value: names[i - 1].clone() }, &mut r)
+                .unwrap();
+        }
+        s.focused_pane = LEFT_MENUBAR_IDX;
+        s.param_editor = crate::slots::CONTENT_IDX;
+        s.grid_cursor_col = 13;
+        s.grid_cursor_row = 7;
+        s.sync_cursor_and_selection();
+        s.sync_parameters_pane();
+        let before = params(&s);
+
+        for row in [6, 5] {
+            s.process_window_event(WindowEvent::KeyboardInput { event: key(cce_ui::widget::ElementState::Pressed) });
+            s.process_window_event(WindowEvent::KeyboardInput { event: key(cce_ui::widget::ElementState::Released) });
+            assert_eq!(s.grid_cursor_row, row, "the step has to have happened, or the check is vacuous");
+        }
+        assert_eq!(s.graph().selected_node(), Some(base + 1), "the cursor landed on the relax");
+        assert_eq!(params(&s), before, "no node's parameters may change from walking over it");
+
+        // And the guard does not stand in the way of the pane it protects:
+        // an edit to the node it now shows still lands.
+        let rows: Vec<(String, String, String)> = s
+            .param()
+            .node_params()
+            .into_iter()
+            .map(|(n, v, t)| if n == "Iterations" { (n, "12".into(), t) } else { (n, v, t) })
+            .collect();
+        s.param_mut().set_display_params(&rows);
+        s.sync_parameters_to_project();
+        let relax = &s.current_dir().children[base + 1];
+        assert_eq!(crate::geometry::node_param_str(relax, "Iterations", ""), "12");
+    }
+
     #[test]
     fn test_the_network_navigation_families() {
         use crate::slots::{LEFT_MENUBAR_IDX, RIGHT_MENUBAR_IDX};