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

commitb0d90541c2aaa9eca83587ab6686ef1a1503f5c7
parent39cf6b6b12
authorLucas Galante <[email protected]>
date2026-09-19 00:01
feat(network): keyboard graph navigation, as the plugin's own scheme

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, f to frame the cursor and shift+f to
frame everything. Fourteen registry commands in the network context, so
all of it is rebindable through input.kdl and listed in the palette.

Half of it existed already, decoded inline in the key handler and
unrebindable. Three things were wrong or missing there:

- The bare family was the only one not gated on the network pane. Plain
  h/j/k/l moved the grid cursor from anywhere, so it drifted invisibly
  while you were looking at the viewport — the selection did not follow,
  because sync_cursor_and_selection has its own pane check, so the only
  symptom was a cursor that had wandered off when you came back. The gate
  now lives once, in the four network_* methods.
- ctrl+hjkl — pan the view — did not exist. It pans by one CELL rather
  than a fixed pixel count, so a step means the same thing at every zoom.
- f framed everything; the plugin's f frames the CURSOR and shift+f
  frames everything. Both exist now, split the plugin's way. The first
  version of Frame Cursor called keep_cursor_in_view, which pans only
  when the cursor has gone off an edge — so it did nothing at all with a
  cursor that is visible but off in a corner, which is exactly when it
  gets pressed. It centres the cell now.

shift+hjkl — the plugin's extend-the-selection family — is deliberately
absent. The Graph widget carries a single selected_node, and four rows
that quietly did what bare hjkl already does would be worse than the gap.
It wants multi-selection first.

The conflict check from two commits ago earned its place: ctrl+h was
already edit_handles, and the test named the winner and the shadowed
command instead of leaving a chord that silently stopped working.
edit_handles moved to ctrl+shift+h; the ctrl+hjkl family is the plugin's
and keeps the letters.

frame_all_nodes is extracted verbatim from the old f arm so the command
and the key run one implementation, which is the point of the registry.

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

 CLAUDE.md       |  35 ++++++
 shapeshifter.md |  29 ++++-
 src/app.rs      | 355 ++++++++++++++++++++++++++++++++++----------------------
 src/command.rs  |  28 ++++-
 src/main.rs     | 123 ++++++++++++++++++++
 src/shortcut.rs |   9 ++
 6 files changed, 439 insertions(+), 140 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 5692567..cc6a2c2 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -443,6 +443,41 @@ The GPU image is owned by `State::page_image` and freed when replaced;
 everything-at-once node, is deliberately not ported: it is these four chained,
 and that collapse is the whole premise of "fifty operators, ten nodes".
 
+### Keyboard graph navigation
+
+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.
+
+**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
+a node deselects. Every family is gated on the network pane having focus — one
+gate, in the four `network_*` methods. The bare family used to be the one that
+was NOT gated: plain h/j/k/l moved the cursor from any pane, so it drifted
+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
+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 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.
+
+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
+owns those now), and `f` now frames the CURSOR where it used to frame
+everything, with framing everything on `shift+f` — the plugin's split.
+
 ### Commands, chords and the palette
 
 `src/command.rs` is one list of everything the app can be asked to do. Each row
diff --git a/shapeshifter.md b/shapeshifter.md
index 2f10fd4..b9538c9 100644
--- a/shapeshifter.md
+++ b/shapeshifter.md
@@ -387,9 +387,32 @@ Touches: `geometry.rs`, `nodes/*.json`.
 > points. Snapping is a command (`toggle_snap`), which is the previous round's
 > registry paying for itself.
 >
