graphic design tool
git clone https://git.lucas.co/cce-designer.git
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(¶ms);
+ 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};