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

commita0ff69185b80810a117a97320ba38d63598a4871
parent6c3b495d13
authorLucas Galante <[email protected]>
date2026-09-23 10:13
feat: dragging a selected node carries the whole selection

The widget drags one node — it has one `dragging_idx` — so the companions
move here, rigidly, by the offset the dragged node has travelled. Their
offsets are measured from the cells they started on rather than stepped
each frame: a drag is continuous but resolves to whole cells, so
accumulating the steps would drift the group apart the first time two
motion events named one cell.

The preview follows `drop_target_cell_rect`, which runs commit_drag's own
resolution, and the release re-lays the companions from the cell that
actually committed, since the widget can walk the dragged node a cell aside
from where the preview put it. They are not walked off occupied cells
themselves: a selection that rearranged itself around whatever it passed
over would not be the selection you picked up, which is the bargain
alt+hjkl has always made.

Two things make the gesture possible. A press on a node INSIDE the
selection now leaves the cursor alone — the press path otherwise moves the
anchor onto the pressed node, which is exactly what collapses a region, so
the selection would be gone before the drag began; a press on a node
outside it still moves the anchor, and that collapse is the right one.
And `read_panel_offsets` returns early while a group drag is live, for the
same reason.

On release the region is shifted by the committed offset, as alt+hjkl
shifts it.

Co-Authored-By: Claude Opus 5 <[email protected]>

 CLAUDE.md   |  27 ++++++++++++--
 src/app.rs  | 114 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
 src/main.rs |  80 ++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 216 insertions(+), 5 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index e824b64..d7e2f52 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -97,7 +97,7 @@ engine's shaping/glyph pass (the app has no `FontSystem` or buffer cache of its
 `cce_ui::cosmic_text`; `glyphon` is not a dependency of this crate at all, having
 gone from cce-ui with the wgpu path).
 
-- `src/app.rs` (~8k lines) — the heart: `State` (the entire app model), `McpAction` /
+- `src/app.rs` (~8.9k lines) — the heart: `State` (the entire app model), `McpAction` /
   `CustomEvent`, node-template loading, pane layout. `tick_frame` (simulation:
   config polling, inertia, widget ticks) and `stage_frame` (renderer staging) are the
   two halves of the old render loop. GPU mesh updates are staged CPU-side
@@ -848,7 +848,7 @@ on a cell it covers instead. Its anchor is empty grid by construction — a
 press on a node drags the node — so there is no single selection to defer to.
 
 The network's operations act on that selection: **Delete**, the **`e`**
-geometry toggle, **Ctrl+C/X** and **alt+hjkl**. Two rules worth keeping:
+geometry toggle, **Ctrl+C/X**, **alt+hjkl**, and the **mouse**. Two rules worth keeping:
 deletions run HIGHEST SLOT FIRST, or removing one shifts the slots above it
 and the second removal takes the wrong node; and the `e` toggle sets the whole
 selection to the opposite of the FIRST node's flag rather than flipping each,
@@ -860,6 +860,29 @@ a paste keeps the SHAPE it was copied in: the set's top-left lands on the
 cursor and each node keeps its offset, with a node whose cell is taken
 stepping aside to the nearest free one.
 
+**Dragging a selected node carries the whole selection** (`NodeDragGroup`,
+`drag_group_to`). The widget drags ONE node — it has one `dragging_idx` — so
+the companions are moved here, rigidly, by the offset the dragged node has
+travelled, measured from the cells they started on rather than stepped each
+frame (a drag is continuous but resolves to whole cells, so accumulating the
+steps would drift the group apart the first time two motions named one cell).
+They are NOT walked off occupied cells the way the widget walks the node it
+drags: a selection that rearranged itself around whatever it passed over would
+not be the selection you picked up — the same bargain alt+hjkl has always made.
+The preview follows `drop_target_cell_rect`, which runs `commit_drag`'s own
+resolution, and the release re-lays them from the cell that actually committed,
+since the widget can walk the dragged node a cell aside from the preview.
+
+Two things make that gesture work at all. **A press on a node inside the
+selection leaves the cursor alone**: the press path otherwise moves the anchor
+onto the pressed node, which is exactly what collapses a region, so the
+selection would be gone before the drag began. A press on a node OUTSIDE the
+selection does move it, and that collapse is the right one — clicking an
+unselected node selects that node. And `read_panel_offsets` returns early while
+a group drag is live, for the same reason: it yanks the cursor onto the
+selected node's cell, and the anchor is deliberately standing still. On release
+the region is shifted by the committed offset, as alt+hjkl shifts it.
+
 Escape collapses the region (`deselect_node`), because of the two selections
 this is the one that needs clearing: a single selection under a plain cursor
 comes back on the next sync anyway, while a region stands until the cursor is
