graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: shift+hjkl extends the network selection
The plugin's extend-the-selection family, absent while the graph's single
`selected_node` was the whole selection. The cursor is a region now, so
these four rows grow its far corner and the nodes inside it are the
selection.
The anchor never moves: `network_extend` walks the FAR corner and leaves
the anchor where it is, exactly as a drag does, so shift+l then shift+h
returns to where it started rather than walking the region right and back.
A far corner that meets the anchor again drops the expanse outright, so a
region shrunk to nothing is the plain one-cell cursor and not a 1x1 region
that merely behaves like one; carrying on past it grows the region the
other way. Extending from a cursor sitting ON a node keeps that node
selected, which is what makes this an extend rather than a second way to
start a selection.
It scrolls the far cell into view, not the anchor — the anchor is the end
that is not moving. `keep_cursor_in_view` is now a wrapper over
`keep_cell_in_view`.
Co-Authored-By: Claude Opus 5 <[email protected]>
CLAUDE.md | 38 +++++++++++++++++++---------
src/app.rs | 45 +++++++++++++++++++++++++++++++--
src/command.rs | 8 ++++++
src/main.rs | 78 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--
src/shortcut.rs | 8 +++---
5 files changed, 158 insertions(+), 19 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index c67b2e4..0a47af3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -713,10 +713,11 @@ only thing carrying the pointed-at cell across to the palette.
The network pane's keyboard scheme is the plugin's, ported: **hjkl rather than
arrows** — the arrows are the playbar transport in every pane and context — bare
-to move the grid cursor, `alt` to move the node under it, `ctrl` to pan the
-view, plus `f` to frame the cursor and `shift+f` to frame everything. All
-fourteen are registry commands in `Context::Network`, so they are rebindable
-through `input.kdl` and listed in the palette.
+to move the grid cursor, `shift` to extend it into a region, `alt` to move the
+selected nodes, `ctrl` to pan the view, plus `f` to frame the cursor and
+`shift+f` to frame everything. All eighteen are registry commands in
+`Context::Network`, so they are rebindable through `input.kdl` and listed in
+the palette.
**The grid cursor IS the selection.** `sync_cursor_and_selection` selects
whatever node sits in the cursor's cell, so navigating selects, and stepping off
@@ -727,20 +728,33 @@ invisibly while you were looking at the viewport (the selection did not follow,
because `sync_cursor_and_selection` has its own pane check) and was somewhere
unexpected when you came back.
-`alt` moves the node AND the cursor, so a run of `alt+h` drags a node across the
-sheet rather than leaving it behind on the first press. `ctrl` pans by one CELL
+`alt` moves the SELECTION and the cursor, so a run of `alt+h` drags what is
+selected across the sheet rather than leaving it behind on the first press. `ctrl` pans by one CELL
rather than a fixed pixel count, so a pan step means the same thing at every
zoom. Frame Cursor CENTRES the cursor cell; its first version called
`keep_cursor_in_view`, which pans only when the cursor has gone off an edge, so
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 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.
+`shift+hjkl` — the plugin's extend-the-selection family — grows the cursor's
+region (below) by one cell. It was absent while the graph's single
+`selected_node` was the whole selection, when four rows would have done what
+bare hjkl already does; there is a real multi-selection to extend now.
+
+**The anchor never moves.** `network_extend` walks the region's FAR corner and
+leaves the anchor where it is, exactly as a drag does, so `shift+l` then
+`shift+h` returns to where it started rather than walking the whole region
+right and back. A far corner that meets the anchor again drops the expanse
+outright, so a region shrunk to nothing is the plain one-cell cursor and not a
+1×1 region that merely behaves like one — and carrying on past the anchor grows
+it the other way. Extending from a cursor that sits ON a node keeps that node
+selected, the anchor's cell being part of its own region, which is what makes
+the family an extend rather than a second way to start a selection.
+
+It scrolls the FAR cell into view (`keep_cell_in_view`, which
+`keep_cursor_in_view` is now a one-line wrapper of): the anchor is the end that
+is not moving, and following it would scroll the wrong end of the selection
+into view.
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
diff --git a/src/app.rs b/src/app.rs
index 0dc685e..f805730 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -5127,8 +5127,16 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
pub fn keep_cursor_in_view(&mut self) {
+ self.keep_cell_in_view(self.grid_cursor_col, self.grid_cursor_row);
+ }
+
+ /// Pan the least that brings one lattice cell fully into the pane. The
+ /// cursor's own is the usual one; an EXTEND walks the region's far corner
+ /// instead, and following the anchor there would scroll the wrong end of
+ /// the selection into view — the anchor is the end that is not moving.
+ pub fn keep_cell_in_view(&mut self, col: i32, row: i32) {
let (px, py, pw, ph) = self.positions[CONTENT_IDX];
- let (cx, cy, cw, ch) = self.cell_rect(self.grid_cursor_col, self.grid_cursor_row);
+ let (cx, cy, cw, ch) = self.cell_rect(col, row);
if cx < px {
self.pan_x -= cx - px;
@@ -5863,6 +5871,36 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
true
}
+ /// Grow (or shrink) the cursor's region by one cell — the shift+hjkl
+ /// family, the plugin's extend-the-selection scheme.
+ ///
+ /// The ANCHOR never moves: it is the fixed end of the region, exactly as
+ /// it is for a drag, so shift+l then shift+h returns to where it started
+ /// rather than walking the whole region right and back. A far corner that
+ /// meets the anchor again drops the expanse outright, so a shrunk-to-
+ /// nothing region is the plain one-cell cursor and not a 1x1 region that
+ /// merely behaves like one.
+ ///
+ /// Extending from a cursor that sits ON a node keeps that node in the
+ /// selection — the anchor's cell is part of its own region — which is what
+ /// makes the family an EXTEND rather than a second way to start one.
+ pub(crate) fn network_extend(&mut self, dc: i32, dr: i32) -> bool {
+ if self.focused_pane != LEFT_MENUBAR_IDX {
+ return false;
+ }
+ 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 far = (far.0 + dc, far.1 + dr);
+ self.grid_cursor_expanse = (far != anchor).then_some((anchor, far));
+ self.pan_velocity_x = 0.0;
+ self.pan_velocity_y = 0.0;
+ self.keep_cell_in_view(far.0, far.1);
+ true
+ }
+
/// Copy the selected nodes, positions and all.
pub(crate) fn copy_selected_nodes(&mut self) {
let slots = self.selected_slots();
@@ -6106,10 +6144,13 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
Action::ToggleDialog => self.toggle_dialog(),
// The network navigation families. Each returns false when the
// network pane does not have focus, which is how one gate covers
- // all fourteen of them.
+ // all eighteen of them.
Action::NetworkNav(dc, dr) => {
self.network_nav(dc, dr);
}
+ Action::NetworkExtend(dc, dr) => {
+ self.network_extend(dc, dr);
+ }
Action::NetworkMove(dc, dr) => {
self.network_move_node(dc, dr);
}
diff --git a/src/command.rs b/src/command.rs
index 62711ea..623e28d 100644
--- a/src/command.rs
+++ b/src/command.rs
@@ -140,6 +140,14 @@ pub const COMMANDS: &[Command] = &[
Command { id: "nav_down", label: "Cursor Down", context: Context::Network, run: Run::Key(Action::NetworkNav(0, 1)), default_chord: Some("j") },
Command { id: "nav_up", label: "Cursor Up", context: Context::Network, run: Run::Key(Action::NetworkNav(0, -1)), default_chord: Some("k") },
Command { id: "nav_right", label: "Cursor Right", context: Context::Network, run: Run::Key(Action::NetworkNav(1, 0)), default_chord: Some("l") },
+ // shift+hjkl — the plugin's extend-the-selection family. It was absent
+ // while the graph's single `selected_node` was the whole selection; the
+ // cursor is a REGION now, so these grow its far corner and the nodes
+ // inside it are the selection (see `State::selected_slots`).
+ Command { id: "extend_left", label: "Extend Selection Left", context: Context::Network, run: Run::Key(Action::NetworkExtend(-1, 0)), default_chord: Some("Shift+h") },
+ Command { id: "extend_down", label: "Extend Selection Down", context: Context::Network, run: Run::Key(Action::NetworkExtend(0, 1)), default_chord: Some("Shift+j") },
+ Command { id: "extend_up", label: "Extend Selection Up", context: Context::Network, run: Run::Key(Action::NetworkExtend(0, -1)), default_chord: Some("Shift+k") },
+ Command { id: "extend_right", label: "Extend Selection Right", context: Context::Network, run: Run::Key(Action::NetworkExtend(1, 0)), default_chord: Some("Shift+l") },
Command { id: "move_left", label: "Move Node Left", context: Context::Network, run: Run::Key(Action::NetworkMove(-1, 0)), default_chord: Some("Alt+h") },
Command { id: "move_down", label: "Move Node Down", context: Context::Network, run: Run::Key(Action::NetworkMove(0, 1)), default_chord: Some("Alt+j") },
Command { id: "move_up", label: "Move Node Up", context: Context::Network, run: Run::Key(Action::NetworkMove(0, -1)), default_chord: Some("Alt+k") },
diff --git a/src/main.rs b/src/main.rs
index b620f9a..9a9dd9c 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -4881,8 +4881,8 @@ mod tests {
}
/// The navigation scheme is the plugin's, and the registry says so: hjkl
- /// bare, alt and ctrl, plus the two framings — fourteen rows, all in the
- /// network context, none of them colliding.
+ /// bare, shift, alt and ctrl, plus the two framings — eighteen rows, all
+ /// in the network context, none of them colliding.
#[test]
fn test_the_navigation_scheme_matches_the_plugins() {
use crate::command::{by_id, Context};
@@ -4890,6 +4890,7 @@ mod tests {
// Uppercase because `describe` prints single letters as capitals,
// the way every menu in the app writes a chord.
("nav_left", "H"), ("nav_down", "J"), ("nav_up", "K"), ("nav_right", "L"),
+ ("extend_left", "Shift+H"), ("extend_down", "Shift+J"), ("extend_up", "Shift+K"), ("extend_right", "Shift+L"),
("move_left", "Alt+H"), ("move_down", "Alt+J"), ("move_up", "Alt+K"), ("move_right", "Alt+L"),
("view_left", "Ctrl+H"), ("view_down", "Ctrl+J"), ("view_up", "Ctrl+K"), ("view_right", "Ctrl+L"),
("frame_cursor", "F"), ("frame_all", "Shift+F"),
@@ -8826,6 +8827,79 @@ mod tests {
assert!(state.current_dir().children.iter().any(|n| n.name == "c"), "c survived");
}
+ /// shift+hjkl grows the cursor's region from a FIXED anchor, so the
+ /// selection extends the way the plugin's does: shift+l then shift+h
+ /// comes back to where it started rather than walking the region sideways,
+ /// and a far corner that meets the anchor again leaves a plain one-cell
+ /// cursor rather than a 1x1 region.
+ #[test]
+ fn shift_hjkl_extends_the_selection_from_a_fixed_anchor() {
+ 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, 4.0), ("b", 2.0, 4.0), ("c", 3.0, 4.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"));
+
+ // The cursor starts ON a node, and extending KEEPS it: the anchor's
+ // cell is part of its own region, which is what makes this an extend
+ // rather than a second way to start a selection.
+ state.grid_cursor_col = 1;
+ state.grid_cursor_row = 4;
+ state.sync_cursor_and_selection();
+ assert_eq!(state.selected_slots(), vec![a]);
+
+ assert!(state.run_command("extend_right"));
+ assert_eq!(state.grid_cursor_region(), (1, 4, 2, 1));
+ assert_eq!(state.selected_slots(), vec![a, b]);
+ assert!(state.run_command("extend_right"));
+ assert_eq!(state.selected_slots(), vec![a, b, c]);
+ assert_eq!(
+ (state.grid_cursor_col, state.grid_cursor_row),
+ (1, 4),
+ "the anchor is the fixed end"
+ );
+
+ // Back the way it came, and the region shrinks rather than walking.
+ assert!(state.run_command("extend_left"));
+ assert_eq!(state.selected_slots(), vec![a, b]);
+ assert!(state.run_command("extend_left"));
+ assert_eq!(state.grid_cursor_region(), (1, 4, 1, 1), "collapsed, not 1x1-with-an-expanse");
+ assert!(state.grid_cursor_expanse.is_none());
+ assert_eq!(state.selected_slots(), vec![a], "the plain single selection again");
+
+ // The other way round: past the anchor, so the region grows leftward.
+ assert!(state.run_command("extend_left"));
+ assert_eq!(state.grid_cursor_region(), (0, 4, 2, 1));
+ assert!(state.run_command("extend_up"));
+ assert_eq!(state.grid_cursor_region(), (0, 3, 2, 2));
+
+ // And the family is the network pane's, like the other three.
+ state.focused_pane = crate::slots::RIGHT_MENUBAR_IDX;
+ let before = state.grid_cursor_region();
+ state.run_command("extend_right");
+ assert_eq!(state.grid_cursor_region(), before, "not the viewport's key");
+ }
+
/// 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
diff --git a/src/shortcut.rs b/src/shortcut.rs
index 9298145..bdc1b56 100644
--- a/src/shortcut.rs
+++ b/src/shortcut.rs
@@ -35,10 +35,12 @@ pub enum Action {
/// Enter or leave the selected node's viewer state.
ToggleViewerState,
/// Network-pane keyboard navigation, in the plugin's vim-style families:
- /// bare hjkl moves the grid cursor, alt moves the node under it, ctrl pans
- /// the view. The direction rides the variant so one registry row binds one
- /// key, which is what a rebindable scheme needs.
+ /// bare hjkl moves the grid cursor, shift extends it into a region, alt
+ /// moves the selected nodes, ctrl pans the view. The direction rides the
+ /// variant so one registry row binds one key, which is what a rebindable
+ /// scheme needs.
NetworkNav(i32, i32),
+ NetworkExtend(i32, i32),
NetworkMove(i32, i32),
NetworkPan(i32, i32),
FrameCursor,