-> Still outstanding for this phase: keyboard graph navigation and auto-layout
-> in the network pane. The keycam navigator the proposal names as a viewer
-> state is not written yet, but the framework it would sit on is.
+> **Keyboard graph navigation landed**, as the plugin's own scheme rather than
+> an invention: hjkl bare to move the grid cursor (the arrows are the playbar
+> transport in every pane), alt to move the node under it, ctrl to pan the view,
+> `f` to frame the cursor and `shift+f` to frame everything. Fourteen registry
+> commands in the network context, so all of it is rebindable through
+> `input.kdl` and listed in the palette.
+>
+> Half of it already existed, hardcoded inline and unrebindable — and the bare
+> family was the one that was never gated on the network pane, so plain hjkl
+> drifted the cursor invisibly while you looked at the viewport. The ctrl pan
+> family did not exist at all. Frame Cursor did not either: `f` framed
+> everything, and the first version of the new command only scrolled the cursor
+> into view, which does nothing in the case you actually press it in — it
+> centres now.
+>
+> `shift+hjkl` is deliberately absent. The Graph widget carries a single
+> `selected_node`, and four rows that quietly did what bare hjkl already does
+> would be worse than the gap. It wants multi-selection first.
+>
+> The conflict check earned its place: `ctrl+h` was taken by `edit_handles` from
+> the previous round, and the test named the winner and the shadowed command
+> rather than leaving a key that silently stopped working.
+>
+> Still outstanding for this phase: auto-layout in the network pane. The keycam
+> navigator the proposal names as a viewer state is not written yet, but the
+> framework it would sit on is.
 
 Independent of all the geometry work, and the place where the app gets to be
 better rather than equal. A **command palette** on the HC Panel's model — fuzzy
diff --git a/src/app.rs b/src/app.rs
index 0a1d7e0..04cfbe9 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -5116,10 +5116,222 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         }
     }
 