diff --git a/src/app.rs b/src/app.rs
index 5ad1c67..8ecab5b 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -376,6 +376,25 @@ pub enum NetworkMenuAction {
     Separator,
 }
 
+/// A mouse drag that moves the whole selection, not just the node under the
+/// pointer.
+///
+/// The widget drags ONE node — it has one `dragging_idx` — so the companions
+/// are moved here, rigidly, by the offset the dragged node has travelled.
+/// Their ORIGINAL cells are kept rather than stepped each frame: a drag is a
+/// continuous gesture reported in whole cells, so accumulating the steps would
+/// drift the group apart the first time two motion events resolved to the same
+/// cell.
+#[derive(Debug, Clone)]
+pub struct NodeDragGroup {
+    /// The slot the pointer grabbed — the widget's own dragged node.
+    pub dragged: usize,
+    /// The cell it started on; every offset is measured from here.
+    pub from: (i32, i32),
+    /// The rest of the selection, with the cells they started on.
+    pub others: Vec<(usize, (f32, f32))>,
+}
+
 /// The command ids the network context menu offers, in order; `None` is a
 /// separator. Add Node leads because right-clicking empty space USED to open
 /// the add-node palette outright, and that is still the common reason to come
@@ -1426,6 +1445,9 @@ pub struct State {
     /// that gets missed at one of them, and a cursor left stretched across
     /// the sheet is not a subtle wrong.
     pub grid_cursor_expanse: Option<((i32, i32), (i32, i32))>,
+    /// A node drag that is carrying the whole selection. `None` for an
+    /// ordinary one-node drag, which the widget handles by itself.
+    pub node_drag_group: Option<NodeDragGroup>,
     /// The anchor of a LIVE expansion drag — a left press on empty grid,
     /// cleared on release. `Some` is what makes motion grow the region
     /// rather than do nothing; the region it leaves behind outlives it.
@@ -4585,6 +4607,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             grid_cursor_row: 0,
             grid_cursor_expanse: None,
             grid_cursor_drag: None,
+            node_drag_group: None,
             modifiers: ModifiersState::default(),
             width: lw,
             height: lh,
@@ -5871,6 +5894,28 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         true
     }
 
+    /// Lay the drag group's companions out around `dest`, the cell the dragged
+    /// node is on (or heading for), keeping the shape the selection had when
+    /// the drag began.
+    ///
+    /// They translate rigidly and are NOT walked off occupied cells the way
+    /// the widget walks the node it drags: a whole selection that rearranged
+    /// itself around whatever it passed over would not be the selection you
+    /// picked up. It is the same bargain `network_move_node` has always made
+    /// with alt+hjkl.
+    fn drag_group_to(&mut self, dest: (i32, i32)) {
+        let Some(group) = self.node_drag_group.clone() else { return };
+        let (dc, dr) = (dest.0 - group.from.0, dest.1 - group.from.1);
+        let len = self.current_dir().children.len();
+        for (slot, origin) in group.others {
+            if slot < len {
+                self.current_dir_mut().children[slot].position =
+                    (origin.0 + dc as f32, origin.1 + dr as f32);
+            }
+        }
+        self.sync_nodes();
+    }
+
     /// Close an expansion drag by shrinking the region onto what it caught:
     /// the bounding box of the selected nodes, or — with nothing caught — one
     /// cell at the middle of where the region stood.
@@ -6460,6 +6505,12 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                 child.position = node.position;
             }
         }
