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

commit6c3b495d1333ca6a8cfe69ad06cee1dfc8bfe699
parente34d38c174
authorLucas Galante <[email protected]>
date2026-09-23 10:07
feat: an expansion drag settles onto what it caught

On release the cursor's region shrinks to the bounding box of the nodes it
selected, or — with nothing caught — to one cell at the middle of where it
stood (even spans rounding down toward the region's first cell).

A region is a way of pointing at nodes, and once the pointing is done the
empty margin the pointer swept through is noise: it hides nothing, it
selects nothing, and it leaves the next alt+hjkl or Add Node reading off an
anchor out in open grid. Settling also makes the region say what was
selected — a box drawn loosely around two nodes comes back fitted to them.

Nothing caught settles to the middle rather than back to the anchor: the
anchor is merely where the gesture began, and a drag that selected nothing
is aimed at the space it ended up circling.

The selection itself never changes in a settle, the bounding box of the
selected nodes containing no cell the region did not — which is what lets
it run at the end of every drag without a thought for what it might drop.

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

 CLAUDE.md   | 19 ++++++++++++--
 src/app.rs  | 68 ++++++++++++++++++++++++++++++++++++++++++++---
 src/main.rs | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++++++------
 3 files changed, 161 insertions(+), 13 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 0a47af3..e824b64 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -808,12 +808,27 @@ speak it; this app sets the pitch.
 
 A left press on EMPTY grid puts the cursor on the pressed cell — on the press,
 not the release — and arms an expansion drag from it. Dragging grows the cursor
-from that anchor to the cell under the pointer, and the region it reached stays
-after the release. `State::grid_cursor_region` is the one derivation,
+from that anchor to the cell under the pointer. `State::grid_cursor_region` is the one derivation,
 `(col, row, cols, rows)`, never smaller than one cell; `grid_cursor_rect` is the
 window-space union the outline is painted on, which for the usual one-cell
 cursor is exactly `cell_rect` of it.
 
+**The release SETTLES the region** onto what it caught
+(`settle_cursor_expansion`): the bounding box of the selected nodes, or — with
+nothing caught — one cell at the MIDDLE of where the region stood, even spans
+rounding down toward its first cell. A region is a way of pointing at nodes,
+and once the pointing is done the empty margin the pointer swept through is
+noise: it hides nothing, it selects nothing, and it leaves the next alt+hjkl or
+Add Node reading off an anchor out in open grid. Settling also makes the region
+say what was selected — a box drawn loosely around two nodes comes back fitted
+to them. Nothing caught settles to the middle rather than back to the anchor,
+because the anchor is merely where the gesture began and a drag that selected
+nothing is aimed at the space it ended up circling.
+
+The SELECTION never changes in a settle — the bounding box of the selected
+nodes contains no cell the region did not — which is what lets it run at the
+end of every drag without a thought for what it might drop.
+
 **The region collapses by itself.** `grid_cursor_expanse` stores the anchor
 alongside the far cell, and `grid_cursor_region` hands it back only while that
 anchor is still `(grid_cursor_col, grid_cursor_row)`. So every OTHER way the
diff --git a/src/app.rs b/src/app.rs
index f805730..5ad1c67 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -5871,6 +5871,66 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         true
     }
 
+    /// 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.
+    ///
+    /// A region is a way of POINTING at nodes, and once the pointing is done
+    /// the empty margin the pointer swept through is noise: it hides nothing,
+    /// it selects nothing, and it makes the next alt+hjkl or Add Node read off
+    /// an anchor out in open grid. Settling it also makes the region say what
+    /// was selected — a box drawn loosely around two nodes comes back fitted
+    /// to them, which is the selection made visible.
+    ///
+    /// Nothing caught settles to the MIDDLE rather than to the anchor: the
+    /// anchor is where the gesture began, and a drag that selected nothing is
+    /// most likely aimed at the space it ended up circling. Even spans round
+    /// down, toward the region's own first cell.
+    ///
+    /// The selection is unchanged by all of this — the bounding box of the
+    /// selected nodes contains no cell the region did not — which is what
+    /// lets it run unconditionally at the end of every drag.
+    pub(crate) fn settle_cursor_expansion(&mut self) -> bool {
+        if !self.grid_cursor_expanded() {
+            return false;
+        }
+        let (col, row, cols, rows) = self.grid_cursor_region();
+        let cells: Vec<(i32, i32)> = self
+            .selected_slots()
+            .into_iter()
+            .map(|i| {
+                let p = self.current_dir().children[i].position;
+                (p.0 as i32, p.1 as i32)
+            })
+            .collect();
+        match cells.split_first() {
+            None => {
+                self.grid_cursor_col = col + (cols - 1) / 2;
+                self.grid_cursor_row = row + (rows - 1) / 2;
+                self.grid_cursor_expanse = None;
+            }
+            Some((first, rest)) => {
+                let (mut c0, mut r0, mut c1, mut r1) = (first.0, first.1, first.0, first.1);
+                for &(c, r) in rest {
+                    c0 = c0.min(c);
+                    r0 = r0.min(r);
+                    c1 = c1.max(c);
+                    r1 = r1.max(r);
+                }
+                self.grid_cursor_col = c0;
+                self.grid_cursor_row = r0;
+                self.grid_cursor_expanse =
+                    ((c1, r1) != (c0, r0)).then_some(((c0, r0), (c1, r1)));
+            }
+        }
+        // A settle that lands on a node selects it, and one that lands on
+        // empty grid clears the single selection — the ordinary cursor rules,
+        // which is what the cursor is again whenever the region collapsed.
+        self.deselected_cell = None;
+        self.sync_cursor_and_selection();
+        true
+    }
+
     /// Grow (or shrink) the cursor's region by one cell — the shift+hjkl
     /// family, the plugin's extend-the-selection scheme.
     ///