+    /// Move the grid cursor one cell, taking the selection with it.
+    ///
+    /// The cursor is the network pane's keyboard position: `sync_cursor_and_
+    /// selection` selects whatever node sits in its cell, so navigating IS
+    /// selecting. Gated on the network pane having focus — plain h/j/k/l used
+    /// to move it from any pane, which drifted the cursor invisibly while you
+    /// were looking at the viewport and put it somewhere unexpected when you
+    /// came back. Every other network key was already gated; this family was
+    /// the one that was not.
+    pub(crate) fn network_nav(&mut self, dc: i32, dr: i32) -> bool {
+        if self.focused_pane != LEFT_MENUBAR_IDX {
+            return false;
+        }
+        self.pan_velocity_x = 0.0;
+        self.pan_velocity_y = 0.0;
+        self.grid_cursor_col += dc;
+        self.grid_cursor_row += dr;
+        self.sync_cursor_and_selection();
+        self.keep_cursor_in_view();
+        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.
+    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);
+            self.sync_nodes();
+            self.sync_layout();
+        }
+        self.network_nav(dc, dr)
+    }
+
+    /// Pan the network view by one cell, leaving the cursor and the selection
+    /// alone — the ctrl+hjkl family, which had no equivalent here.
+    ///
+    /// One cell, not a fixed pixel count, so a pan step means the same thing
+    /// at every zoom level: the sheet moves by exactly one node.
+    pub(crate) fn network_pan_view(&mut self, dc: i32, dr: i32) -> bool {
+        if self.focused_pane != LEFT_MENUBAR_IDX {
+            return false;
+        }
+        self.pan_velocity_x = 0.0;
+        self.pan_velocity_y = 0.0;
+        self.pan_x -= dc as f32 * (self.grid_size_x + self.gap_col_w);
+        self.pan_y -= dr as f32 * (self.grid_size_y + self.gap_row_h);
+        self.rebuild_positions();
+        self.apply_layout();
+        self.update_panel_bounds();
+        true
+    }
+
+    /// Centre the view on the grid cursor, without changing the zoom — the
+    /// counterpart of framing everything, and what `f` means in the plugin
+    /// this is replacing.
+    ///
+    /// CENTRES rather than merely scrolling the cursor into view. The first
+    /// version called `keep_cursor_in_view`, which pans only when the cursor
+    /// has gone off the edge — so the command did nothing at all in the common
+    /// case of a cursor that is already visible but off in a corner, which is
+    /// exactly when you press it.
+    pub(crate) fn frame_cursor(&mut self) -> bool {
+        if self.focused_pane != LEFT_MENUBAR_IDX {
+            return false;
+        }
+        let (_px, _py, pw, ph) = self.positions[CONTENT_IDX];
+        if pw <= 0.0 || ph <= 0.0 {
+            return true;
+        }
+        // Pan so the cursor CELL's centre lands at the pane's centre. The
+        // cell's offset from the sheet origin is its column times the column
+        // pitch, so the pan that centres it is the pane's half-extent minus
+        // that offset, minus half a cell.
+        self.pan_x = pw * 0.5
+            - self.grid_cursor_col as f32 * (self.grid_size_x + self.gap_col_w)
+            - self.grid_size_x * 0.5;
+        self.pan_y = ph * 0.5
+            - self.grid_cursor_row as f32 * (self.grid_size_y + self.gap_row_h)
+            - self.grid_size_y * 0.5;
+        self.pan_velocity_x = 0.0;
+        self.pan_velocity_y = 0.0;
+        self.sync_grid_settings();
+        self.rebuild_positions();
+        self.apply_layout();
+        self.update_panel_bounds();
+        true
+    }
+
+    /// Fit every node in the current level into the network pane.
+    ///
+    /// Extracted verbatim from the `f` key's inline arm so the command
+    /// registry and the key run the same code — the point of the registry
+    /// being that there is one implementation behind every way of asking.
+    pub(crate) fn frame_all_nodes(&mut self) {
+            let active_nodes = self.current_dir().children.len();
+            if active_nodes == 0 {
+                self.grid_size_x = 80.0;
+                self.grid_size_y = 40.0;
+                self.gap_col_w = 20.0;
+                self.gap_row_h = 20.0;
+                self.pan_x = 20.0;
+                self.pan_y = 20.0;
+            } else {
+                let base_gx = 80.0;
+                let base_gy = 40.0;
+                let base_col_w = 20.0;
+                let base_row_h = 20.0;
+
+                let mut b_xmin = f32::MAX;
+                let mut b_xmax = f32::MIN;
+                let mut b_ymin = f32::MAX;
+                let mut b_ymax = f32::MIN;
+
+                for slot_idx in 0..active_nodes {
+                    let (col, row) = self.current_dir().children[slot_idx].position;
+                    let x_min = col * (base_gx + base_col_w);
+                    let x_max = x_min + base_gx;
+                    let y_min = row * (base_gy + base_row_h);
+                    let y_max = y_min + base_gy;
+
+                    if x_min < b_xmin { b_xmin = x_min; }
+                    if x_max > b_xmax { b_xmax = x_max; }
+                    if y_min < b_ymin { b_ymin = y_min; }
+                    if y_max > b_ymax { b_ymax = y_max; }
+                }
+
+                let w_base = b_xmax - b_xmin;
+                let h_base = b_ymax - b_ymin;
+
+                 let (_px, _py, pw, ph) = self.positions[CONTENT_IDX];
+                 let viewport_w = pw;
+                 let viewport_h = ph;
+
+                let padding = 40.0;
+                let padded_w = (viewport_w - 2.0 * padding).max(10.0);
+                let padded_h = (viewport_h - 2.0 * padding).max(10.0);
+
+                let fx = padded_w / w_base;
+                let fy = padded_h / h_base;
+                let mut f = fx.min(fy);
+
+                f = f.min(1.0).max(30.0 / base_gx);
+
+                self.grid_size_x = (base_gx * f).clamp(30.0, 500.0);
+                self.grid_size_y = (base_gy * f).clamp(15.0, 250.0);
+                self.gap_col_w = base_col_w * f;
+                self.gap_row_h = base_row_h * f;
+
+                let mut actual_xmin = f32::MAX;
+                let mut actual_xmax = f32::MIN;
+                let mut actual_ymin = f32::MAX;
+                let mut actual_ymax = f32::MIN;
+
+                for slot_idx in 0..active_nodes {
+                    let (col, row) = self.current_dir().children[slot_idx].position;
+                    let x_min = col * (self.grid_size_x + self.gap_col_w);
+                    let x_max = x_min + self.grid_size_x;
+                    let y_min = row * (self.grid_size_y + self.gap_row_h);
+                    let y_max = y_min + self.grid_size_y;
+
+                    if x_min < actual_xmin { actual_xmin = x_min; }
+                    if x_max > actual_xmax { actual_xmax = x_max; }
+                    if y_min < actual_ymin { actual_ymin = y_min; }
+                    if y_max > actual_ymax { actual_ymax = y_max; }
+                }
+
+                let actual_w = actual_xmax - actual_xmin;
+                let actual_h = actual_ymax - actual_ymin;
+
+                self.pan_x = (viewport_w - actual_w) / 2.0 - actual_xmin;
+                self.pan_y = (viewport_h - actual_h) / 2.0 - actual_ymin;
+            }
+
+            self.sync_grid_settings();
+            self.rebuild_positions();
+            self.apply_layout();
+            self.update_panel_bounds();
+        self.sync_grid_settings();
+        self.rebuild_positions();
+        self.apply_layout();
+        self.update_panel_bounds();
+    }
+
     pub fn execute_action(&mut self, action: Action) {
         let mut settings_changed = false;
         match action {
             Action::CommandPalette => self.open_command_palette(),
+            // 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.
+            Action::NetworkNav(dc, dr) => {
+                self.network_nav(dc, dr);
+            }
+            Action::NetworkMove(dc, dr) => {
+                self.network_move_node(dc, dr);
+            }
+            Action::NetworkPan(dc, dr) => {
+                self.network_pan_view(dc, dr);
+            }
+            Action::FrameCursor => {
+                self.frame_cursor();
+            }
+            Action::FrameAll => {
+                if self.focused_pane == LEFT_MENUBAR_IDX {
+                    self.frame_all_nodes();
+                }
+            }
             Action::ToggleViewerState => {
                 // The selected node, the same one the context menu's entry
                 // would act on — so the command and the menu cannot disagree
@@ -6628,56 +6840,15 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                 let mut changed = false;
                 if event.state == ElementState::Pressed {
                         let is_plain_key = !self.modifiers.control_key() && !self.modifiers.alt_key() && !self.modifiers.super_key();
-                        let is_alt_key = self.modifiers.alt_key() && !self.modifiers.control_key() && !self.modifiers.super_key();
                         let is_ctrl_only = self.modifiers.control_key() && !self.modifiers.alt_key() && !self.modifiers.super_key() && !self.modifiers.shift_key();
 
-                        let mut delta = None;
-
-                        // Grid-cursor movement is hjkl-only: the arrows belong
-                        // to the playbar transport (dispatched above), in every
-                        // pane and context.
-                        match &event.logical_key {
-                            Key::Character(s) => {
-                                match s.as_str() {
-                                    "k" | "K" if is_plain_key || (is_alt_key && self.focused_pane == LEFT_MENUBAR_IDX) => {
-                                        delta = Some((0, -1));
-                                    }
-                                    "j" | "J" if is_plain_key || (is_alt_key && self.focused_pane == LEFT_MENUBAR_IDX) => {
-                                        delta = Some((0, 1));
-                                    }
-                                    "h" | "H" if is_plain_key || (is_alt_key && self.focused_pane == LEFT_MENUBAR_IDX) => {
-                                        delta = Some((-1, 0));
-                                    }
-                                    "l" | "L" if is_plain_key || (is_alt_key && self.focused_pane == LEFT_MENUBAR_IDX) => {
-                                        delta = Some((1, 0));
-                                    }
-                                    _ => {}
-                                }
-                            }
-                            _ => {}
-                        }
-
-                        if let Some((dc, dr)) = delta {
-                            self.pan_velocity_x = 0.0;
-                            self.pan_velocity_y = 0.0;
-                            if is_alt_key {
-                                let active_nodes = self.current_dir().children.len();
-                                let node_idx_at_cursor = self.current_dir().children.iter().take(active_nodes).position(|child| {
-                                    child.position.0 as i32 == self.grid_cursor_col && child.position.1 as i32 == self.grid_cursor_row
-                                });
-                                if let Some(idx) = node_idx_at_cursor {
-                                    let new_x = self.current_dir().children[idx].position.0 + dc as f32;
-                                    let new_y = self.current_dir().children[idx].position.1 + dr as f32;
-                                    self.current_dir_mut().children[idx].position = (new_x, new_y);
-                                    self.sync_nodes();
-                                    self.sync_layout();
-                                }
-                            }
-                            self.grid_cursor_col += dc;
-                            self.grid_cursor_row += dr;
-                            self.sync_cursor_and_selection();
-                            changed = true;
-                        } else {
+                        // The hjkl navigation families used to be decoded
+                        // here, inline and unrebindable, with the bare form
+                        // ungated so it drifted the cursor from any pane.
+                        // They are registry commands now (nav_*, move_*,
+                        // view_*, frame_*), matched with every other chord
+                        // below.
+                        {
                             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() {
@@ -6743,94 +6914,6 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                                             self.zoom(1.15, None);
                                             changed = true;
                                         }
-                                        "f" | "F" => {
-                                            if self.focused_pane == LEFT_MENUBAR_IDX {
-                                                let active_nodes = self.current_dir().children.len();
-                                                if active_nodes == 0 {
-                                                    self.grid_size_x = 80.0;
-                                                    self.grid_size_y = 40.0;
-                                                    self.gap_col_w = 20.0;
-                                                    self.gap_row_h = 20.0;
-                                                    self.pan_x = 20.0;
-                                                    self.pan_y = 20.0;
-                                                } else {
-                                                    let base_gx = 80.0;
-                                                    let base_gy = 40.0;
-                                                    let base_col_w = 20.0;
-                                                    let base_row_h = 20.0;
-
-                                                    let mut b_xmin = f32::MAX;
-                                                    let mut b_xmax = f32::MIN;
-                                                    let mut b_ymin = f32::MAX;
-                                                    let mut b_ymax = f32::MIN;
-
-                                                    for slot_idx in 0..active_nodes {
-                                                        let (col, row) = self.current_dir().children[slot_idx].position;
-                                                        let x_min = col * (base_gx + base_col_w);
-                                                        let x_max = x_min + base_gx;
-                                                        let y_min = row * (base_gy + base_row_h);
-                                                        let y_max = y_min + base_gy;
-
-                                                        if x_min < b_xmin { b_xmin = x_min; }
-                                                        if x_max > b_xmax { b_xmax = x_max; }
-                                                        if y_min < b_ymin { b_ymin = y_min; }
-                                                        if y_max > b_ymax { b_ymax = y_max; }
-                                                    }
-
-                                                    let w_base = b_xmax - b_xmin;
-                                                    let h_base = b_ymax - b_ymin;
-
-                                                     let (_px, _py, pw, ph) = self.positions[CONTENT_IDX];
-                                                     let viewport_w = pw;
-                                                     let viewport_h = ph;
-
-                                                    let padding = 40.0;
-                                                    let padded_w = (viewport_w - 2.0 * padding).max(10.0);
-                                                    let padded_h = (viewport_h - 2.0 * padding).max(10.0);
-
-                                                    let fx = padded_w / w_base;
-                                                    let fy = padded_h / h_base;
-                                                    let mut f = fx.min(fy);
-
-                                                    f = f.min(1.0).max(30.0 / base_gx);
-
-                                                    self.grid_size_x = (base_gx * f).clamp(30.0, 500.0);
-                                                    self.grid_size_y = (base_gy * f).clamp(15.0, 250.0);
-                                                    self.gap_col_w = base_col_w * f;
-                                                    self.gap_row_h = base_row_h * f;
-
-                                                    let mut actual_xmin = f32::MAX;
-                                                    let mut actual_xmax = f32::MIN;
-                                                    let mut actual_ymin = f32::MAX;
-                                                    let mut actual_ymax = f32::MIN;
-
-                                                    for slot_idx in 0..active_nodes {
-                                                        let (col, row) = self.current_dir().children[slot_idx].position;
-                                                        let x_min = col * (self.grid_size_x + self.gap_col_w);
-                                                        let x_max = x_min + self.grid_size_x;
-                                                        let y_min = row * (self.grid_size_y + self.gap_row_h);
-                                                        let y_max = y_min + self.grid_size_y;
-
-                                                        if x_min < actual_xmin { actual_xmin = x_min; }
-                                                        if x_max > actual_xmax { actual_xmax = x_max; }
-                                                        if y_min < actual_ymin { actual_ymin = y_min; }
-                                                        if y_max > actual_ymax { actual_ymax = y_max; }
-                                                    }
-
-                                                    let actual_w = actual_xmax - actual_xmin;
-                                                    let actual_h = actual_ymax - actual_ymin;
-
-                                                    self.pan_x = (viewport_w - actual_w) / 2.0 - actual_xmin;
-                                                    self.pan_y = (viewport_h - actual_h) / 2.0 - actual_ymin;
-                                                }
-
-                                                self.sync_grid_settings();
-                                                self.rebuild_positions();
-                                                self.apply_layout();
-                                                self.update_panel_bounds();
-                                                changed = true;
-                                            }
-                                        }
                                         _ => {}
                                     }
                                 } else if is_ctrl_only && self.focused_pane == LEFT_MENUBAR_IDX {
diff --git a/src/command.rs b/src/command.rs
index 050055f..0f8d5dd 100644
--- a/src/command.rs
+++ b/src/command.rs
@@ -115,6 +115,29 @@ pub const COMMANDS: &[Command] = &[
     Command { id: "command_palette", label: "Command Palette", context: Context::Always, run: Run::Key(Action::CommandPalette), default_chord: Some("Ctrl+p") },
     Command { id: "toggle_configure", label: "Configure", context: Context::Always, run: Run::Key(Action::ToggleConfigure), default_chord: Some("Ctrl+,") },
 
+    // --- Network navigation ---
+    //
+    // The plugin's scheme, ported: hjkl rather than arrows (the arrows are the
+    // playbar transport in every pane), bare to move the cursor, alt to move
+    // the node under it, ctrl to pan the view. `shift+hjkl` — extend the
+    // selection — is deliberately absent: the Graph widget carries a single
+    // `selected_node`, and a select family with nothing to extend would be
+    // four rows that quietly do what bare hjkl already does.
+    Command { id: "nav_left", label: "Cursor Left", context: Context::Network, run: Run::Key(Action::NetworkNav(-1, 0)), default_chord: Some("h") },
+    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") },
+    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") },
+    Command { id: "move_right", label: "Move Node Right", context: Context::Network, run: Run::Key(Action::NetworkMove(1, 0)), default_chord: Some("Alt+l") },
+    Command { id: "view_left", label: "Pan View Left", context: Context::Network, run: Run::Key(Action::NetworkPan(-1, 0)), default_chord: Some("Ctrl+h") },
+    Command { id: "view_down", label: "Pan View Down", context: Context::Network, run: Run::Key(Action::NetworkPan(0, 1)), default_chord: Some("Ctrl+j") },
+    Command { id: "view_up", label: "Pan View Up", context: Context::Network, run: Run::Key(Action::NetworkPan(0, -1)), default_chord: Some("Ctrl+k") },
+    Command { id: "view_right", label: "Pan View Right", context: Context::Network, run: Run::Key(Action::NetworkPan(1, 0)), default_chord: Some("Ctrl+l") },
+    Command { id: "frame_cursor", label: "Frame Cursor", context: Context::Network, run: Run::Key(Action::FrameCursor), default_chord: Some("f") },
+    Command { id: "frame_all", label: "Frame All", context: Context::Network, run: Run::Key(Action::FrameAll), default_chord: Some("Shift+f") },
+
     // --- Network ---
     Command { id: "zoom_in", label: "Zoom In", context: Context::Network, run: Run::Menu("Zoom In"), default_chord: None },
     Command { id: "zoom_out", label: "Zoom Out", context: Context::Network, run: Run::Menu("Zoom Out"), default_chord: None },
@@ -123,7 +146,10 @@ pub const COMMANDS: &[Command] = &[
     Command { id: "detach_circular_window", label: "Detach Circular Window", context: Context::Network, run: Run::Key(Action::DetachCircularWindow), default_chord: None },
 
     // --- Viewer states ---
-    Command { id: "edit_handles", label: "Edit Handles", context: Context::Viewport, run: Run::Key(Action::ToggleViewerState), default_chord: Some("Ctrl+h") },
+    // Ctrl+Shift+H, not Ctrl+H: the ctrl+hjkl family below is the network
+    // pane's view panning, and the conflict check caught the collision the
+    // first time both existed.
+    Command { id: "edit_handles", label: "Edit Handles", context: Context::Viewport, run: Run::Key(Action::ToggleViewerState), default_chord: Some("Ctrl+Shift+h") },
     Command { id: "toggle_snap", label: "Toggle Snapping", context: Context::Viewport, run: Run::Key(Action::ToggleSnap), default_chord: Some("Ctrl+b") },
 
     // --- Viewport ---
diff --git a/src/main.rs b/src/main.rs
index c05f87a..4db2956 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -4264,6 +4264,129 @@ mod tests {
         assert!(source_for("Curve").is_some());
     }
 
+    /// Keyboard graph navigation: the plugin's hjkl families, as commands.
+    ///
+    /// Bare hjkl moves the grid cursor and therefore the selection, alt moves
+    /// the node under it, ctrl pans the view and touches neither. All of it is
+    /// gated on the network pane having focus — the bare family used to be the
+    /// one that was not, so the cursor drifted invisibly while you looked at
+    /// the viewport.
+    #[test]
+    fn test_the_network_navigation_families() {
+        use crate::slots::{LEFT_MENUBAR_IDX, RIGHT_MENUBAR_IDX};
+        let mut state = State::new(false);
+        let mut redraw = false;
+        state
+            .apply_action(
+                McpAction::AddNode { template_name: "Sphere".to_string(), name: None, x: 3.0, y: 2.0 },
+                &mut redraw,
+            )
+            .expect("add node");
+        let slot = state.current_dir().children.len() - 1;
+        assert_eq!(state.current_dir().children[slot].position, (3.0, 2.0));
+
+        state.focused_pane = LEFT_MENUBAR_IDX;
+        state.param_editor = crate::slots::CONTENT_IDX;
+        state.grid_cursor_col = 3;
+        state.grid_cursor_row = 2;
+        state.sync_cursor_and_selection();
+        assert_eq!(state.graph().selected_node(), Some(slot), "the cursor should select what it sits on");
+
+        // Bare hjkl walks the cursor, and the selection follows it off the
+        // node. Up, not right: the default project already has a node at
+        // (4, 2), and navigating onto it would select that one instead —
+        // correctly, which is exactly why the empty cell has to be chosen
+        // deliberately rather than assumed.
+        assert!(state.run_command("nav_up"));
+        assert_eq!((state.grid_cursor_col, state.grid_cursor_row), (3, 1));
+        assert_eq!(state.graph().selected_node(), None, "the cursor left the node");
+        assert!(state.run_command("nav_down"));
+        assert_eq!(state.graph().selected_node(), Some(slot), "and came back to it");
+
+        // And navigating ONTO another node selects that one.
+        let neighbour = state
+            .current_dir()
+            .children
+            .iter()
+            .position(|c| c.position == (4.0, 2.0))
+            .expect("the default project has a node at (4, 2)");
+        assert!(state.run_command("nav_right"));
+        assert_eq!(state.graph().selected_node(), Some(neighbour));
+        assert!(state.run_command("nav_left"));
+
+        // Alt moves the node AND the cursor, so a run of them drags it rather
+        // than leaving it behind on the first press.
+        assert!(state.run_command("move_down"));
+        assert_eq!(state.current_dir().children[slot].position, (3.0, 3.0));
+        assert_eq!((state.grid_cursor_col, state.grid_cursor_row), (3, 3));
+        assert_eq!(state.graph().selected_node(), Some(slot), "the node should still be selected");
+        assert!(state.run_command("move_down"));
+        assert_eq!(state.current_dir().children[slot].position, (3.0, 4.0));
+
+        // Ctrl pans the view: the cursor, the selection and the node all stay.
+        let before = (state.pan_x, state.pan_y);
+        let cursor = (state.grid_cursor_col, state.grid_cursor_row);
+        assert!(state.run_command("view_right"));
+        assert_ne!((state.pan_x, state.pan_y), before, "the view did not pan");
+        assert_eq!((state.grid_cursor_col, state.grid_cursor_row), cursor);
+        assert_eq!(state.current_dir().children[slot].position, (3.0, 4.0));
+        assert_eq!(state.graph().selected_node(), Some(slot));
+
+        // Frame Cursor CENTRES the cursor cell, rather than only scrolling it
+        // into view when it has gone off an edge — which would make the
+        // command do nothing in the case you actually press it in.
+        state.positions[crate::slots::CONTENT_IDX] = (0.0, 0.0, 800.0, 600.0);
+        state.grid_cursor_col = 9;
+        state.grid_cursor_row = 7;
+        assert!(state.run_command("frame_cursor"));
+        let cell_x = 9.0 * (state.grid_size_x + state.gap_col_w) + state.pan_x;
+        let cell_y = 7.0 * (state.grid_size_y + state.gap_row_h) + state.pan_y;
+        assert!(
+            (cell_x + state.grid_size_x * 0.5 - 400.0).abs() < 1.0,
+            "the cursor cell is not centred horizontally: {cell_x}"
+        );
+        assert!(
+            (cell_y + state.grid_size_y * 0.5 - 300.0).abs() < 1.0,
+            "the cursor cell is not centred vertically: {cell_y}"
+        );
+
+        // Every family is gated on the network pane. With the viewport focused
+        // the commands run and do nothing, rather than moving a cursor nobody
+        // can see.
+        state.focused_pane = RIGHT_MENUBAR_IDX;
+        let cursor = (state.grid_cursor_col, state.grid_cursor_row);
+        let pos = state.current_dir().children[slot].position;
+        let pan = (state.pan_x, state.pan_y);
+        for id in ["nav_left", "nav_right", "nav_up", "nav_down", "move_left", "view_left", "frame_cursor"] {
+            assert!(state.run_command(id), "{id} should be a known command");
+        }
+        assert_eq!((state.grid_cursor_col, state.grid_cursor_row), cursor, "the cursor moved from another pane");
+        assert_eq!(state.current_dir().children[slot].position, pos, "a node moved from another pane");
+        assert_eq!((state.pan_x, state.pan_y), pan, "the view panned from another pane");
+    }
+
+    /// 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.
+    #[test]
+    fn test_the_navigation_scheme_matches_the_plugins() {
+        use crate::command::{by_id, Context};
+        let expected = [
+            // 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"),
+            ("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"),
+        ];
+        for (id, chord) in expected {
+            let cmd = by_id(id).unwrap_or_else(|| panic!("{id} is not a command"));
+            assert_eq!(cmd.context, Context::Network, "{id} is not a network command");
+            let parsed = crate::shortcut::Shortcut::parse(cmd.default_chord.expect(id)).unwrap();
+            assert_eq!(parsed.describe(), chord, "{id} is not bound where the plugin binds it");
+        }
+    }
+
     /// A page's raster is its physical size times its resolution — the
     /// property that makes DPI a page parameter rather than an export one.
     #[test]
diff --git a/src/shortcut.rs b/src/shortcut.rs
index a44003b..cd53c5c 100644
--- a/src/shortcut.rs
+++ b/src/shortcut.rs
@@ -29,6 +29,15 @@ pub enum Action {
     ToggleSnap,
     /// 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.
+    NetworkNav(i32, i32),
+    NetworkMove(i32, i32),
+    NetworkPan(i32, i32),
+    FrameCursor,
+    FrameAll,
 }
 
 #[derive(Debug, Clone)]