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

commit2e7c175397b5880746002e5a2b12159034b829fe
parentf83f0073ca
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-06 19:17
fix(network): a dragged node always snaps to a cell it could land on

The designer passed config.kdl's graph grid_snap (off on the user's
machine) to its editors, so a dragged node floated freely and landed
somewhere else. Snapping is always on now, and cce-ui's drag snaps only
to cells the node could land on.

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

 CLAUDE.md   |  7 +++++++
 src/app.rs  | 17 +++++++++--------
 src/main.rs | 10 +++++++++-
 3 files changed, 25 insertions(+), 9 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 8e9c49e..6834135 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1423,6 +1423,13 @@ position write-back carries into the tree, and the release drains
   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.
+- **A dragged node always snaps to a cell it could land on** (the same
+  day): `State::grid_snap_enabled` is always on, and config.kdl's
+  `style.surface.graph.grid_snap` is not read — off on the user's machine,
+  it let a dragged node float freely and land somewhere else. With swap on
+  every crossing is one it could land on (a free one moves it, a node's
+  swaps), so the ghost goes where the pointer is nearest; in a group drag,
+  where swap is off, it skips taken crossings (cce-ui's `drag_update`).
 
 `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.
diff --git a/src/app.rs b/src/app.rs
index 5ea07ca..53835e2 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -2435,9 +2435,8 @@ pub fn configured_grid_geometry() -> GridGeometry {
 /// change to what it checks, made for the sake of a number that is arbitrary
 /// either way. What matters is that the number is the suite's own.
 ///
-/// `graph_grid_snap` is NOT pinned here: it is read inside cce-ui's Graph
-/// widget rather than through this crate, so there is nothing to intercept —
-/// it is off both by cce-ui default and in practice.
+/// `graph_grid_snap` needs no pin: this app does not read it, a dragged node
+/// always snapping to a cell it can land on (`State::grid_snap_enabled`).
 #[cfg(test)]
 pub fn configured_grid_geometry() -> GridGeometry {
     GridGeometry { pitch_x: 140.0, pitch_y: 70.0, node_w: 80.0, node_h: 40.0 }
@@ -3105,6 +3104,9 @@ pub struct State {
     pub physical_height: u32,
     pub scale: f64,
     pub square_viewport: bool,
+    /// Whether a dragged node snaps to cells: always, in this app — to a
+    /// cell it can land on (cce-ui's `Graph::drag_update`). Not read from
+    /// `style.surface.graph.grid_snap`.
     pub grid_snap_enabled: bool,
     pub network_grid_visible: bool,
     /// The network grid's pitch at the current zoom — centre of one grid
@@ -9084,7 +9086,6 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         let opacity = cce_ui::color::graph_opacity();
         let node_opacity = cce_ui::color::graph_node_opacity();
         let graph_grid_color = cce_ui::color::graph_grid_color();
-        let snap_enabled = cce_ui::layout::graph_grid_snap();
         let node_color = cce_ui::color::graph_node_color();
         let node_compression = cce_ui::config::cached_config()
             .pointer("/style/surface/graph/node_compression")
@@ -9114,10 +9115,10 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             colors::set_node_color(node_color);
             changed = true;
         }
-        if self.grid_snap_enabled != snap_enabled {
-            self.grid_snap_enabled = snap_enabled;
-            changed = true;
-        }
+        // `graph_grid_snap` is not read: a dragged node always snaps to a
+        // cell it can land on (`grid_snap_enabled`, always on — since
+        // 2026-10-06; the config's switch, off on the user's machine, had a
+        // dragged node float freely and land somewhere else).
         // The grid's spacing and node size: re-applied at the zoom in hand,
         // so a config.kdl edit to `spacing_y` shows at once. Until
         // 2026-10-06 the live geometry was set at startup and only zoomed
diff --git a/src/main.rs b/src/main.rs
index 6443de3..0c9ef5b 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -16276,9 +16276,17 @@ mod tests {
             state.handle_event(&WindowEvent::CursorMoved { position: LocalPosition { x: x as f64, y: y as f64 } });
         };
 
-        // Grab b and drop it on a.
+        // Grab b and drop it on a — the pointer between cells, not on a
+        // crossing: the node snaps, whatever the config's grid_snap says.
+        state.update_graph_settings_from_config();
+        assert!(state.grid_snap_enabled, "a dragged node always snaps");
         move_to(&mut state, (1, 6));
         state.handle_event(&WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left });
+        let (ax, ay) = state.cell_center(1, 5);
+        state.handle_event(&WindowEvent::CursorMoved { position: LocalPosition { x: (ax + 23.0) as f64, y: (ay + 11.0) as f64 } });
+        let ghost = state.slots.content.inner().node_rect(slot(&state, "b")).unwrap();
+        let cell = state.cell_rect(1, 5);
+        assert!((ghost.0 - cell.0).abs() < 0.5 && (ghost.1 - cell.1).abs() < 0.5, "the dragged node sits on a's cell: {ghost:?} vs {cell:?}");
         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 });