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

commitb4e9f43a33139eb8b01297488339a94ea2026e6d
parent3300ad3f31
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-06 17:28
feat(network): dropping a node on a node swaps their places

Both editors turn on cce-ui's swap-on-drop. The widget trades the two
cells; swap_places trades the connections, as a renaming of the two in
every wire plus their own wires port for port, so a chain's order
changes (I → A → B → C, B onto A: I → B → A → C). One undo step; a
multi-node drag does not swap.

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

 CLAUDE.md   |  27 +++++++++++++++
 src/app.rs  | 113 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/main.rs |  93 +++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 233 insertions(+)

diff --git a/CLAUDE.md b/CLAUDE.md
index 25b0b15..b067163 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1389,6 +1389,33 @@ one, and the wires between pasted nodes follow; a wire to a node that was
 not copied still names that node.
 `a_paste_on_a_wire_is_spliced_into_its_chain` is the test.
 
+### Dropping a node on a node swaps their places (since 2026-10-06)
+
+A node dragged onto another node trades places with it, connections and
+all — the chain's ORDER changes, not just the picture: in I → A → B → C,
+B dropped on A gives I → B → A → C. Both network editors turn cce-ui's
+`Graph::set_swap_on_drop` on (see its CLAUDE.md, "A node dropped on a node
+can swap with it"); the widget trades the two cells, which the drag's
+position write-back carries into the tree, and the release drains
+`take_pending_swap` into `app::swap_places`, which trades the wires.
+
+- **The rule is a renaming σ (A ↔ B) of every wire**: a third node's wire
+  w becomes σ(w); A's k-th wire becomes σ of B's k-th and B's σ of A's,
+  port for port (`node` parameters in order, as `node_wires` numbers
+  them), so a wire between the two turns round. A port only one of them
+  has keeps its own wire, σ'd — a Relax dropped on a Pull keeps its Rest.
+  An expression wire moves as it is, unrewritten.
+- **One undo step**: positions and wires are both structure, noticed at
+  the end of the event (`record_structure_changes`).
+- **A multi-node drag does not swap** — `set_swap_on_drop(false)` while a
+  `NodeDragGroup` is armed — since the widget drags one node and the rest
+  follow by an offset.
+- **The swap wins over a splice**: a node's own wires run into its body,
+  so a ghost on a node always touches one.
+
+`dropping_a_node_on_a_node_swaps_their_places` drives it by pointer, undo
+included; `swapping_places_trades_wires_port_for_port` is the rule.
+
 ### Sibling-first inputs and the Switch node
 
 Two pieces added on 2026-09-21 so a node can be BUILT FROM other nodes
diff --git a/src/app.rs b/src/app.rs
index 3dd55f4..c132260 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -940,6 +940,88 @@ pub(crate) fn splice_chain_into_wire(dir: &mut FsNode, head_id: &str, tail_id: &
     true
 }
 
+/// Swap the places of the children `a_id` and `b_id` of `dir` in the
+/// graph: each takes the other's wires, and every wire that named the one
+/// names the other. The positions are the widget's to trade (a node dropped
+/// on a node, `Graph::set_swap_on_drop`); this trades the connections, so
+/// in I → A → B → C dragging B onto A gives I → B → A → C — the chain's
+/// order, not just the picture of it. Written as a renaming σ (A ↔ B) of
+/// what each wire holds: a third node's wire w becomes σ(w); A's k-th wire
+/// becomes σ of B's k-th and B's σ of A's, port for port, so a wire between
+/// the two turns round. A port only one of them has keeps its own wire,
+/// σ'd. An expression wire is moved as it is, unrewritten: what it names
+/// is not a text to rename. False, and nothing written, when either is not
+/// there.
+pub(crate) fn swap_places(dir: &mut FsNode, a_id: &str, b_id: &str) -> bool {
+    let (Some(ai), Some(bi)) = (
+        dir.children.iter().position(|c| c.id == a_id),
+        dir.children.iter().position(|c| c.id == b_id),
+    ) else {
+        return false;
+    };
+    if ai == bi {
+        return false;
+    }
+    let (a, b) = (dir.children[ai].name.clone(), dir.children[bi].name.clone());
+    let sigma = |w: &str| -> String {
+        let t = w.trim();
+        if t == a {
+            b.clone()
+        } else if t == b {
+            a.clone()
+        } else {
+            w.to_string()
+        }
+    };
+    // Each wire port of a node, in order: (param index, text, expression).
+    let ports = |n: &FsNode| -> Vec<(usize, String, bool)> {
+        n.params
+            .iter()
+            .enumerate()
+            .filter(|(_, p)| p.kind() == ParamKind::Node)
+            .map(|(i, p)| (i, p.text().to_string(), p.is_expr()))
+            .collect()
+    };
+    let (pa, pb) = (ports(&dir.children[ai]), ports(&dir.children[bi]));
+    let moved = |(text, expr): (&String, bool)| if expr { (text.clone(), true) } else { (sigma(text), false) };
+    // What each of the two will hold, port for port, from the wires as
+    // they stood.
+    let new_for = |own: &[(usize, String, bool)], other: &[(usize, String, bool)]| -> Vec<(usize, String, bool)> {
+        own.iter()
+            .enumerate()
+            .map(|(k, (i, text, expr))| {
+                let (t, e) = match other.get(k) {
+                    Some((_, ot, oe)) => moved((ot, *oe)),
+                    None => moved((text, *expr)),
+                };
+                (*i, t, e)
+            })
+            .collect()
+    };
+    let (na, nb) = (new_for(&pa, &pb), new_for(&pb, &pa));
+    for (slot, child) in dir.children.iter_mut().enumerate() {
+        if slot == ai || slot == bi {
+            continue;
+        }
+        for p in child.params.iter_mut() {
+            if p.kind() == ParamKind::Node && !p.is_expr() {
+                let t = sigma(p.text());
+                if t != p.text() {
+                    p.set_text(t);
+                }
+            }
+        }
+    }
+    for (slot, wires) in [(ai, na), (bi, nb)] {
+        for (i, text, expr) in wires {
+            let p = &mut dir.children[slot].params[i];
+            p.set_text(text);
+            p.set_expr(expr);
+        }
+    }
+    true
+}
+
 /// [`node_wires_at`] with nothing evaluated: an expression wire names
 /// nothing.
 pub fn node_wires(node: &FsNode) -> Vec<(String, String)> {
@@ -8208,6 +8290,10 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         let wire_style = cce_ui::widget::display::WireStyle::parse(&settings.viewport.node_wire_style);
         slots.content.inner_mut().set_wire_style(wire_style);
         slots.content2.inner_mut().set_wire_style(wire_style);
+        // A node dropped on a node swaps places with it, connections and all
+        // (`swap_places`).
+        slots.content.inner_mut().set_swap_on_drop(true);
+        slots.content2.inner_mut().set_swap_on_drop(true);
         slots.playbar.inner_mut().fps = settings.playbar_fps.clamp(1.0, 120.0);
         if let Some(viewport) = slots.viewport.as_any_mut().downcast_mut::<Viewport3D>() {
             viewport.show_grid = settings.viewport.show_grid_enabled;
@@ -11697,7 +11783,13 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                                                 from: (pos.0 as i32, pos.1 as i32),
                                                 others: cells,
                                             });
+                                            // A group does not swap: one of
+                                            // it trading places would leave
+                                            // the rest where the offset put
+                                            // them, the selection scattered.
+                                            self.slots.content.inner_mut().set_swap_on_drop(false);
                                         } else {
+                                            self.slots.content.inner_mut().set_swap_on_drop(true);
                                             self.grid_cursor_col = pos.0 as i32;
                                             self.grid_cursor_row = pos.1 as i32;
                                         }
