git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit525b66e98b4007e27808b276999d077100d7f93b
parentfce84f5bc8
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-06 19:16
fix(graph): a snapped drag sits only on a cell it could land on

With grid snap on, the dragged node snapped to the nearest crossing even
when another node stood there, and the release then walked it elsewhere.
It now snaps to the nearest crossing it could land on: free, or a swap
target when swap-on-drop is on, else the nearest free one.

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

 CLAUDE.md                   |  7 +++++++
 src/widget/display/graph.rs | 24 ++++++++++++++++++++++--
 2 files changed, 29 insertions(+), 2 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index da01128..74b7d2d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1681,6 +1681,13 @@ one as it always did, so cce-files and cce-graph see no change.
   are never both reported.
 - **A host turns it off for a multi-node drag**: the widget drags one node,
   and one of a group trading places would scatter the rest.
+- **A snapped drag sits only where it could land** (`drag_update`, since
+  the same day): the nearest crossing when it is free or a swap target,
+  else the nearest free one — `find_empty_cell`, the walk `commit_drag`
+  makes — so the ghost never stands over a node it cannot stay on, and
+  `drop_target_cell_rect` is where it is. It snapped to the nearest
+  crossing whatever stood there until then. Unsnapped drags (a host with
+  `grid_snap` off) are unchanged.
 
 `a_node_dropped_on_a_node_swaps_with_it` is the test.
 
diff --git a/src/widget/display/graph.rs b/src/widget/display/graph.rs
index 5fab467..9a2376d 100644
--- a/src/widget/display/graph.rs
+++ b/src/widget/display/graph.rs
@@ -1304,9 +1304,23 @@ impl Input for Graph {
             let nx = px - self.drag_ox;
             let ny = py - self.drag_oy;
 
-            // Snapping centres the body on the nearest intersection.
+            // Snapping centres the body on the nearest intersection a drop
+            // could LAND on: an empty one, or another node's when a drop
+            // there swaps the two (`set_swap_on_drop`); otherwise the
+            // nearest free one, which is where `commit_drag` would walk it
+            // — so the body never sits where it cannot stay. It snapped to
+            // the nearest crossing whatever stood there until 2026-10-06,
+            // and showed a node over another until the release moved it.
             let (nx, ny) = match self.nearest_cell(nx, ny) {
-                Some((c, r)) if self.grid_snap_enabled => self.cell_origin(c, r),
+                Some((c, r)) if self.grid_snap_enabled => {
+                    let dragged = self.dragging_idx.unwrap_or(usize::MAX);
+                    let (c, r) = if self.swap_candidate(dragged, nx, ny).is_some() {
+                        (c, r)
+                    } else {
+                        self.find_empty_cell(c, r, self.dragging_idx)
+                    };
+                    self.cell_origin(c, r)
+                }
                 _ => (nx, ny),
             };
 
@@ -1913,6 +1927,7 @@ mod tests {
 
         let (mut g, mut ctx) = build(true);
         assert_eq!(g.swap_target_idx(), Some(1), "beta is the swap target while the ghost is on it");
+        assert_eq!(g.node_rect(0), g.node_rect(1), "the ghost sits on beta's cell, where a swap lands");
         assert_eq!(g.drop_target_cell_rect(), g.node_rect(1), "and its cell is where the drop lands");
         assert!(g.mouse_input(MouseButton::Left, ElementState::Released, 140.0, 240.0, &mut ctx));
         assert_eq!(GraphController::take_pending_swap(&mut *g), Some(("a".to_string(), "b".to_string())));
@@ -1923,6 +1938,11 @@ mod tests {
 
         let (mut g, mut ctx) = build(false);
         assert_eq!(g.swap_target_idx(), None);
+        // No swap to make: the ghost snaps to the free cell a drop walks to,
+        // not over beta, where it could not stay.
+        let ghost = g.node_rect(0).unwrap();
+        assert_ne!(Some(ghost), g.node_rect(1), "the ghost does not sit on a taken cell");
+        assert_eq!(Some(ghost), g.drop_target_cell_rect(), "it sits where the drop lands");
         assert!(g.mouse_input(MouseButton::Left, ElementState::Released, 140.0, 240.0, &mut ctx));
         assert_eq!(GraphController::take_pending_swap(&mut *g), None);
         let nodes = GraphController::get_nodes(&*g);