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