@@ -11895,6 +11987,18 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                     changed |= self.connect_port(&path, &input_node_id, output_node_name, port);
                 }
 
+                // A node dropped onto a node swaps places with it: the widget
+                // traded their cells (written back with the drag above), and
+                // the connections are traded here.
+                if let Some((a_id, b_id)) = self.graph_mut().take_pending_swap() {
+                    if swap_places(self.current_dir_mut(), &a_id, &b_id) {
+                        self.sync_nodes();
+                        self.rebuild_scene_geometry();
+                        self.sync_parameters_pane();
+                        changed = true;
+                    }
+                }
+
                 // A node dropped onto a wire splices in between its ends:
                 // the dragged node inherits the wire's upstream as its
                 // Input, and the wire's downstream node re-aims its Input
@@ -11931,6 +12035,15 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                         let p2 = self.current_path2.clone();
                         changed |= self.connect_port(&p2, &input_node_id, output_node_name, port);
                     }
+                    if let Some((a_id, b_id)) = self.slots.content2.take_pending_swap() {
+                        let p2 = self.current_path2.clone();
+                        if swap_places(self.dir_at_mut(&p2), &a_id, &b_id) {
+                            self.sync_nodes();
+                            self.rebuild_scene_geometry();
+                            self.sync_parameters_pane();
+                            changed = true;
+                        }
+                    }
                     if let Some((mid_id, src_name, dest_id)) =
                         self.slots.content2.take_pending_splice()
                     {
diff --git a/src/main.rs b/src/main.rs
index 27de283..ad4c613 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -16142,6 +16142,99 @@ mod tests {
     /// selection with it, rigidly, and the region travels too. Dragging a node
     /// OUTSIDE the selection is the ordinary one-node drag, and collapses the
     /// selection onto what was grabbed.
+    /// A node dropped on another node swaps places with it, connections
+    /// and all: in sphere1 → a → b → c, dragging b onto a leaves b where a
+    /// was and a where b was, wired sphere1 → b → a → c. One undo puts both
+    /// the places and the wires back.
+    #[test]
+    fn dropping_a_node_on_a_node_swaps_their_places() {
+        use crate::window::{LocalPosition, WindowEvent};
+        use cce_ui::widget::{ElementState, MouseButton};
+        let mut state = State::new(false);
+        state.resize(1600.0, 900.0, 1.0);
+        state.rebuild_positions();
+        state.apply_layout();
+        state.focused_pane = crate::slots::LEFT_MENUBAR_IDX;
+        let mut redraw = false;
+        for (name, y) in [("a", 5.0), ("b", 6.0), ("c", 7.0)] {
+            state
+                .apply_action(McpAction::AddNode { template_name: "Attribute".into(), name: Some(name.into()), x: 1.0, y }, &mut redraw)
+                .unwrap();
+        }
+        let slot = |state: &State, name: &str| state.current_dir().children.iter().position(|c| c.name == name).expect(name);
+        for (name, from) in [("a", "sphere1"), ("b", "a"), ("c", "b")] {
+            let slot = slot(&state, name);
+            state.apply_action(McpAction::SetParam { slot, name: "input".into(), value: from.into() }, &mut redraw).unwrap();
+        }
+        state.edit_history.break_group();
+        state.rebuild_positions();
+        state.apply_layout();
+        let input = |state: &State, name: &str| {
+            crate::geometry::node_param_str(&state.current_dir().children[slot(state, name)], "input", "").to_string()
+        };
+        let at = |state: &State, name: &str| state.current_dir().children[slot(state, name)].position;
+        let move_to = |state: &mut State, (col, row): (i32, i32)| {
+            let (x, y) = state.cell_center(col, row);
+            state.handle_event(&WindowEvent::CursorMoved { position: LocalPosition { x: x as f64, y: y as f64 } });
+        };
+
+        // Grab b and drop it on a.
+        move_to(&mut state, (1, 6));
+        state.handle_event(&WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left });
+        move_to(&mut state, (1, 5));
+        assert_eq!(state.slots.content.inner().swap_target_idx(), Some(slot(&state, "a")), "a is the swap target");
+        state.handle_event(&WindowEvent::MouseInput { state: ElementState::Released, button: MouseButton::Left });
+
+        assert_eq!((at(&state, "b"), at(&state, "a")), ((1.0, 5.0), (1.0, 6.0)), "the two traded places");
+        assert_eq!(
+            (input(&state, "b"), input(&state, "a"), input(&state, "c")),
+            ("sphere1".to_string(), "b".to_string(), "a".to_string()),
+            "and their connections: sphere1 → b → a → c"
+        );
+
+        // One step back.
+        state.edit_history.break_group();
+        assert!(state.history_step(true));
+        assert_eq!((at(&state, "a"), at(&state, "b")), ((1.0, 5.0), (1.0, 6.0)));
+        assert_eq!(
+            (input(&state, "a"), input(&state, "b"), input(&state, "c")),
+            ("sphere1".to_string(), "a".to_string(), "b".to_string())
+        );
+    }
+
+    /// The rule a swap trades wires by: a renaming of the two, applied to
+    /// every wire, with the two nodes' own wires traded port for port. A
+    /// port only one of them has keeps its own wire.
+    #[test]
+    fn swapping_places_trades_wires_port_for_port() {
+        use crate::app::{swap_places, FsNode, ParamDef};
+        let node = |name: &str, wires: &[(&str, &str)]| FsNode {
+            id: name.into(),
+            name: name.into(),
+            node_type: "attribute".into(),
+            children: vec![],
+            params: wires.iter().map(|(p, v)| ParamDef::new(*p, "node", *v)).collect(),
+            geometry_visible: true,
+            bypassed: false,
+            position: (0.0, 0.0),
+            inputs: 1,
+            outputs: 1,
+        };
+        let mut dir = node("dir", &[]);
+        dir.children = vec![
+            node("i", &[]),
+            node("a", &[("input", "i")]),
+            node("b", &[("input", "a"), ("rest", "i")]),
+            node("c", &[("input", "b"), ("with", "a")]),
+        ];
+        assert!(swap_places(&mut dir, "a", "b"));
+        let wires = |n: usize| dir.children[n].params.iter().map(|p| p.text().to_string()).collect::<Vec<_>>();
+        assert_eq!(wires(1), ["b"], "a reads b now, b standing where a stood");
+        assert_eq!(wires(2), ["i", "i"], "b takes a's input; its Rest, which a lacks, stays");
+        assert_eq!(wires(3), ["a", "b"], "c's wires follow the swap");
+        assert!(!swap_places(&mut dir, "a", "nope"));
+    }
+
     #[test]
     fn dragging_a_selected_node_carries_the_selection() {
         use crate::window::{LocalPosition, WindowEvent};