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

commit9f1d276f1b8a91246ffadcc77ffec4ebf4527bdc
parent94343c8713
authorLucas Galante <[email protected]>
date2026-09-22 18:24
feat: the expanded cursor selects every node inside it

`State::selected_slots` is the network's selection now, with two arms: one
cell — the ordinary cursor — defers to the graph's own `selected_node`, so
nothing about a single selection changes; an expanded cursor names every
node standing on a cell it covers. Its anchor is empty grid by
construction, so there is no single selection to defer to there.

The selected bodies wear the highlight tint the widget gives its own
selection, recognised by the cell each is centred on — the same
`grid_cursor_covers` the selection is derived from, rather than a second
rect test that could disagree with it.

Delete, the `e` geometry toggle, Ctrl+C/X and alt+hjkl act on the whole
selection. Deletions run highest slot first, or removing one shifts the
slots above it and the next takes the wrong node. The `e` toggle sets the
selection to the opposite of the first node's flag rather than flipping
each, so a toggle over a mixed selection settles it. `network_move_node`
carries the region along with the nodes, since stepping the anchor alone is
what collapses a region — the first alt+h would otherwise drop the
selection it had just moved. The clipboard is a Vec and a paste keeps the
shape it was copied in.

Escape collapses the region: of the two selections it is the one that needs
clearing, a single selection coming back on the next sync anyway.

The graph's own single selection is deliberately NOT set from the region —
`read_panel_offsets` yanks the cursor onto the selected node's cell, which
would move the anchor off its own region and collapse it.

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

 CLAUDE.md     |  53 ++++++++++++---
 src/app.rs    | 213 +++++++++++++++++++++++++++++++++++++++++++++-------------
 src/main.rs   | 171 ++++++++++++++++++++++++++++++++++++++++++++++
 src/render.rs |  16 ++++-
 4 files changed, 397 insertions(+), 56 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index acafce9..c67b2e4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -735,9 +735,12 @@ zoom. Frame Cursor CENTRES the cursor cell; its first version called
 the command did nothing at all in the common case of a cursor that is visible
 but off in a corner — which is exactly when it gets pressed.
 
