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