@@ -7454,9 +7514,11 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                         self.sync_pane_focus();
                     }
                     ElementState::Released => {
-                        // The expansion drag ends, but the region it grew
-                        // stays: it is the cursor now, until the cursor moves.
-                        self.grid_cursor_drag = None;
+                        // The expansion drag ends by SETTLING onto what it
+                        // caught — see `settle_cursor_expansion`.
+                        if self.grid_cursor_drag.take().is_some() && self.settle_cursor_expansion() {
+                            changed = true;
+                        }
                         if self.orbit_drag.take().is_some() {
                             return true;
                         }
diff --git a/src/main.rs b/src/main.rs
index 9a9dd9c..6d9d10c 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -8741,21 +8741,92 @@ mod tests {
         assert!((rx - ax).abs() < 0.01 && (ry - ay).abs() < 0.01);
         assert!(rw > cw * 2.0 && rh > ch * 2.0, "{rw}x{rh} spans three cells each way");
 
-        // The release ends the drag and keeps the region.
+        // The release SETTLES the region: this drag caught no nodes, so it
+        // comes back as one cell at the middle of where it stood — (1, 4)
+        // through (3, 6), whose middle is (2, 5).
         state.handle_event(&WindowEvent::MouseInput {
             state: ElementState::Released,
             button: MouseButton::Left,
         });
         assert!(state.grid_cursor_drag.is_none());
-        assert_eq!(state.grid_cursor_region(), (1, 4, 3, 3), "the region outlives the drag");
-        // Motion with no drag armed leaves it alone.
-        move_to(&mut state, (0, 2));
-        assert_eq!(state.grid_cursor_region(), (1, 4, 3, 3));
+        assert_eq!(state.grid_cursor_region(), (2, 5, 1, 1));
+        assert!(state.grid_cursor_expanse.is_none(), "collapsed outright, not a 1x1 region");
 
-        // And any other move of the cursor collapses it, with nothing in that
-        // path saying so — the anchor simply stops matching.
+        // Motion with no drag armed leaves the cursor alone.
+        move_to(&mut state, (0, 2));
+        assert_eq!(state.grid_cursor_region(), (2, 5, 1, 1));
         state.run_command("nav_right");
-        assert_eq!(state.grid_cursor_region(), (2, 4, 1, 1));
+        assert_eq!(state.grid_cursor_region(), (3, 5, 1, 1));
+    }
+
+    /// 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.
+    #[test]
+    fn a_drag_settles_onto_the_nodes_it_caught() {
+        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;
+
+        // Two nodes well inside a box drawn from (0, 3) to (3, 9).
+        let mut redraw = false;
+        for (name, x, y) in [("a", 1.0, 5.0), ("b", 2.0, 7.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) = (slot(&state, "a"), slot(&state, "b"));
+
+        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 },
+            });
+        };
+        move_to(&mut state, (0, 3));
+        state.handle_event(&WindowEvent::MouseInput {
+            state: ElementState::Pressed,
+            button: MouseButton::Left,
+        });
+        move_to(&mut state, (3, 9));
+        assert_eq!(state.grid_cursor_region(), (0, 3, 4, 7), "the box as drawn");
+        assert_eq!(state.selected_slots(), vec![a, b]);
+
+        state.handle_event(&WindowEvent::MouseInput {
+            state: ElementState::Released,
+            button: MouseButton::Left,
+        });
+        assert_eq!(state.grid_cursor_region(), (1, 5, 2, 3), "fitted to a and b");
+        assert_eq!(state.selected_slots(), vec![a, b], "and holding the same two");
+
+        // One node caught collapses the region onto it, and the ordinary
+        // single selection takes over from there.
+        state.grid_cursor_col = 0;
+        state.grid_cursor_row = 3;
+        state.grid_cursor_expanse = Some(((0, 3), (3, 6)));
+        assert_eq!(state.selected_slots(), vec![a]);
+        assert!(state.settle_cursor_expansion());
+        assert_eq!(state.grid_cursor_region(), (1, 5, 1, 1));
+        assert!(state.grid_cursor_expanse.is_none());
+        assert_eq!(state.graph().selected_node(), Some(a), "the cursor sits on it now");
     }
 
     /// An expanded cursor selects every node standing inside it, and the