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