vim normal-mode navigation in non-editor panes (T-406)

The structural T-403 child: make vim normal mode mean navigation in panes
that were mouse-only. The passive global key path can't run multi-chord
sequences (D-82), so each pane hosts its own SequenceMatcher — factored into
a reusable PaneKeyNav that resolves the live keymap and dispatches nav.*
intents while a pane holds focus under the vim preset.

- nav.* intents (down/up/pageDown/pageUp/top/bottom/expandOrRight/
  collapseOrLeft/activate) — preset-neutral; vim.yaml binds j/k/ctrl+d/ctrl+u/
  gg/G/l/h/[o,enter] under `vim.normal && !editor.focused`.
- The editor publishes an `editor.focused` scope flag from its focus node, so
  the same keys stay buffer motions while the editor is focused and become nav
  when a pane is — resolved by file order + the guard (no change to the editor
  motion bindings).
- File tree: a flattened visible-index selection cursor in FileTreeController
  (j/k move, h collapse-or-out, l expand-or-into, o/enter open), with a focus
  ring + scroll-into-view.
- Conversation: j/k line-scroll, ctrl+d/u half-page, gg top, G bottom — G
  re-arms follow-tail.

Foundation for T-404/T-405/T-407, which build on the per-pane matcher and the
editor.focused guard. Git panel + ticket board list nav deferred to a
follow-up (the ticket says lists can trail). Tests: keymap resolution under
both scopes, PaneKeyNav dispatch, the controller selection model, and
end-to-end key-driven nav in both panes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-13 14:46:04 +02:00
co-authored by Claude Opus 4.8
parent 9a44001fbf
commit 25db19fc0e
17 changed files with 1093 additions and 26 deletions
+67
View File
@@ -98,6 +98,63 @@ class TextScaleResetIntent extends Intent {
const TextScaleResetIntent();
}
// -- Pane navigation (vim normal-mode motions outside the editor) ------------
/// Base for the preset-neutral navigation intents (T-406). A focused non-editor
/// pane (file tree, conversation, lists) runs its own [SequenceMatcher] and
/// dispatches the resolved [NavIntent] to its own handler — the vim preset binds
/// j/k/etc. to these; default/vscode/jetbrains can later bind arrows/page keys
/// to the same ids. Marker base so a pane's key handler can tell a nav motion
/// apart from any other fired intent.
sealed class NavIntent extends Intent {
const NavIntent();
}
/// Move the selection / scroll down one step (vim `j`).
class NavDownIntent extends NavIntent {
const NavDownIntent();
}
/// Move the selection / scroll up one step (vim `k`).
class NavUpIntent extends NavIntent {
const NavUpIntent();
}
/// Scroll down half a viewport (vim `ctrl+d`).
class NavPageDownIntent extends NavIntent {
const NavPageDownIntent();
}
/// Scroll up half a viewport (vim `ctrl+u`).
class NavPageUpIntent extends NavIntent {
const NavPageUpIntent();
}
/// Jump to the first item / top (vim `gg`).
class NavTopIntent extends NavIntent {
const NavTopIntent();
}
/// Jump to the last item / bottom (vim `G`).
class NavBottomIntent extends NavIntent {
const NavBottomIntent();
}
/// Expand the focused node, or step into it / move right (vim `l`).
class NavExpandOrRightIntent extends NavIntent {
const NavExpandOrRightIntent();
}
/// Collapse the focused node, or step out of it / move left (vim `h`).
class NavCollapseOrLeftIntent extends NavIntent {
const NavCollapseOrLeftIntent();
}
/// Activate the focused item — open the file, run the row (vim `o` / `enter`).
class NavActivateIntent extends NavIntent {
const NavActivateIntent();
}
// -- Command bridge ---------------------------------------------------------
/// Generic "invoke this CommandRegistry command id" intent. Used for
@@ -136,6 +193,16 @@ final Map<String, Intent Function()> builtinIntents = {
'quickOpen.selectPrevious': () => const QuickOpenSelectPreviousIntent(),
'quickOpen.accept': () => const QuickOpenAcceptIntent(),
'findInFiles.open': () => const FindInFilesIntent(),
// Pane navigation (T-406) — preset-neutral; the vim preset binds j/k/etc.
'nav.down': () => const NavDownIntent(),
'nav.up': () => const NavUpIntent(),
'nav.pageDown': () => const NavPageDownIntent(),
'nav.pageUp': () => const NavPageUpIntent(),
'nav.top': () => const NavTopIntent(),
'nav.bottom': () => const NavBottomIntent(),
'nav.expandOrRight': () => const NavExpandOrRightIntent(),
'nav.collapseOrLeft': () => const NavCollapseOrLeftIntent(),
'nav.activate': () => const NavActivateIntent(),
'text.scaleIncrease': () => const TextScaleIncreaseIntent(),
'text.scaleDecrease': () => const TextScaleDecreaseIntent(),
'text.scaleReset': () => const TextScaleResetIntent(),
+122
View File
@@ -0,0 +1,122 @@
/// A reusable vim normal-mode navigation key handler for non-editor panes
/// (T-406).
///
/// The passive global key path is single-chord only and can't run sequences or
/// consume events (D-82), so — exactly like the editor's command-mode handler —
/// each pane that wants vim motions hosts its OWN [SequenceMatcher] inside a
/// `Focus.onKeyEvent`. [PaneKeyNav] is that handler, factored out so the file
/// tree, conversation, and lists share one implementation.
///
/// While a `vim.normal` scope flag is set and this region holds focus, bare and
/// shift-only chords (plus the two half-page chords `ctrl+d` / `ctrl+u`) feed
/// the matcher against the live keymap; a fired [NavIntent] is handed to
/// [onNav] with its repeat count. Everything else under `vim.normal` is
/// swallowed (vim normal mode is inert for unbound keys), except other-modifier
/// chords (palette, quick-open, …) which bubble to the global handler. Under a
/// non-vim preset or in insert mode the region is transparent — keys pass
/// straight through.
///
/// The vim preset binds nav.* `when: vim.normal && !editor.focused`, so a key
/// that also has an `editor.vim.*` motion (j/k/h/l/gg/G) resolves to the nav
/// intent here and to the editor motion in the editor — see vim.yaml.
library;
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import '../facade.dart';
import 'intents.dart';
import 'key_chord.dart';
import 'keymap.dart';
import 'sequence_matcher.dart';
/// Signature for a fired navigation motion: the [intent] and its repeat
/// [count] (>= 1, from a leading digit prefix like `5j`).
typedef NavHandler = void Function(NavIntent intent, int count);
class PaneKeyNav extends StatefulWidget {
const PaneKeyNav({super.key, required this.child, required this.onNav, this.focusNode, this.autofocus = false, this.canRequestFocus = true});
final Widget child;
/// Called when a `nav.*` motion resolves while this region has focus.
final NavHandler onNav;
/// Focus node for the region. When null, [PaneKeyNav] owns one. Panes that
/// want to move focus here programmatically (a row tap, F6) pass their own.
final FocusNode? focusNode;
final bool autofocus;
/// Whether the region can take focus at all. False makes it a pure pass-through
/// (used when a pane temporarily routes keys elsewhere, e.g. a filter box).
final bool canRequestFocus;
@override
State<PaneKeyNav> createState() => _PaneKeyNavState();
}
class _PaneKeyNavState extends State<PaneKeyNav> {
FocusNode? _ownNode;
SequenceMatcher? _matcher;
FocusNode get _node => widget.focusNode ?? (_ownNode ??= FocusNode(debugLabel: 'PaneKeyNav'));
/// The half-page scroll chords are the only modified chords this handler
/// claims; every other modified chord bubbles to the global shortcut path.
static final KeyChord _ctrlD = KeyChord(modifiers: const {KeyModifier.ctrl}, key: LogicalKeyboardKey.keyD);
static final KeyChord _ctrlU = KeyChord(modifiers: const {KeyModifier.ctrl}, key: LogicalKeyboardKey.keyU);
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_matcher != null) return;
final kernel = ClideKernel.of(context);
_matcher = SequenceMatcher(keymap: () => kernel.keymap.keymap ?? Keymap(const []), context: () => kernel.keymap.scope);
}
@override
void dispose() {
_ownNode?.dispose();
super.dispose();
}
KeyEventResult _onKey(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) return KeyEventResult.ignored;
final kernel = ClideKernel.of(context);
// Only vim normal mode drives pane navigation. Insert/visual or a non-vim
// preset → transparent, keys pass through to whatever's below.
if (kernel.keymap.scope['vim.normal'] != true) return KeyEventResult.ignored;
final hw = HardwareKeyboard.instance;
final chord = KeyChord.fromKeyEvent(event, hw);
if (chord == null) return KeyEventResult.ignored;
// Bare + shift-only chords drive the matcher; ctrl+d/ctrl+u are the only
// modified chords we claim (half-page scroll). Any other modified chord is
// an app shortcut (palette, quick-open) — let it bubble to the global path.
final modified = chord.modifiers.any((m) => m != KeyModifier.shift);
if (modified && chord != _ctrlD && chord != _ctrlU) return KeyEventResult.ignored;
final r = _matcher!.feed(chord);
switch (r.outcome) {
case SeqOutcome.fired:
// The vim preset also binds these keys to editor.vim.* motions; in a
// pane only nav.* applies. A non-nav fired intent (e.g. a stray
// editor.vim.* with no focus guard) is swallowed, never executed here.
if (r.intent is NavIntent) widget.onNav(r.intent! as NavIntent, r.count);
return KeyEventResult.handled;
case SeqOutcome.pending:
return KeyEventResult.handled;
case SeqOutcome.unmatched:
// Vim normal mode beeps on unbound keys — swallow so a bare key never
// leaks to text input or the global handler.
return KeyEventResult.handled;
}
}
@override
Widget build(BuildContext context) {
return Focus(focusNode: _node, autofocus: widget.autofocus, canRequestFocus: widget.canRequestFocus, onKeyEvent: _onKey, child: widget.child);
}
}