Files
clide/test/kernel/src/keymap/pane_key_nav_test.dart
T
jpmschweitzerandClaude Opus 4.8 25db19fc0e 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>
2026-06-13 14:46:04 +02:00

82 lines
3.4 KiB
Dart

/// Widget tests for PaneKeyNav (T-406): the per-pane vim-normal key handler
/// that runs its own SequenceMatcher and dispatches nav.* intents — proven
/// end-to-end against the real vim preset and scope flags.
library;
import 'package:clide/kernel/kernel.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../../helpers/kernel_fixture.dart';
import '../../../helpers/widget_harness.dart';
void main() {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() => f.dispose());
Future<List<NavIntent>> pump(WidgetTester tester, {required Map<String, bool> scope}) async {
// setPreset does real asset + keybindings-file I/O; run it outside the
// fake-async zone or the testWidgets body hangs (the T-122 lesson).
await tester.runAsync(() => f.services.keymap.setPreset('vim'));
for (final e in scope.entries) {
f.services.keymap.setScopeFlag(e.key, e.value);
}
final got = <NavIntent>[];
final node = FocusNode();
addTearDown(node.dispose);
await tester.pumpWidget(
harness(f, PaneKeyNav(focusNode: node, autofocus: true, onNav: (i, _) => got.add(i), child: const SizedBox(width: 100, height: 100))),
);
node.requestFocus();
await tester.pump();
return got;
}
testWidgets('bare motions dispatch nav.* under vim.normal (pane focused)', (tester) async {
final got = await pump(tester, scope: {'vim.normal': true});
await tester.sendKeyEvent(LogicalKeyboardKey.keyJ);
await tester.sendKeyEvent(LogicalKeyboardKey.keyK);
await tester.sendKeyEvent(LogicalKeyboardKey.keyH);
await tester.sendKeyEvent(LogicalKeyboardKey.keyL);
expect(got, [isA<NavDownIntent>(), isA<NavUpIntent>(), isA<NavCollapseOrLeftIntent>(), isA<NavExpandOrRightIntent>()]);
});
testWidgets('gg sequence resolves to nav.top', (tester) async {
final got = await pump(tester, scope: {'vim.normal': true});
await tester.sendKeyEvent(LogicalKeyboardKey.keyG);
await tester.sendKeyEvent(LogicalKeyboardKey.keyG);
expect(got, [isA<NavTopIntent>()]);
});
testWidgets('ctrl+d / ctrl+u are claimed as half-page nav', (tester) async {
final got = await pump(tester, scope: {'vim.normal': true});
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.keyD);
await tester.sendKeyEvent(LogicalKeyboardKey.keyU);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
expect(got, [isA<NavPageDownIntent>(), isA<NavPageUpIntent>()]);
});
testWidgets('the editor.focused guard suppresses nav (keys go to the editor)', (tester) async {
final got = await pump(tester, scope: {'vim.normal': true, 'editor.focused': true});
await tester.sendKeyEvent(LogicalKeyboardKey.keyJ);
await tester.sendKeyEvent(LogicalKeyboardKey.keyK);
// j/k now resolve to editor.vim.* — not NavIntents — so onNav never fires.
expect(got, isEmpty);
});
testWidgets('keys pass through outside vim normal mode', (tester) async {
final got = await pump(tester, scope: {'vim.insert': true});
await tester.sendKeyEvent(LogicalKeyboardKey.keyJ);
expect(got, isEmpty);
});
testWidgets('an unbound bare key is swallowed without dispatching nav', (tester) async {
final got = await pump(tester, scope: {'vim.normal': true});
await tester.sendKeyEvent(LogicalKeyboardKey.keyZ);
expect(got, isEmpty);
});
}