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:
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user