+        // Not while a group drag is carrying the selection: the anchor is
+        // holding the region still on purpose, and yanking it onto the
+        // dragged node's cell would collapse the selection mid-gesture.
+        if self.node_drag_group.is_some() {
+            return;
+        }
         if let Some(sel_idx) = self.graph().selected_node() {
             if let Some(node) = updated_nodes.get(sel_idx) {
                 self.grid_cursor_col = node.position.0 as i32;
@@ -6916,6 +6967,19 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                             unsafe { (*ptr).handle_event(&ev, &mut self.ui_context) }
                         } {
                             changed = true;
+                            if idx == CONTENT_IDX && self.node_drag_group.is_some() {
+                                // The companions follow in whole cells, from
+                                // where the widget says the dragged body will
+                                // LAND (`drop_target_cell_rect` runs
+                                // commit_drag's own resolution) — so the
+                                // selection previews the same arrangement the
+                                // release will make, rather than one measured
+                                // off the free-floating ghost.
+                                if let Some((rx, ry, rw, rh)) = self.graph().drop_target_cell_rect() {
+                                    let dest = self.cell_at(rx + rw * 0.5, ry + rh * 0.5);
+                                    self.drag_group_to(dest);
+                                }
+                            }
                             if idx == PARAM_IDX {
                                 self.sync_parameters_to_project();
                             } else if idx == crate::slots::DIALOG_PARAMS_IDX {
@@ -7480,8 +7544,34 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                                     let dir = self.current_dir();
                                     if slot_idx < dir.children.len() {
                                         let pos = dir.children[slot_idx].position;
-                                        self.grid_cursor_col = pos.0 as i32;
-                                        self.grid_cursor_row = pos.1 as i32;
+                                        // A press on a node INSIDE the
+                                        // selection picks up the whole of it
+                                        // and leaves the cursor alone: moving
+                                        // the anchor onto the pressed node is
+                                        // exactly what collapses a region, so
+                                        // the selection would be gone before
+                                        // the drag began. A press on a node
+                                        // outside it does move the anchor, and
+                                        // that collapse is the right one —
+                                        // clicking an unselected node selects
+                                        // that node.
+                                        let selected = self.selected_slots();
+                                        if selected.len() > 1 && selected.contains(&slot_idx) {
+                                            let cells: Vec<(usize, (f32, f32))> = selected
+                                                .iter()
+                                                .copied()
+                                                .filter(|&i| i != slot_idx)
+                                                .map(|i| (i, dir.children[i].position))
+                                                .collect();
+                                            self.node_drag_group = Some(NodeDragGroup {
+                                                dragged: slot_idx,
+                                                from: (pos.0 as i32, pos.1 as i32),
+                                                others: cells,
+                                            });
+                                        } else {
+                                            self.grid_cursor_col = pos.0 as i32;
+                                            self.grid_cursor_row = pos.1 as i32;
+                                        }
                                     }
                                 } else {
                                     // Empty grid: the cursor goes to the cell
@@ -7596,7 +7686,25 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                                         child.position = node.position;
                                     }
                                 }
-                                if let Some(sel_idx) = self.graph().selected_node() {
+                                if let Some(group) = self.node_drag_group.take() {
+                                    // Re-lay the companions from the cell the
+                                    // dragged node actually COMMITTED to: the
+                                    // widget walks it off an occupied cell, so
+                                    // the preview's offset can be a cell out
+                                    // from the one that landed.
+                                    let dest = updated_nodes
+                                        .get(group.dragged)
+                                        .map(|n| (n.position.0 as i32, n.position.1 as i32))
+                                        .unwrap_or(group.from);
+                                    self.node_drag_group = Some(group.clone());
+                                    self.drag_group_to(dest);
+                                    self.node_drag_group = None;
+                                    // The region travels with what it holds,
+                                    // as it does for alt+hjkl — the anchor sat
+                                    // still through the drag precisely so it
+                                    // would still be there to move.
+                                    self.shift_grid_cursor(dest.0 - group.from.0, dest.1 - group.from.1);
+                                } else if let Some(sel_idx) = self.graph().selected_node() {
                                     if let Some(node) = updated_nodes.get(sel_idx) {
                                         self.grid_cursor_col = node.position.0 as i32;
                                         self.grid_cursor_row = node.position.1 as i32;
diff --git a/src/main.rs b/src/main.rs
index 6d9d10c..41ba624 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -8759,6 +8759,86 @@ mod tests {
         assert_eq!(state.grid_cursor_region(), (3, 5, 1, 1));
     }
 
+    /// Dragging a node that is part of the selection carries the whole
+    /// 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.
+    #[test]
+    fn dragging_a_selected_node_carries_the_selection() {
+        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;
+        state.param_editor = crate::slots::CONTENT_IDX;
+
+        let mut redraw = false;
+        for (name, x, y) in [("a", 1.0, 5.0), ("b", 2.0, 7.0), ("c", 1.0, 11.0)] {
+            state
+                .apply_action(
+                    crate::app::McpAction::AddNode {
+                        template_name: "Plane".into(),
+                        name: Some(name.into()),
+                        x,
+                        y,
+                    },
+                    &mut redraw,
+                )
+                .unwrap();
+        }
+        state.rebuild_positions();
+        state.apply_layout();
+        let slot = |state: &State, name: &str| {
+            state.current_dir().children.iter().position(|c| c.name == name).expect(name)
+        };
+        let (a, b, c) = (slot(&state, "a"), slot(&state, "b"), slot(&state, "c"));
+
+        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 },
+            });
+        };
+
+        // Select a and b by dragging a box round them; it settles onto their
+        // bounding box, (1, 5) to (2, 7).
+        move_to(&mut state, (0, 4));
+        state.handle_event(&WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left });
+        move_to(&mut state, (3, 9));
+        state.handle_event(&WindowEvent::MouseInput { state: ElementState::Released, button: MouseButton::Left });
+        assert_eq!(state.grid_cursor_region(), (1, 5, 2, 3));
+        assert_eq!(state.selected_slots(), vec![a, b]);
+
+        // Grab b — one of the selected — and drag it one cell right and one
+        // down. Both travel; c, unselected, does not.
+        move_to(&mut state, (2, 7));
+        state.handle_event(&WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left });
+        assert!(state.node_drag_group.is_some(), "the press picked up the selection");
+        assert_eq!(state.grid_cursor_region(), (1, 5, 2, 3), "the press left the region alone");
+        move_to(&mut state, (3, 8));
+        state.handle_event(&WindowEvent::MouseInput { state: ElementState::Released, button: MouseButton::Left });
+
+        assert!(state.node_drag_group.is_none());
+        assert_eq!(state.current_dir().children[b].position, (3.0, 8.0), "the grabbed node");
+        assert_eq!(state.current_dir().children[a].position, (2.0, 6.0), "carried along");
+        assert_eq!(state.current_dir().children[c].position, (1.0, 11.0), "not selected");
+        assert_eq!(state.grid_cursor_region(), (2, 6, 2, 3), "the region came too");
+        assert_eq!(state.selected_slots(), vec![a, b], "still the same two");
+
+        // Grabbing c, which is NOT selected, is the ordinary one-node drag:
+        // the anchor moves onto it and the region collapses with it.
+        move_to(&mut state, (1, 11));
+        state.handle_event(&WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left });
+        assert!(state.node_drag_group.is_none(), "one node, the widget's own drag");
+        assert_eq!(state.grid_cursor_region(), (1, 11, 1, 1), "collapsed onto what was grabbed");
+        move_to(&mut state, (0, 11));
+        state.handle_event(&WindowEvent::MouseInput { state: ElementState::Released, button: MouseButton::Left });
+        assert_eq!(state.current_dir().children[c].position, (0.0, 11.0));
+        assert_eq!(state.current_dir().children[a].position, (2.0, 6.0), "a stayed put");
+    }
+
     /// A drag that CAUGHT nodes settles onto their bounding box — the loose
     /// box you drew comes back fitted to what it selected, and the selection
     /// itself does not change, the box containing no cell the region did not.