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