graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: the network grid cursor expands by dragging
A left press on empty grid already moved the cursor to the pressed cell on
the press; it now also arms an expansion drag from it, so dragging grows
the cursor from that anchor to the cell under the pointer and the region it
reached stays after the release.
`grid_cursor_region` is the one derivation and `grid_cursor_rect` the
window-space union the outline paints on — for the usual one-cell cursor,
exactly the `cell_rect` it has always been.
The region collapses by itself: the expanse stores its anchor, and it is
read back only while that anchor is still the live cursor cell. Every other
way the cursor moves leaves the anchor behind and drops the region with it,
without a line in any of those fifteen call sites — a flag reset by hand at
all of them is one that gets missed at one.
Arming is gated on the graph not having taken the press. A press on a PORT
starts a connection and consumes the press without selecting anything, so
the empty-grid arm would read it as bare lattice and then swallow every
motion event, freezing the rubber-band line at the port it started from.
Co-Authored-By: Claude Opus 5 <[email protected]>
CLAUDE.md | 32 ++++++++++++++
src/app.rs | 89 ++++++++++++++++++++++++++++++++++++++-
src/main.rs | 132 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/render.rs | 6 ++-
4 files changed, 255 insertions(+), 4 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index f7c4631..acafce9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -787,6 +787,38 @@ cell-and-gap setters (`set_grid_sizes` / `set_skipped_sizes`) survive as a
description of the same lattice for cce-files and cce-graph, which still
speak it; this app sets the pitch.
+### The cursor is a region, and dragging the grid grows it
+
+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,
+`(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 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
+cursor moves — a nav key, a click, a load, the selection following a node —
+leaves the anchor behind and drops the region with it, without a line in any of
+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.
+
+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
+press without selecting anything, so the empty-grid arm would read it as bare
+lattice and then swallow every motion event — leaving the rubber-band line
+frozen at the port it started from. The gesture is otherwise uncontested,
+because `Graph::draggable` is true only while it is moving a node.
+
### Auto-layout
`src/layout.rs` arranges a level's nodes from their wiring. The network is
diff --git a/src/app.rs b/src/app.rs
index da1c62d..b3c6c6c 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1410,6 +1410,22 @@ pub struct State {
pub cursor_y: f32,
pub grid_cursor_col: i32,
pub grid_cursor_row: i32,
+ /// The grid cursor EXPANDED over a region: the cell it was anchored at
+ /// when a drag began, and the far cell that drag reached.
+ ///
+ /// Read through [`grid_cursor_region`](State::grid_cursor_region), which
+ /// hands it back only while its anchor is still the live cursor cell.
+ /// That is the whole collapse rule: every OTHER way the cursor moves — a
+ /// nav key, a click, a load, a selection following a node — leaves the
+ /// anchor behind and the region goes with it, without a line in any of
+ /// those places. A flag reset by hand at fifteen call sites is a flag
+ /// 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))>,
+ /// 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.
+ pub grid_cursor_drag: Option<(i32, i32)>,
pub modifiers: ModifiersState,
pub width: f32,
@@ -4563,6 +4579,8 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
cursor_y: 0.0,
grid_cursor_col: 0,
grid_cursor_row: 0,
+ grid_cursor_expanse: None,
+ grid_cursor_drag: None,
modifiers: ModifiersState::default(),
width: lw,
height: lh,
@@ -4831,6 +4849,44 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
(col as i32, row as i32)
}
+ /// The lattice region the cursor covers: `(col, row, cols, rows)`, never
+ /// smaller than one cell. The expanse a drag left behind counts only
+ /// while its anchor is still where the cursor is — see
+ /// [`grid_cursor_expanse`](State::grid_cursor_expanse).
+ pub fn grid_cursor_region(&self) -> (i32, i32, i32, i32) {
+ let anchor = (self.grid_cursor_col, self.grid_cursor_row);
+ let far = match self.grid_cursor_expanse {
+ Some((a, far)) if a == anchor => far,
+ _ => anchor,
+ };
+ let (c0, c1) = (anchor.0.min(far.0), anchor.0.max(far.0));
+ let (r0, r1) = (anchor.1.min(far.1), anchor.1.max(far.1));
+ (c0, r0, c1 - c0 + 1, r1 - r0 + 1)
+ }
+
+ /// The window-space rect the cursor outline is drawn on: the union of the
+ /// region's corner cells, which for the usual one-cell cursor is exactly
+ /// `cell_rect` of it.
+ pub fn grid_cursor_rect(&self) -> (f32, f32, f32, f32) {
+ let (col, row, cols, rows) = self.grid_cursor_region();
+ let (x0, y0, _, _) = self.cell_rect(col, row);
+ let (x1, y1, w, h) = self.cell_rect(col + cols - 1, row + rows - 1);
+ (x0, y0, x1 - x0 + w, y1 - y0 + h)
+ }
+
+ /// 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 {
+ let Some(anchor) = self.grid_cursor_drag else { return false };
+ let far = self.cell_at(self.cursor_x, self.cursor_y);
+ let next = (anchor != far).then_some((anchor, far));
+ if self.grid_cursor_expanse == next {
+ return false;
+ }
+ self.grid_cursor_expanse = next;
+ true
+ }
+
pub fn sync_grid_settings(&mut self) {
let active_node_area_y = self.positions[CONTENT_IDX].1;
let active_node_area_x = self.positions[CONTENT_IDX].0;
@@ -6492,6 +6548,13 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
return true;
}
+ // An expansion drag on the network grid, likewise armed by
+ // its own press and competing with nothing: the cursor grows
+ // from the pressed cell to the one under the pointer.
+ if self.grid_cursor_drag.is_some() {
+ return self.grid_cursor_drag_motion();
+ }
+
// An armed corner-dot press becomes a layout drag once it
// moves; stubbed (collapsed/detached) panes stay click-only.
if let Some((idx, px, py)) = self.corner_press {
@@ -7122,11 +7185,19 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
}
if let Some(i) = click_target {
self.slots.get_dyn_mut(i).set_modifiers(self.modifiers.control_key(), self.modifiers.shift_key(), self.modifiers.alt_key());
- if {
+ // Kept as a binding: the network's cursor-expansion
+ // drag must not arm on a press the graph already
+ // took. A press on a PORT is the case that bites —
+ // it starts a connection and returns true without
+ // selecting anything, so the "no selection" arm
+ // below would read it as empty grid and swallow the
+ // motion the rubber-band line is drawn from.
+ let widget_took = {
let ev = cce_ui::widget::Event::MouseButton { button: *button, state: *btn_state, x: self.cursor_x, y: self.cursor_y, local_x: self.cursor_x, local_y: self.cursor_y };
let ptr = self.slots.get_dyn_mut(i) as *mut (dyn WidgetHost + 'static);
unsafe { (*ptr).handle_event(&ev, &mut self.ui_context) }
- } {
+ };
+ if widget_took {
changed = true;
if i == PARAM_IDX {
self.sync_parameters_to_project();
@@ -7181,9 +7252,20 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
self.grid_cursor_row = pos.1 as i32;
}
} else {
+ // Empty grid: the cursor goes to the cell
+ // pressed, on the PRESS, and the press arms
+ // an expansion drag from it. The graph
+ // widget is only `draggable` while it is
+ // moving a node, so nothing else wants this
+ // gesture — a press that never moves simply
+ // leaves a one-cell cursor behind.
let (col, row) = self.cell_at(self.cursor_x, self.cursor_y);
self.grid_cursor_col = col;
self.grid_cursor_row = row;
+ self.grid_cursor_expanse = None;
+ if !widget_took {
+ self.grid_cursor_drag = Some((col, row));
+ }
changed = true;
}
if let Some(dir_idx) = self.graph().double_clicked_node() {
@@ -7200,6 +7282,9 @@ 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;
if self.orbit_drag.take().is_some() {
return true;
}
diff --git a/src/main.rs b/src/main.rs
index 4162563..8f01f5f 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -8679,6 +8679,138 @@ mod tests {
assert_eq!(state.dialog_tab(), Tab::Commands);
}
+ /// A left press on empty grid puts the cursor on the pressed cell — on the
+ /// PRESS — and dragging from there expands it into a region, which stays
+ /// after the release. Moving the cursor any other way collapses it, since
+ /// the expanse is only read back while its anchor is the live cursor.
+ #[test]
+ fn dragging_the_network_grid_expands_the_cursor() {
+ use crate::slots::CONTENT_IDX;
+ 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;
+
+ // Two empty cells inside the pane's visible span, two columns and
+ // two rows apart.
+ let anchor = (1, 4);
+ let far = (3, 6);
+ for cell in [anchor, far] {
+ assert!(
+ !state.current_dir().children.iter().any(|c| (c.position.0 as i32, c.position.1 as i32) == cell),
+ "{cell:?} must be empty grid"
+ );
+ }
+ 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, anchor);
+ state.handle_event(&WindowEvent::MouseInput {
+ state: ElementState::Pressed,
+ button: MouseButton::Left,
+ });
+ assert_eq!(
+ (state.grid_cursor_col, state.grid_cursor_row),
+ anchor,
+ "the press alone moves the cursor — not the release"
+ );
+ assert_eq!(state.grid_cursor_region(), (anchor.0, anchor.1, 1, 1));
+
+ // Dragging grows it from the anchor to the cell under the pointer.
+ move_to(&mut state, (2, 5));
+ assert_eq!(state.grid_cursor_region(), (1, 4, 2, 2));
+ move_to(&mut state, far);
+ assert_eq!(state.grid_cursor_region(), (1, 4, 3, 3));
+ assert_eq!(
+ (state.grid_cursor_col, state.grid_cursor_row),
+ anchor,
+ "the anchor is still the cursor cell — Add Node places there"
+ );
+
+ // The outline follows: the region's corner cells, unioned.
+ let (rx, ry, rw, rh) = state.grid_cursor_rect();
+ let (ax, ay, cw, ch) = state.cell_rect(anchor.0, anchor.1);
+ 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.
+ 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));
+
+ // And any other move of the cursor collapses it, with nothing in that
+ // path saying so — the anchor simply stops matching.
+ state.run_command("nav_right");
+ assert_eq!(state.grid_cursor_region(), (2, 4, 1, 1));
+ }
+
+ /// 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
+ /// as bare lattice and then swallow every motion event — leaving the
+ /// connection's rubber-band line frozen at the port it started from.
+ #[test]
+ fn a_port_press_does_not_arm_the_cursor_expansion() {
+ 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;
+
+ // A real port centre, off the widget's own geometry — the ports sit
+ // outside the node body, so nothing but this gets one right.
+ let (px, py) = {
+ let g = state.slots.content.inner();
+ (0..state.current_dir().children.len())
+ .find_map(|i| {
+ g.port_center(i, cce_ui::widget::display::graph::PortType::Output, 0)
+ })
+ .expect("a node with an output port")
+ };
+ state.handle_event(&WindowEvent::CursorMoved {
+ position: LocalPosition { x: px as f64, y: py as f64 },
+ });
+ // Nothing selected: the project loads with a selection, and with one
+ // standing the empty-grid arm is never reached at all — the guard
+ // this test is about would go untested.
+ state.graph_mut().set_selected_node(None);
+ state.handle_event(&WindowEvent::MouseInput {
+ state: ElementState::Pressed,
+ button: MouseButton::Left,
+ });
+ assert!(
+ state.graph().selected_node().is_none(),
+ "a port press selects nothing — which is what makes the arm below \
+ read it as empty grid unless the guard holds"
+ );
+ assert!(
+ state.grid_cursor_drag.is_none(),
+ "the graph took this press — the cursor must not start expanding"
+ );
+
+ // And the motion that follows is still the graph's, not eaten here.
+ let before = state.grid_cursor_region();
+ state.handle_event(&WindowEvent::CursorMoved {
+ position: LocalPosition { x: px as f64, y: (py + 120.0) as f64 },
+ });
+ assert_eq!(state.grid_cursor_region(), before, "no region grew out of it");
+ }
+
/// A right press on EMPTY network space opens the network's own context
/// menu — until 2026-09-22 it opened the add-node palette outright, which
/// left the network the one pane whose right-click was not a context menu,
diff --git a/src/render.rs b/src/render.rs
index a7e4d28..dd35fed 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -518,8 +518,10 @@ impl State {
if show_cursor && !second {
// A node-sized outline centred on the cursor's intersection —
- // exactly where a node placed there would sit.
- let (cx, cy, cw, ch) = self.cell_rect(self.grid_cursor_col, self.grid_cursor_row);
+ // exactly where a node placed there would sit — or, after a
+ // drag across the grid, the union of the region it expanded
+ // over. One cell is the usual case and the same rect as ever.
+ let (cx, cy, cw, ch) = self.grid_cursor_rect();
// The cursor is the focus language: a FILL-LESS tinted plate
// (transparent bevel + accent tint), which the shader renders
// as the wrapped glint alone — the plate roll's own specular