-`shift+hjkl` — the plugin's extend-the-selection family — is deliberately
-absent. The Graph widget carries a single `selected_node`, so four rows that
-quietly did what bare hjkl already does would be worse than the gap.
+`shift+hjkl` — the plugin's extend-the-selection family — is still absent, but
+the reason has changed. It used to be that the Graph widget carries a single
+`selected_node`, so four rows would quietly have done what bare hjkl already
+does. Since the cursor became a REGION (below) there is a real multi-selection
+to extend, and these four rows are implementable as growing the region's far
+corner; they are simply not written yet.
 
 Two chords moved to make room, both caught by `command::conflicts` rather than
 by hand: `edit_handles` from `Ctrl+H` to `Ctrl+Shift+H` (the ctrl+hjkl family
@@ -806,11 +809,45 @@ those places. Fifteen call sites write the cursor; a flag reset by hand at all
 of them is a flag that gets missed at one, and a cursor left stretched across
 the sheet is not a subtle wrong.
 
-Everything that reads the cursor as a CELL still reads the anchor: Add Node
-places there, Frame Cursor centres it, `sync_cursor_and_selection` selects what
-sits on it. The Graph widget carries a single `selected_node`, so the region
-selects nothing yet — the same limit that keeps `shift+hjkl` out of the
-keyboard scheme.
+**An expanded cursor selects every node standing inside it.**
+`State::selected_slots` is the selection, and it has two arms for a reason:
+one cell — the ordinary cursor — DEFERS to the graph's own `selected_node`,
+so nothing about a single selection changes (that one answer already carries
+the deselect memory, a click that arrived from another pane, and a selection
+made while the network was not focused); an expanded cursor names every node
+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:
+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,
+because a toggle over a mixed selection should settle it, not shuffle it.
+`network_move_node` moves the region along with the nodes — stepping the
+anchor alone is precisely what collapses a region, so the first alt+h would
+otherwise drop the selection it had just moved. The clipboard is a `Vec`, and
+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.
+
+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
+moved off its anchor.
+
+Everything that reads the cursor as ONE CELL still reads the anchor: Add Node
+places there, Frame Cursor centres it, `sync_cursor_and_selection` sets the
+graph's own selection from it. That single selection is deliberately NOT set
+from the region: `read_panel_offsets` yanks the cursor onto the selected
+node's cell, which would move the anchor off its own region and collapse it
+on the next layout sync. So with a region up the params pane shows nothing —
+it shows one node's parameters, and the selection is many.
+
+The paint reads the same `grid_cursor_covers`: a node body is recognised by
+the cell it is centred on and drawn with the highlight tint the widget gives
+its own single selection, rather than by a second rect test that could
+disagree with the selection itself.
 
 Arming is gated on the graph NOT having taken the press (`widget_took`). The
 case that bites is a press on a PORT: it starts a connection and consumes the
diff --git a/src/app.rs b/src/app.rs
index b3c6c6c..0dc685e 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1306,7 +1306,11 @@ pub struct State {
     pub params_pin: Option<usize>,
     /// The spreadsheet's pin — same shape, set from its plate's corner menu.
     pub spreadsheet_pin: Option<usize>,
-    pub node_clipboard: Option<FsNode>,
+    /// The copied nodes, with the positions they were copied FROM — a paste
+    /// lays them back out in the same shape, offset to the cursor. A Vec
+    /// rather than one node because an expanded cursor selects many, and a
+    /// copy that silently took one of them would be a trap.
+    pub node_clipboard: Vec<FsNode>,
     pub last_click: Option<(Instant, usize)>,
 
     pub shortcut_manager: ShortcutManager,
@@ -4538,7 +4542,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             viewport_pin: None,
             params_pin: None,
             spreadsheet_pin: None,
-            node_clipboard: None,
+            node_clipboard: Vec::new(),
             last_click: None,
             shortcut_manager,
             pending_command: None,
@@ -4874,6 +4878,55 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         (x0, y0, x1 - x0 + w, y1 - y0 + h)
     }
 
+    /// Whether the cursor's region stands on lattice cell `(col, row)`.
+    pub fn grid_cursor_covers(&self, col: i32, row: i32) -> bool {
+        let (c, r, cols, rows) = self.grid_cursor_region();
+        col >= c && col < c + cols && row >= r && row < r + rows
+    }
+
+    /// Whether the cursor is expanded past its one cell — what makes a
+    /// selection a MULTIPLE selection, and the one test the paint and the
+    /// operations share.
+    pub fn grid_cursor_expanded(&self) -> bool {
+        let (_, _, cols, rows) = self.grid_cursor_region();
+        cols > 1 || rows > 1
+    }
+
+    /// The nodes the cursor selects, as slots in the current level, in slot
+    /// order.
+    ///
+    /// One cell — the ordinary cursor — defers to the graph's own
+    /// `selected_node`, so nothing about a single selection changes here:
+    /// that one answer already carries the deselect memory, a click that
+    /// arrived from another pane, and a selection made while the network was
+    /// not focused. An EXPANDED cursor names every node standing inside it
+    /// 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.
+    pub fn selected_slots(&self) -> Vec<usize> {
+        if !self.grid_cursor_expanded() {
+            return self.graph().selected_node().into_iter().collect();
+        }
+        self.current_dir()
+            .children
+            .iter()
+            .enumerate()
+            .filter(|(_, c)| self.grid_cursor_covers(c.position.0 as i32, c.position.1 as i32))
+            .map(|(i, _)| i)
+            .collect()
+    }
+
+    /// Move the cursor AND the region it carries by whole cells — what a
+    /// selection being dragged across the sheet needs, since stepping the
+    /// anchor alone is precisely the thing that collapses the region.
+    pub fn shift_grid_cursor(&mut self, dc: i32, dr: i32) {
+        self.grid_cursor_col += dc;
+        self.grid_cursor_row += dr;
+        if let Some((anchor, far)) = self.grid_cursor_expanse {
+            self.grid_cursor_expanse =
+                Some(((anchor.0 + dc, anchor.1 + dr), (far.0 + dc, far.1 + dr)));
+        }
+    }
+
     /// Grow the live expansion drag to the cell under the cursor. `true` when
     /// the region actually changed, which is the redraw.
     fn grid_cursor_drag_motion(&mut self) -> bool {
@@ -5765,6 +5818,16 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
     /// Returns false when nothing was selected, so Escape can fall through to
     /// meaning nothing rather than reporting that it did something.
     pub(crate) fn deselect_node(&mut self) -> bool {
+        // An expanded cursor IS a selection, and of the two this is the one
+        // Escape has to be able to clear: the single selection under a plain
+        // cursor comes back on the next sync anyway, while a region would
+        // stand until the cursor was moved off its anchor.
+        if self.grid_cursor_expanded() {
+            self.grid_cursor_expanse = None;
+            self.grid_cursor_drag = None;
+            self.sync_parameters_pane();
+            return true;
+        }
         if self.graph().selected_node().is_none() {
             return false;
         }
@@ -5800,23 +5863,91 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         true
     }
 
-    /// Move the node under the cursor one cell, and the cursor with it — so a
-    /// run of alt+h drags a node across the sheet rather than leaving it
-    /// behind on the first press.
+    /// Copy the selected nodes, positions and all.
+    pub(crate) fn copy_selected_nodes(&mut self) {
+        let slots = self.selected_slots();
+        let dir = self.current_dir();
+        self.node_clipboard = slots
+            .into_iter()
+            .filter_map(|i| dir.children.get(i).cloned())
+            .collect();
+    }
+
+    /// Paste the clipboard at the grid cursor, KEEPING THE SHAPE it was
+    /// copied in: the set's top-left corner lands on the cursor cell and
+    /// every node keeps its offset from it, so a pasted chain arrives wired
+    /// the way it was drawn rather than stacked in a column.
+    ///
+    /// A node whose cell is taken steps aside to the nearest free one, which
+    /// is the one case where the shape gives: a paste that silently sat two
+    /// nodes on one crossing would be worse than a paste that is a cell out.
+    pub(crate) fn paste_nodes(&mut self) -> bool {
+        if self.node_clipboard.is_empty() {
+            return false;
+        }
+        let origin = self.node_clipboard.iter().fold((f32::MAX, f32::MAX), |(x, y), n| {
+            (x.min(n.position.0), y.min(n.position.1))
+        });
+        let (cx, cy) = (self.grid_cursor_col as f32, self.grid_cursor_row as f32);
+        let mut last = (cx, cy);
+        for source in self.node_clipboard.clone() {
+            let mut node = source;
+            regenerate_node_ids(&mut node);
+            let want = (cx + node.position.0 - origin.0, cy + node.position.1 - origin.1);
+            let (nx, ny) = self.find_empty_cell(want.0, want.1, None);
+            node.position = (nx, ny);
+            // Added = hidden, same as AddNode: a paste of a displayed node
+            // must not become a second visible sibling.
+            node.geometry_visible = false;
+            self.current_dir_mut().children.push(node);
+            last = (nx, ny);
+        }
+        // The cursor lands on the last node pasted, collapsed — the pasted
+        // nodes are new, and the region that selected the originals means
+        // nothing about them.
+        self.grid_cursor_col = last.0 as i32;
+        self.grid_cursor_row = last.1 as i32;
+        self.grid_cursor_expanse = None;
+        self.sync_nodes();
+        self.sync_cursor_and_selection();
+        self.rebuild_positions();
+        self.apply_layout();
+        self.update_panel_bounds();
+        self.rebuild_scene_geometry();
+        self.viewport_dirty = true;
+        true
+    }
+
+    /// Move the SELECTED nodes one cell, and the cursor with them — so a run
+    /// of alt+h drags them across the sheet rather than leaving them behind on
+    /// the first press.
+    ///
+    /// With an expanded cursor that is every node inside it, and the region
+    /// travels too: stepping the anchor alone is exactly what collapses a
+    /// region, so a selection would be dropped by the first press that moved
+    /// it. `network_nav` cannot do that job — for the plain cursor, stepping
+    /// off IS the point.
     pub(crate) fn network_move_node(&mut self, dc: i32, dr: i32) -> bool {
         if self.focused_pane != LEFT_MENUBAR_IDX {
             return false;
         }
-        let at_cursor = self.current_dir().children.iter().position(|child| {
-            child.position.0 as i32 == self.grid_cursor_col
-                && child.position.1 as i32 == self.grid_cursor_row
-        });
-        if let Some(idx) = at_cursor {
-            let (x, y) = self.current_dir().children[idx].position;
-            self.current_dir_mut().children[idx].position = (x + dc as f32, y + dr as f32);
+        let moving = self.selected_slots();
+        if !moving.is_empty() {
+            for idx in moving {
+                let (x, y) = self.current_dir().children[idx].position;
+                self.current_dir_mut().children[idx].position = (x + dc as f32, y + dr as f32);
+            }
             self.sync_nodes();
             self.sync_layout();
         }
+        if self.grid_cursor_expanded() {
+            self.pan_velocity_x = 0.0;
+            self.pan_velocity_y = 0.0;
+            self.shift_grid_cursor(dc, dr);
+            self.deselected_cell = None;
+            self.keep_cursor_in_view();
+            return true;
+        }
         self.network_nav(dc, dr)
     }
 
@@ -7677,7 +7808,11 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                         {
                             if event.logical_key == Key::Named(NamedKey::Delete) {
                                 if is_plain_key && self.focused_pane == LEFT_MENUBAR_IDX {
-                                    if let Some(slot_idx) = self.graph().selected_node() {
+                                    // Every selected node, highest slot first:
+                                    // removing one shifts the slots above it,
+                                    // so any other order deletes the wrong
+                                    // nodes from the second one on.
+                                    for slot_idx in self.selected_slots().into_iter().rev() {
                                         if self.delete_node(slot_idx) {
                                             changed = true;
                                         }
@@ -7688,10 +7823,19 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                                     match s.as_str() {
                                         "e" | "E" => {
                                             if self.focused_pane == LEFT_MENUBAR_IDX {
-                                                if let Some(slot_idx) = self.graph().selected_node() {
+                                                // The whole selection follows the
+                                                // FIRST node's flag rather than
+                                                // each flipping its own: a toggle
+                                                // over a mixed selection should
+                                                // settle it, not shuffle it.
+                                                let selected = self.selected_slots();
+                                                let target = selected
+                                                    .first()
+                                                    .map(|&i| !self.current_dir().children[i].geometry_visible);
+                                                for slot_idx in selected {
+                                                    let visible = target.unwrap_or(false);
                                                     let dir = self.current_dir();
                                                     if slot_idx < dir.children.len() && dir.children[slot_idx].node_type != "utility" {
-                                                        let visible = !dir.children[slot_idx].geometry_visible;
                                                         self.current_dir_mut().set_child_geometry_visible(slot_idx, visible);
                                                         self.sync_nodes();
                                                         self.rebuild_scene_geometry();
@@ -7745,45 +7889,20 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                                 } else if is_ctrl_only && self.focused_pane == LEFT_MENUBAR_IDX {
                                     match s.as_str() {
                                         "c" | "C" => {
-                                            if let Some(slot_idx) = self.graph().selected_node() {
-                                                let dir = self.current_dir();
-                                                if slot_idx < dir.children.len() {
-                                                    self.node_clipboard = Some(dir.children[slot_idx].clone());
-                                                }
-                                            }
+                                            self.copy_selected_nodes();
                                         }
                                         "x" | "X" => {
-                                            if let Some(slot_idx) = self.graph().selected_node() {
-                                                let dir = self.current_dir();
-                                                if slot_idx < dir.children.len() {
-                                                    self.node_clipboard = Some(dir.children[slot_idx].clone());
-                                                    self.delete_node(slot_idx);
+                                            self.copy_selected_nodes();
+                                            // Highest slot first, as the Delete
+                                            // key does and for the same reason.
+                                            for slot_idx in self.selected_slots().into_iter().rev() {
+                                                if self.delete_node(slot_idx) {
                                                     changed = true;
                                                 }
                                             }
                                         }
                                         "v" | "V" => {
-                                            if let Some(ref clipboard_node) = self.node_clipboard {
-                                                let mut node = clipboard_node.clone();
-                                                regenerate_node_ids(&mut node);
-                                                let start_x = self.grid_cursor_col as f32;
-                                                let start_y = self.grid_cursor_row as f32;
-                                                let (nx, ny) = self.find_empty_cell(start_x, start_y, None);
-                                                node.position = (nx, ny);
-                                                // Added = hidden, same as AddNode: a
-                                                // paste of a displayed node must not
-                                                // become a second visible sibling.
-                                                node.geometry_visible = false;
-                                                self.current_dir_mut().children.push(node);
-                                                self.grid_cursor_col = nx as i32;
-                                                self.grid_cursor_row = ny as i32;
-                                                self.sync_nodes();
-                                                self.sync_cursor_and_selection();
-                                                self.rebuild_positions();
-                                                self.apply_layout();
-                                                self.update_panel_bounds();
-                                                self.rebuild_scene_geometry();
-                                                self.viewport_dirty = true;
+                                            if self.paste_nodes() {
                                                 changed = true;
                                             }
                                         }
diff --git a/src/main.rs b/src/main.rs
index 8f01f5f..b620f9a 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -8757,6 +8757,177 @@ mod tests {
         assert_eq!(state.grid_cursor_region(), (2, 4, 1, 1));
     }
 
+    /// An expanded cursor selects every node standing inside it, and the
+    /// selection is what the network's operations act on: alt+move drags them
+    /// all (region and all, or the first press would collapse what it moved),
+    /// Delete removes them all, and Escape collapses the region.
+    #[test]
+    fn an_expanded_cursor_selects_every_node_inside_it() {
+        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, x, y) in [("a", 8.0, 8.0), ("b", 10.0, 9.0), ("c", 14.0, 8.0)] {
+            state
+                .apply_action(
+                    crate::app::McpAction::AddNode {
+                        template_name: "Plane".into(),
+                        name: Some(name.into()),
+                        x,
+                        y,
+                    },
+                    &mut redraw,
+                )
+                .unwrap();
+        }
+        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"));
+
+        // A region from (8, 8) to (11, 10) holds a and b, and not c at (14, 8).
+        state.grid_cursor_col = 8;
+        state.grid_cursor_row = 8;
+        state.grid_cursor_expanse = Some(((8, 8), (11, 10)));
+        assert_eq!(state.grid_cursor_region(), (8, 8, 4, 3));
+        assert_eq!(state.selected_slots(), vec![a, b]);
+        assert!(!state.selected_slots().contains(&c));
+
+        // alt+h moves both, and the region travels with them — stepping the
+        // anchor alone is exactly what drops a region.
+        state.run_command("move_left");
+        assert_eq!(state.current_dir().children[a].position, (7.0, 8.0));
+        assert_eq!(state.current_dir().children[b].position, (9.0, 9.0));
+        assert_eq!(state.current_dir().children[c].position, (14.0, 8.0), "not selected, not moved");
+        assert_eq!(state.grid_cursor_region(), (7, 8, 4, 3), "the region came along");
+        assert_eq!(state.selected_slots(), vec![a, b], "and still holds the same two");
+
+        // Escape collapses it, since nothing else would until the cursor
+        // wandered off the anchor.
+        assert!(state.deselect_node());
+        assert_eq!(state.grid_cursor_region(), (7, 8, 1, 1));
+        assert_eq!(
+            state.selected_slots(),
+            state.graph().selected_node().into_iter().collect::<Vec<_>>(),
+            "collapsed, the selection is the graph's own single one again"
+        );
+
+        // Delete takes the whole selection, not just one of it.
+        state.grid_cursor_expanse = Some(((7, 8), (10, 10)));
+        assert_eq!(state.selected_slots().len(), 2);
+        let before = state.current_dir().children.len();
+        state.handle_event(&crate::window::WindowEvent::KeyboardInput {
+            event: key_press(Key::Named(NamedKey::Delete)),
+        });
+        assert_eq!(state.current_dir().children.len(), before - 2);
+        assert!(state.current_dir().children.iter().any(|n| n.name == "c"), "c survived");
+    }
+
+    /// The selected nodes actually LOOK selected: each body is painted with
+    /// the highlight tint the widget gives its own single selection, so an
+    /// expanded cursor reads as a selection rather than as an empty outline
+    /// drawn over nodes.
+    #[test]
+    fn every_node_in_the_region_is_painted_selected() {
+        use cce_ui::scene::paint::Prim;
+        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;
+
+        // Cells inside the pane's visible span, clear of the project's own
+        // nodes — the paint is clipped to the pane, so an off-screen node
+        // would prove nothing.
+        let mut redraw = false;
+        for (name, x, y) in [("a", 1.0, 4.0), ("b", 2.0, 5.0), ("c", 3.0, 9.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();
+        state.grid_cursor_col = 1;
+        state.grid_cursor_row = 4;
+        state.grid_cursor_expanse = Some(((1, 4), (2, 6)));
+        assert_eq!(state.selected_slots().len(), 2, "a and b, not c");
+
+        let hl = cce_ui::colors::highlight_primary_color();
+        let tinted_at = |list: &cce_ui::scene::paint::DisplayList, (cx, cy): (f32, f32)| {
+            list.items.iter().any(|item| match &item.prim {
+                Prim::Bevel { rect, tint, .. } => {
+                    tint[0] == hl[0]
+                        && tint[1] == hl[1]
+                        && tint[2] == hl[2]
+                        && (rect.x + rect.width * 0.5 - cx).abs() < 1.0
+                        && (rect.y + rect.height * 0.5 - cy).abs() < 1.0
+                }
+                _ => false,
+            })
+        };
+        let list = state.collect_display_list();
+        assert!(tinted_at(&list, state.cell_center(1, 4)), "a is painted selected");
+        assert!(tinted_at(&list, state.cell_center(2, 5)), "b is painted selected");
+        assert!(!tinted_at(&list, state.cell_center(3, 9)), "c is outside the region");
+    }
+
+    /// Copy takes the whole selection and paste lays it back out in the shape
+    /// it was copied in — a pasted chain arrives wired the way it was drawn,
+    /// not stacked in a column.
+    #[test]
+    fn copying_a_region_keeps_the_shape_on_paste() {
+        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, x, y) in [("a", 8.0, 8.0), ("b", 10.0, 9.0)] {
+            state
+                .apply_action(
+                    crate::app::McpAction::AddNode {
+                        template_name: "Plane".into(),
+                        name: Some(name.into()),
+                        x,
+                        y,
+                    },
+                    &mut redraw,
+                )
+                .unwrap();
+        }
+        state.grid_cursor_col = 8;
+        state.grid_cursor_row = 8;
+        state.grid_cursor_expanse = Some(((8, 8), (11, 10)));
+        state.copy_selected_nodes();
+        assert_eq!(state.node_clipboard.len(), 2);
+
+        // Paste at a clear corner of the sheet: the set's top-left lands on
+        // the cursor and the second node keeps its (+2, +1) offset.
+        state.grid_cursor_col = 20;
+        state.grid_cursor_row = 20;
+        state.grid_cursor_expanse = None;
+        let before = state.current_dir().children.len();
+        assert!(state.paste_nodes());
+        assert_eq!(state.current_dir().children.len(), before + 2);
+        let pasted: Vec<(f32, f32)> = state.current_dir().children[before..]
+            .iter()
+            .map(|n| n.position)
+            .collect();
+        assert_eq!(pasted, vec![(20.0, 20.0), (22.0, 21.0)]);
+    }
+
     /// A press the graph itself took does not arm the expansion drag. The
     /// case that bites is a PORT: it starts a connection and consumes the
     /// press without selecting anything, so the empty-grid arm would read it
diff --git a/src/render.rs b/src/render.rs
index dd35fed..97b59d0 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -472,7 +472,21 @@ impl State {
                 for (qx, qy, qw, qh, qc, cell) in g.geometry_quads_tagged(clip) {
                     if g.is_node_rect(qx, qy, qw, qh) {
                         seen_node = true;
-                        bodies.push((qx, qy, qw, qh, same_rgb(qc, sel) || same_rgb(qc, drag)));
+                        // An EXPANDED cursor selects every node standing
+                        // inside it, and they wear the selected look. The
+                        // widget colours one body — its own `selected_idx` —
+                        // so the rest are recognised here, by the cell the
+                        // body is centred on: the same `grid_cursor_covers`
+                        // the selection itself is derived from, rather than a
+                        // second rect test that could disagree with it. Pane
+                        // 1 only, the grid cursor being pane 1's concept.
+                        let in_region = !second
+                            && self.grid_cursor_expanded()
+                            && {
+                                let (col, row) = self.cell_at(qx + qw * 0.5, qy + qh * 0.5);
+                                self.grid_cursor_covers(col, row)
+                            };
+                        bodies.push((qx, qy, qw, qh, in_region || same_rgb(qc, sel) || same_rgb(qc, drag)));
                     } else if seen_node {
                         overlays.push((qx, qy, qw, qh, qc));
                     } else if let Some(corners) = cell {