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:
@@ -14,10 +14,11 @@ import 'package:clide/builtin/claude/src/image_thumbnail.dart';
|
||||
import 'package:clide/builtin/claude/src/transcript_publisher.dart';
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
||||
import 'package:clide/builtin/claude/src/workflow_run.dart';
|
||||
import 'package:clide/kernel/kernel.dart' show PaneKeyNav;
|
||||
import 'package:clide/kernel/src/events/message_bus.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart' show Builder, Image, FileImage, MediaQuery, ValueKey;
|
||||
import 'package:flutter/widgets.dart' show Builder, Focus, Image, FileImage, MediaQuery, Scrollable, ScrollableState, ValueKey;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
@@ -199,6 +200,61 @@ void main() {
|
||||
expect(find.text('Waiting for Claude…'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('vim G / gg / j scroll the conversation under vim.normal (T-406)', (tester) async {
|
||||
await tester.runAsync(() => f.services.keymap.setPreset('vim'));
|
||||
f.services.keymap.setScopeFlag('vim.normal', true);
|
||||
addTearDown(() => f.services.keymap.clearScopeFlag('vim.normal'));
|
||||
|
||||
// Enough prose to overflow the 700px viewport so there's room to scroll.
|
||||
await pumpWith(tester, [for (var i = 0; i < 40; i++) AssistantTextMessage(uuid: 'a$i', timestamp: _t, isSidechain: false, text: 'line number $i')]);
|
||||
|
||||
// Focus the pane's nav region (its own Focus is PaneKeyNav's outermost).
|
||||
final node = tester.widget<Focus>(find.descendant(of: find.byType(PaneKeyNav), matching: find.byType(Focus)).first).focusNode!;
|
||||
node.requestFocus();
|
||||
await tester.pump();
|
||||
|
||||
final pos = tester.state<ScrollableState>(find.byType(Scrollable).first).position;
|
||||
expect(pos.maxScrollExtent, greaterThan(0), reason: 'content must overflow to scroll');
|
||||
|
||||
// G → jump to the bottom.
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyG);
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
|
||||
await tester.pump();
|
||||
expect(pos.pixels, pos.maxScrollExtent);
|
||||
|
||||
// gg → jump to the top.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyG);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyG);
|
||||
await tester.pump();
|
||||
expect(pos.pixels, 0);
|
||||
|
||||
// j → down one line (48px); k → back up.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyJ);
|
||||
await tester.pump();
|
||||
expect(pos.pixels, 48);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyK);
|
||||
await tester.pump();
|
||||
expect(pos.pixels, 0);
|
||||
|
||||
// ctrl+d / ctrl+u → half a viewport down then back up.
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyD);
|
||||
await tester.pump();
|
||||
expect(pos.pixels, greaterThan(0));
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyU);
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
|
||||
await tester.pump();
|
||||
expect(pos.pixels, 0);
|
||||
|
||||
// h / l / o have no reader-pane semantics — they don't move the scroll.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyL);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyH);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyO);
|
||||
await tester.pump();
|
||||
expect(pos.pixels, 0);
|
||||
});
|
||||
|
||||
testWidgets('a Workflow tool-use with a live run renders the workflow card (T-416)', (tester) async {
|
||||
var run = const WorkflowRun(toolUseId: 'x1', name: 'parallel-words');
|
||||
run = run.foldEvent({
|
||||
|
||||
@@ -56,6 +56,13 @@ void main() {
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
testWidgets('focusing the editor publishes editor.focused (T-406)', (tester) async {
|
||||
stubOneBuffer('hello');
|
||||
expect(f.services.keymap.scope['editor.focused'], isNot(true));
|
||||
await pumpEditor(tester); // taps into the editor → focus
|
||||
expect(f.services.keymap.scope['editor.focused'], isTrue, reason: 'pane nav guards on !editor.focused');
|
||||
});
|
||||
|
||||
testWidgets('normal-mode x deletes the char under the caret', (tester) async {
|
||||
String? sentText;
|
||||
f.ipc.stub('editor.set-content', (a) async {
|
||||
|
||||
@@ -22,9 +22,13 @@ void main() {
|
||||
km = Keymap([KeymapLayer.fromYaml(src)]);
|
||||
});
|
||||
|
||||
const normal = {'vim.normal': true};
|
||||
// Editor-focused normal mode: j/k/h/l/gg/G/o are buffer motions here because
|
||||
// the `editor.focused` flag suppresses the pane-nav bindings (T-406).
|
||||
const normal = {'vim.normal': true, 'editor.focused': true};
|
||||
const insert = {'vim.insert': true};
|
||||
const visual = {'vim.visual': true};
|
||||
// A non-editor pane focused under vim normal mode: the same keys are nav.*.
|
||||
const paneNormal = {'vim.normal': true};
|
||||
|
||||
Intent? resolve(String chord, Map<String, bool> scope) => km.resolve(KeyChord.parse(chord), scope);
|
||||
|
||||
@@ -83,4 +87,43 @@ void main() {
|
||||
expect(m.feed(KeyChord.parse('g')).outcome, SeqOutcome.pending);
|
||||
expect(_cmd(m.feed(KeyChord.parse('g')).intent), 'editor.vim.docStart');
|
||||
});
|
||||
|
||||
group('pane navigation (T-406)', () {
|
||||
test('motion keys resolve to nav.* when a non-editor pane is focused', () {
|
||||
expect(resolve('j', paneNormal), isA<NavDownIntent>());
|
||||
expect(resolve('k', paneNormal), isA<NavUpIntent>());
|
||||
expect(resolve('h', paneNormal), isA<NavCollapseOrLeftIntent>());
|
||||
expect(resolve('l', paneNormal), isA<NavExpandOrRightIntent>());
|
||||
expect(resolve('ctrl+d', paneNormal), isA<NavPageDownIntent>());
|
||||
expect(resolve('ctrl+u', paneNormal), isA<NavPageUpIntent>());
|
||||
expect(resolve('shift+g', paneNormal), isA<NavBottomIntent>());
|
||||
expect(resolve('o', paneNormal), isA<NavActivateIntent>());
|
||||
expect(resolve('enter', paneNormal), isA<NavActivateIntent>());
|
||||
});
|
||||
|
||||
test('the editor.focused guard hands the same keys to the editor', () {
|
||||
// With the editor focused, nav.* is suppressed and the buffer motions win.
|
||||
expect(_cmd(resolve('j', normal)), 'editor.vim.down');
|
||||
expect(_cmd(resolve('h', normal)), 'editor.vim.left');
|
||||
expect(_cmd(resolve('l', normal)), 'editor.vim.right');
|
||||
expect(_cmd(resolve('shift+g', normal)), 'editor.vim.docEnd');
|
||||
expect(_cmd(resolve('o', normal)), 'editor.vim.openBelow');
|
||||
});
|
||||
|
||||
test('gg resolves to nav.top in a pane, docStart in the editor', () {
|
||||
final pane = SequenceMatcher(keymap: () => km, context: () => paneNormal);
|
||||
pane.feed(KeyChord.parse('g'));
|
||||
expect(pane.feed(KeyChord.parse('g')).intent, isA<NavTopIntent>());
|
||||
|
||||
final editor = SequenceMatcher(keymap: () => km, context: () => normal);
|
||||
editor.feed(KeyChord.parse('g'));
|
||||
expect(_cmd(editor.feed(KeyChord.parse('g')).intent), 'editor.vim.docStart');
|
||||
});
|
||||
|
||||
test('pane nav is normal-mode only — visual mode keeps the editor motion', () {
|
||||
// nav.* is guarded `vim.normal && !editor.focused`; visual mode has no
|
||||
// vim.normal flag, so j stays the editor motion even without editor.focused.
|
||||
expect(_cmd(resolve('j', visual)), 'editor.vim.down');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -382,6 +382,99 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('FileTreeController — keyboard selection (T-406)', () {
|
||||
// Tree: '' (root) → [lib/ (→ app.dart), main.dart]
|
||||
Future<FileTreeController> tree({bool expandLib = false}) async {
|
||||
f.ipc.stub('files.root', (_) async => _ok({'path': '/ws'}));
|
||||
f.ipc.stub('files.watch', (_) async => _ok(const {}));
|
||||
f.ipc.stub('files.ls', (args) async {
|
||||
final path = args['path'] as String? ?? '';
|
||||
if (path == '') {
|
||||
return _ok({
|
||||
'entries': [_fileEntry(name: 'lib', path: 'lib', isDirectory: true), _fileEntry(name: 'main.dart', path: 'main.dart')],
|
||||
});
|
||||
}
|
||||
if (path == 'lib') {
|
||||
return _ok({
|
||||
'entries': [_fileEntry(name: 'app.dart', path: 'lib/app.dart')],
|
||||
});
|
||||
}
|
||||
return _ok({'entries': <Object?>[]});
|
||||
});
|
||||
final c = makeCtrl();
|
||||
await c.load();
|
||||
if (expandLib) await c.toggle('lib');
|
||||
return c;
|
||||
}
|
||||
|
||||
test('visibleNodes flattens the root + expanded children in render order', () async {
|
||||
final c = await tree(expandLib: true);
|
||||
expect(c.visibleNodes().map((n) => n.path), ['', 'lib', 'lib/app.dart', 'main.dart']);
|
||||
expect(c.visibleNodes().map((n) => n.depth), [0, 1, 2, 1]);
|
||||
});
|
||||
|
||||
test('a collapsed directory hides its children from the visible list', () async {
|
||||
final c = await tree();
|
||||
expect(c.visibleNodes().map((n) => n.path), ['', 'lib', 'main.dart']);
|
||||
});
|
||||
|
||||
test('moveSelection walks the visible list and clamps at the ends', () async {
|
||||
final c = await tree(expandLib: true);
|
||||
expect(c.selectedPath, isNull);
|
||||
c.moveSelection(1);
|
||||
expect(c.selectedPath, ''); // first move lands on the root
|
||||
c.moveSelection(1);
|
||||
expect(c.selectedPath, 'lib');
|
||||
c.moveSelection(2);
|
||||
expect(c.selectedPath, 'main.dart'); // lib/app.dart skipped over by +2
|
||||
c.moveSelection(5); // clamp at the bottom
|
||||
expect(c.selectedPath, 'main.dart');
|
||||
c.moveSelection(-100); // clamp at the top
|
||||
expect(c.selectedPath, '');
|
||||
});
|
||||
|
||||
test('selectEdge jumps to the first / last visible row (gg / G)', () async {
|
||||
final c = await tree(expandLib: true);
|
||||
c.selectEdge(top: false);
|
||||
expect(c.selectedPath, 'main.dart');
|
||||
c.selectEdge(top: true);
|
||||
expect(c.selectedPath, '');
|
||||
});
|
||||
|
||||
test('expandOrInto expands a collapsed dir, then steps into its first child', () async {
|
||||
final c = await tree();
|
||||
c.moveSelection(1); // root
|
||||
c.moveSelection(1); // lib (collapsed)
|
||||
expect(c.isExpanded('lib'), isFalse);
|
||||
await c.expandOrInto(); // expands
|
||||
expect(c.isExpanded('lib'), isTrue);
|
||||
expect(c.selectedPath, 'lib'); // selection stays on the dir
|
||||
await c.expandOrInto(); // steps into first child
|
||||
expect(c.selectedPath, 'lib/app.dart');
|
||||
});
|
||||
|
||||
test('collapseOrOut collapses an expanded dir, then steps out to the parent', () async {
|
||||
final c = await tree(expandLib: true);
|
||||
c.selectEdge(top: true);
|
||||
c.moveSelection(2); // lib/app.dart
|
||||
expect(c.selectedPath, 'lib/app.dart');
|
||||
await c.collapseOrOut(); // a file → step to parent
|
||||
expect(c.selectedPath, 'lib');
|
||||
await c.collapseOrOut(); // an expanded dir → collapse in place
|
||||
expect(c.isExpanded('lib'), isFalse);
|
||||
expect(c.selectedPath, 'lib');
|
||||
});
|
||||
|
||||
test('activateTarget reports the selected row as dir-or-file for the view', () async {
|
||||
final c = await tree(expandLib: true);
|
||||
c.selectEdge(top: true);
|
||||
c.moveSelection(1); // lib
|
||||
expect(c.activateTarget(), (isDirectory: true, path: 'lib'));
|
||||
c.moveSelection(2); // main.dart
|
||||
expect(c.activateTarget(), (isDirectory: false, path: 'main.dart'));
|
||||
});
|
||||
});
|
||||
|
||||
group('FileTreeController — dispose()', () {
|
||||
test('dispose cancels event subscription without error', () async {
|
||||
f.ipc.stub('files.root', (_) async => _ok({'path': '/ws'}));
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/// Widget tests for keyboard navigation in the file tree (T-406): under the vim
|
||||
/// preset a focused tree moves a selection cursor with j/k, expands with l, and
|
||||
/// opens the selected file with o/enter — driving the FileTreeController through
|
||||
/// PaneKeyNav.
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/files/src/file_tree_view.dart';
|
||||
import 'package:clide/clide.dart';
|
||||
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';
|
||||
|
||||
IpcResponse _ok(Map<String, Object?> data) => IpcResponse.ok(id: '', data: data);
|
||||
Map<String, Object?> _entry(String name, String path, {bool dir = false}) => {
|
||||
'name': name,
|
||||
'path': path,
|
||||
'isDirectory': dir,
|
||||
'isSymlink': false,
|
||||
'sizeBytes': 0,
|
||||
'modifiedMs': 0,
|
||||
};
|
||||
|
||||
void main() {
|
||||
late KernelFixture f;
|
||||
setUp(() async => f = await KernelFixture.create());
|
||||
tearDown(() => f.dispose());
|
||||
|
||||
// Tree: /repo → [lib/ (→ app.dart), main.dart].
|
||||
void stubTree() {
|
||||
f.ipc.stub('files.root', (_) async => _ok({'path': '/repo'}));
|
||||
f.ipc.stub('files.watch', (_) async => _ok(const {}));
|
||||
f.ipc.stub('files.ls', (args) async {
|
||||
final path = args['path'] as String? ?? '';
|
||||
if (path == '') {
|
||||
return _ok({
|
||||
'entries': [_entry('lib', 'lib', dir: true), _entry('main.dart', 'main.dart')],
|
||||
});
|
||||
}
|
||||
if (path == 'lib') {
|
||||
return _ok({
|
||||
'entries': [_entry('app.dart', 'lib/app.dart')],
|
||||
});
|
||||
}
|
||||
return _ok({'entries': <Object?>[]});
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> mountFocused(WidgetTester tester) async {
|
||||
await tester.runAsync(() => f.services.keymap.setPreset('vim'));
|
||||
f.services.keymap.setScopeFlag('vim.normal', true);
|
||||
addTearDown(() => f.services.keymap.clearScopeFlag('vim.normal'));
|
||||
await tester.pumpWidget(harness(f, const FileTreeView()));
|
||||
await pumpAsync(tester);
|
||||
final node = tester.widget<Focus>(find.descendant(of: find.byType(PaneKeyNav), matching: find.byType(Focus)).first).focusNode!;
|
||||
node.requestFocus();
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
testWidgets('j moves the selection and o opens the selected file (T-406)', (tester) async {
|
||||
stubTree();
|
||||
final opened = <String>[];
|
||||
f.ipc.stub('editor.open', (args) async {
|
||||
opened.add(args['path'] as String? ?? '');
|
||||
return _ok(const {});
|
||||
});
|
||||
await mountFocused(tester);
|
||||
|
||||
// visible: '' (root), 'lib', 'main.dart'. j×3 lands on main.dart.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyJ);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyJ);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyJ);
|
||||
await tester.pump();
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyO);
|
||||
await tester.pump();
|
||||
await pumpAsync(tester);
|
||||
|
||||
expect(opened, ['main.dart']);
|
||||
});
|
||||
|
||||
testWidgets('l expands the selected directory, h collapses it (T-406)', (tester) async {
|
||||
stubTree();
|
||||
await mountFocused(tester);
|
||||
|
||||
expect(find.text('app.dart'), findsNothing); // lib collapsed
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyJ); // root
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyJ); // lib
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyL); // expand
|
||||
await tester.pump();
|
||||
await pumpAsync(tester);
|
||||
expect(find.text('app.dart'), findsOneWidget);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyH); // collapse lib
|
||||
await tester.pump();
|
||||
await pumpAsync(tester);
|
||||
expect(find.text('app.dart'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('G/gg/k and ctrl+d/u move the cursor; o on a dir toggles it (T-406)', (tester) async {
|
||||
stubTree();
|
||||
await mountFocused(tester);
|
||||
|
||||
// G → last visible row (main.dart), o → main.dart is a file → opens it.
|
||||
final opened = <String>[];
|
||||
f.ipc.stub('editor.open', (args) async {
|
||||
opened.add(args['path'] as String? ?? '');
|
||||
return _ok(const {});
|
||||
});
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyG); // G → bottom
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyK); // up → lib
|
||||
await tester.pump();
|
||||
// o on the 'lib' directory toggles (expands) it rather than opening a file.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyO);
|
||||
await tester.pump();
|
||||
await pumpAsync(tester);
|
||||
expect(find.text('app.dart'), findsOneWidget); // lib expanded, no file opened
|
||||
expect(opened, isEmpty);
|
||||
|
||||
// gg → top, then ctrl+d / ctrl+u exercise the half-page paths.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyG);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyG);
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyD);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyU);
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
|
||||
await tester.pump();
|
||||
// No crash, selection stayed in bounds — the dispatch paths ran.
|
||||
expect(opened, isEmpty);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/// 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user