make the editor modal with Vim motions and edits
T-206. A pure motion/edit engine (vim_edit_ops.dart) operates on (text, selection, register) and returns the new value plus an insert-mode request — hjkl/w/b/e/0/^/$/gg/G motions, x/dd/D/dw/yy/p/P/ cc/cw/o/O edits, i/a/I/A insert entries, and d/y/c over a visual range. It's headless, so the whole grammar is unit-tested in isolation. The editor wires it in: in normal/visual mode bare keys feed the SequenceMatcher (modified chords bubble to the global handler for the palette etc.), a fired editor.vim.* intent applies the op count times and persists through the existing edit path, and vim.mode.* intents go to the registry. Crucially the EditableText is read-only in command mode — on desktop printable keys arrive over the TextInput channel separately from KeyEvents, so swallowing the key event alone wouldn't stop them typing; read-only does, while our edits still drive the controller directly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
/// T-206: the pure Vim motion/edit engine. Drives [applyVim] over
|
||||
/// `(text, caret)` and asserts the resulting text, caret, register, and
|
||||
/// insert-mode request.
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/editor/src/vim_edit_ops.dart';
|
||||
import 'package:flutter/services.dart' show TextEditingValue, TextSelection;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
TextEditingValue _tev(String text, int caret, {int? anchor}) => TextEditingValue(
|
||||
text: text,
|
||||
selection: TextSelection(baseOffset: anchor ?? caret, extentOffset: caret),
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('motions', () {
|
||||
const t = 'hello\nworld\nfoo';
|
||||
|
||||
test('right / left stop at line bounds', () {
|
||||
expect(applyVim(VimAction.right, _tev(t, 0)).value.selection.extentOffset, 1);
|
||||
expect(applyVim(VimAction.left, _tev(t, 1)).value.selection.extentOffset, 0);
|
||||
expect(applyVim(VimAction.left, _tev(t, 0)).value.selection.extentOffset, 0);
|
||||
});
|
||||
|
||||
test('down / up preserve column', () {
|
||||
expect(applyVim(VimAction.down, _tev(t, 2)).value.selection.extentOffset, 8); // world, col 2
|
||||
expect(applyVim(VimAction.up, _tev(t, 8)).value.selection.extentOffset, 2);
|
||||
});
|
||||
|
||||
test('down on the last line stays put', () {
|
||||
expect(applyVim(VimAction.down, _tev(t, 13)).value.selection.extentOffset, 13);
|
||||
});
|
||||
|
||||
test('lineStart / lineEnd / firstNonBlank', () {
|
||||
expect(applyVim(VimAction.lineEnd, _tev(t, 0)).value.selection.extentOffset, 5);
|
||||
expect(applyVim(VimAction.lineStart, _tev(t, 8)).value.selection.extentOffset, 6);
|
||||
expect(applyVim(VimAction.firstNonBlank, _tev(' ab', 3)).value.selection.extentOffset, 2);
|
||||
});
|
||||
|
||||
test('word motions', () {
|
||||
const w = 'hello world';
|
||||
expect(applyVim(VimAction.wordForward, _tev(w, 0)).value.selection.extentOffset, 6);
|
||||
expect(applyVim(VimAction.wordBackward, _tev(w, 8)).value.selection.extentOffset, 6);
|
||||
expect(applyVim(VimAction.wordEnd, _tev(w, 0)).value.selection.extentOffset, 4);
|
||||
});
|
||||
|
||||
test('docStart / docEnd', () {
|
||||
expect(applyVim(VimAction.docStart, _tev(t, 8)).value.selection.extentOffset, 0);
|
||||
expect(applyVim(VimAction.docEnd, _tev(t, 0)).value.selection.extentOffset, 12);
|
||||
});
|
||||
|
||||
test('count repeats a motion (3·right)', () {
|
||||
expect(applyVim(VimAction.right, _tev(t, 0), count: 3).value.selection.extentOffset, 3);
|
||||
});
|
||||
|
||||
test('visual motion extends from the anchor', () {
|
||||
final r = applyVim(VimAction.right, _tev(t, 0, anchor: 0), visual: true, count: 3);
|
||||
expect(r.value.selection.baseOffset, 0);
|
||||
expect(r.value.selection.extentOffset, 3);
|
||||
});
|
||||
});
|
||||
|
||||
group('edits', () {
|
||||
test('x deletes the char under the caret', () {
|
||||
final r = applyVim(VimAction.deleteChar, _tev('hello', 0));
|
||||
expect(r.value.text, 'ello');
|
||||
expect(r.value.selection.extentOffset, 0);
|
||||
expect(r.register?.text, 'h');
|
||||
});
|
||||
|
||||
test('x with a count', () {
|
||||
expect(applyVim(VimAction.deleteChar, _tev('hello', 0), count: 2).value.text, 'llo');
|
||||
});
|
||||
|
||||
test('dd deletes the line linewise', () {
|
||||
final r = applyVim(VimAction.deleteLine, _tev('hello\nworld\nfoo', 0));
|
||||
expect(r.value.text, 'world\nfoo');
|
||||
expect(r.value.selection.extentOffset, 0);
|
||||
expect(r.register?.text, 'hello\n');
|
||||
expect(r.register?.linewise, isTrue);
|
||||
});
|
||||
|
||||
test('dd on the last line drops the preceding newline', () {
|
||||
final r = applyVim(VimAction.deleteLine, _tev('a\nb', 2));
|
||||
expect(r.value.text, 'a');
|
||||
expect(r.value.selection.extentOffset, 0);
|
||||
});
|
||||
|
||||
test('2dd deletes two lines', () {
|
||||
final r = applyVim(VimAction.deleteLine, _tev('a\nb\nc', 0), count: 2);
|
||||
expect(r.value.text, 'c');
|
||||
});
|
||||
|
||||
test('D deletes to end of line', () {
|
||||
final r = applyVim(VimAction.deleteToEnd, _tev('hello', 2));
|
||||
expect(r.value.text, 'he');
|
||||
expect(r.register?.text, 'llo');
|
||||
});
|
||||
|
||||
test('dw deletes to the next word', () {
|
||||
final r = applyVim(VimAction.deleteWord, _tev('hello world', 0));
|
||||
expect(r.value.text, 'world');
|
||||
expect(r.value.selection.extentOffset, 0);
|
||||
});
|
||||
|
||||
test('yy yanks linewise without changing text', () {
|
||||
final r = applyVim(VimAction.yankLine, _tev('hello\nworld', 0));
|
||||
expect(r.value.text, 'hello\nworld');
|
||||
expect(r.register?.text, 'hello\n');
|
||||
expect(r.register?.linewise, isTrue);
|
||||
});
|
||||
|
||||
test('p pastes a linewise register below', () {
|
||||
final r = applyVim(VimAction.paste, _tev('a\nb', 0), register: const VimRegister('x\n', linewise: true));
|
||||
expect(r.value.text, 'a\nx\nb');
|
||||
expect(r.value.selection.extentOffset, 2);
|
||||
});
|
||||
|
||||
test('P pastes a linewise register above', () {
|
||||
final r = applyVim(VimAction.pasteBefore, _tev('a\nb', 0), register: const VimRegister('x\n', linewise: true));
|
||||
expect(r.value.text, 'x\na\nb');
|
||||
expect(r.value.selection.extentOffset, 0);
|
||||
});
|
||||
|
||||
test('p pastes a charwise register after the caret', () {
|
||||
final r = applyVim(VimAction.paste, _tev('ac', 0), register: const VimRegister('b'));
|
||||
expect(r.value.text, 'abc');
|
||||
expect(r.value.selection.extentOffset, 1);
|
||||
});
|
||||
|
||||
test('o opens a line below and enters insert', () {
|
||||
final r = applyVim(VimAction.openBelow, _tev('a\nb', 0));
|
||||
expect(r.value.text, 'a\n\nb');
|
||||
expect(r.value.selection.extentOffset, 2);
|
||||
expect(r.enterInsert, isTrue);
|
||||
});
|
||||
|
||||
test('O opens a line above and enters insert', () {
|
||||
final r = applyVim(VimAction.openAbove, _tev('a', 0));
|
||||
expect(r.value.text, '\na');
|
||||
expect(r.value.selection.extentOffset, 0);
|
||||
expect(r.enterInsert, isTrue);
|
||||
});
|
||||
|
||||
test('cc clears the line and enters insert', () {
|
||||
final r = applyVim(VimAction.changeLine, _tev('hello\nworld', 0));
|
||||
expect(r.value.text, '\nworld');
|
||||
expect(r.value.selection.extentOffset, 0);
|
||||
expect(r.enterInsert, isTrue);
|
||||
expect(r.register?.linewise, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('insert entry', () {
|
||||
test('i stays, a advances, A goes to line end, I to first non-blank', () {
|
||||
expect(applyVim(VimAction.insert, _tev('ab', 0)).enterInsert, isTrue);
|
||||
expect(applyVim(VimAction.insert, _tev('ab', 0)).value.selection.extentOffset, 0);
|
||||
expect(applyVim(VimAction.append, _tev('ab', 0)).value.selection.extentOffset, 1);
|
||||
expect(applyVim(VimAction.appendLineEnd, _tev('ab', 0)).value.selection.extentOffset, 2);
|
||||
expect(applyVim(VimAction.insertLineStart, _tev(' ab', 0)).value.selection.extentOffset, 2);
|
||||
});
|
||||
});
|
||||
|
||||
group('visual range ops', () {
|
||||
test('visual delete removes the inclusive range', () {
|
||||
final r = applyVim(VimAction.visualDelete, _tev('hello', 2, anchor: 0), visual: true);
|
||||
expect(r.value.text, 'lo');
|
||||
expect(r.value.selection.extentOffset, 0);
|
||||
expect(r.register?.text, 'hel');
|
||||
});
|
||||
|
||||
test('visual yank keeps text and stores the range', () {
|
||||
final r = applyVim(VimAction.visualYank, _tev('hello', 2, anchor: 0), visual: true);
|
||||
expect(r.value.text, 'hello');
|
||||
expect(r.register?.text, 'hel');
|
||||
});
|
||||
|
||||
test('visual change deletes and enters insert', () {
|
||||
final r = applyVim(VimAction.visualChange, _tev('hello', 2, anchor: 0), visual: true);
|
||||
expect(r.value.text, 'lo');
|
||||
expect(r.enterInsert, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
test('unknown action is a no-op', () {
|
||||
final v = _tev('abc', 1);
|
||||
final r = applyVim('editor.vim.nope', v);
|
||||
expect(r.value, v);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/// T-206: the editor's modal wiring. With a `vim.normal` scope flag set
|
||||
/// and a sequence binding registered, a bare key drives the buffer (and
|
||||
/// the editor goes read-only so the key can't also type); under no Vim
|
||||
/// mode the editor types normally.
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/editor/src/editor_view.dart';
|
||||
import 'package:clide/clide.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);
|
||||
|
||||
void main() {
|
||||
late KernelFixture f;
|
||||
setUp(() async => f = await KernelFixture.create());
|
||||
tearDown(() => f.dispose());
|
||||
|
||||
void stubOneBuffer(String content) {
|
||||
f.ipc.stub(
|
||||
'editor.list',
|
||||
(_) async => _ok({
|
||||
'buffers': [
|
||||
{'id': 'b_1', 'path': 'lib/a.dart', 'dirty': false}
|
||||
]
|
||||
}));
|
||||
f.ipc.stub(
|
||||
'editor.active',
|
||||
(_) async => _ok({
|
||||
'active': {'id': 'b_1'}
|
||||
}));
|
||||
f.ipc.stub(
|
||||
'editor.read',
|
||||
(_) async => _ok({
|
||||
'id': 'b_1',
|
||||
'path': 'lib/a.dart',
|
||||
'content': content,
|
||||
'selection': {'start': 0, 'end': 0},
|
||||
'dirty': false,
|
||||
}));
|
||||
}
|
||||
|
||||
Future<void> pumpEditor(WidgetTester tester) async {
|
||||
await tester.pumpWidget(harness(f, const EditorView()));
|
||||
await tester.pumpAndSettle();
|
||||
// Tap the very start so the caret lands at offset 0 (a centred tap
|
||||
// would put it at end-of-text and make column-sensitive ops no-ops).
|
||||
await tester.tapAt(tester.getTopLeft(find.byType(EditableText)) + const Offset(1, 1));
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
testWidgets('normal-mode x deletes the char under the caret', (tester) async {
|
||||
String? sentText;
|
||||
f.ipc.stub('editor.set-content', (a) async {
|
||||
sentText = a['text'] as String?;
|
||||
return _ok(const {});
|
||||
});
|
||||
f.services.keymap.registerCommandBinding('x', 'editor.vim.deleteChar', when: 'vim.normal');
|
||||
f.services.keymap.setScopeFlag('vim.normal', true);
|
||||
|
||||
stubOneBuffer('hello');
|
||||
await pumpEditor(tester);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyX);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(sentText, 'ello');
|
||||
});
|
||||
|
||||
testWidgets('dd sequence deletes the line', (tester) async {
|
||||
String? sentText;
|
||||
f.ipc.stub('editor.set-content', (a) async {
|
||||
sentText = a['text'] as String?;
|
||||
return _ok(const {});
|
||||
});
|
||||
f.services.keymap.registerCommandBinding('d d', 'editor.vim.deleteLine', when: 'vim.normal');
|
||||
f.services.keymap.setScopeFlag('vim.normal', true);
|
||||
|
||||
stubOneBuffer('one\ntwo');
|
||||
await pumpEditor(tester);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyD);
|
||||
await tester.pump();
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyD);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(sentText, 'two');
|
||||
});
|
||||
|
||||
testWidgets('the editor is read-only in normal mode, writable otherwise', (tester) async {
|
||||
f.services.keymap.setScopeFlag('vim.normal', true);
|
||||
stubOneBuffer('hello');
|
||||
await pumpEditor(tester);
|
||||
|
||||
expect(tester.widget<EditableText>(find.byType(EditableText)).readOnly, isTrue);
|
||||
|
||||
f.services.keymap.setScopeFlag('vim.normal', false);
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.widget<EditableText>(find.byType(EditableText)).readOnly, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('an unbound bare key in normal mode is swallowed (no edit)', (tester) async {
|
||||
String? lastText;
|
||||
f.ipc.stub('editor.set-content', (a) async {
|
||||
lastText = a['text'] as String?;
|
||||
return _ok(const {});
|
||||
});
|
||||
f.services.keymap.setScopeFlag('vim.normal', true);
|
||||
stubOneBuffer('hello');
|
||||
await pumpEditor(tester);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyZ);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// The buffer text is never altered by an unbound normal-mode key.
|
||||
expect(lastText, anyOf(isNull, 'hello'));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user