From caca816233e8687b63f0804c03a4ce6a8aec7feb Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 1 Jun 2026 21:27:15 +0200 Subject: [PATCH] make the editor modal with Vim motions and edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .pql/changelog/ticket_history/2026-06.sql | 2 + .pql/changelog/tickets/2026-06.sql | 2 + lib/builtin/editor/src/editor_view.dart | 95 ++++- lib/builtin/editor/src/vim_edit_ops.dart | 451 +++++++++++++++++++++ test/builtin/editor/vim_edit_ops_test.dart | 190 +++++++++ test/builtin/editor/vim_editor_test.dart | 122 ++++++ 6 files changed, 859 insertions(+), 3 deletions(-) create mode 100644 lib/builtin/editor/src/vim_edit_ops.dart create mode 100644 test/builtin/editor/vim_edit_ops_test.dart create mode 100644 test/builtin/editor/vim_editor_test.dart diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 1eb9f95c..a4946d62 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -43,3 +43,5 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-204', 'status', 'in_progress', 'done', NULL, '2026-06-01 18:51:08', '2026-06-01 18:51:08', '2026-06-01 18:51:08', NULL, 'f9fbf947660b802f85c45290c6e7a9c2', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-207', 'status', 'in_progress', 'done', NULL, '2026-06-01 18:59:19', '2026-06-01 18:59:19', '2026-06-01 18:59:19', NULL, '2bd6e471177259606aa9d27975f89dde', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-205', 'status', 'backlog', 'in_progress', NULL, '2026-06-01 19:07:16', '2026-06-01 19:07:16', '2026-06-01 19:07:16', NULL, 'e1f8c83cfe8956b83080ac25e515f86d', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-205', 'status', 'in_progress', 'done', NULL, '2026-06-01 19:12:01', '2026-06-01 19:12:01', '2026-06-01 19:12:01', NULL, '061ffbffafbfdfe7ecec8f278fbc6bf0', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-206', 'status', 'backlog', 'in_progress', NULL, '2026-06-01 19:16:58', '2026-06-01 19:16:58', '2026-06-01 19:16:58', NULL, '4b1e494c63361eb99d59e0491ff841c3', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 42b9c7a3..d5547e4e 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -33,3 +33,5 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority, INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-204', 'task', 'T-65', 'Fix dead default keymap: focus.next/previous undefined drops whole preset', 'The keymap loader catches FormatException from preset parsing and silently sets _preset=null (keymap_service.dart load()). default.yaml binds tab->focus.next and shift+tab->focus.previous, but neither id exists in builtinIntents (only focus.nextPanel/previousPanel). parseIntentId returns null -> KeymapLayer.fromYaml throws -> the ENTIRE default preset is dropped at boot. So palette (ctrl+shift+p), quick-open (ctrl+p), find-in-files, and text-scale bindings are all dead at runtime. Uncaught because every keymap_service_test injects a synthetic bundle; the real asset is never parsed. Fix: add focus.next->NextFocusIntent and focus.previous->PreviousFocusIntent (Flutter-provided, like activate/dismiss) to builtinIntents so Tab does correct widget focus traversal; add a test that loads the REAL assets/keymaps/default.yaml and asserts it parses with the expected binding count, as a regression guard for every shipped preset.', 'done', 'high', NULL, NULL, NULL, '2026-06-01 18:48:19', '2026-06-01 18:51:08', NULL, 'b335e35e5d0f4c3cb267d1b9573c1434', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-207', 'story', 'T-65', 'Vim mode service + status-bar mode indicator', 'A small mode-tracking service (ChangeNotifier) that owns the current Vim mode (normal/insert/visual), publishes vim.normal / vim.insert / vim.visual scope flags via KeymapService.setScopeFlag, and exposes the mode to the editor (T-206) and a status-bar indicator (-- NORMAL -- / -- INSERT -- / -- VISUAL --). Handles transitions: Esc -> normal (from any mode); i/a/o/I/A -> insert (from normal); v -> visual (from normal); colon -> command-line indicator (stretch). Resets to normal on editor focus by default (configurable). The when-clause grammar already supports dotted flags (vim.normal && editor.focused), confirmed in when_clause.dart. Service is a pure ChangeNotifier with no deps; fits in lib/kernel/src/keymap/ or as an editor builtin piece. Acceptance: setPreset(''vim'') + this service produce correct mode transitions; a regression test asserts i->insert and Esc->normal flip the scope flags; the status bar reflects mode.', 'done', 'high', NULL, NULL, NULL, '2026-06-01 18:48:47', '2026-06-01 18:59:19', NULL, 'bb6b26be440d7850e55bf9ab138263bd', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-205', 'story', 'T-65', 'Key-sequence resolution in the keymap layer (Vim motions, counts)', 'The resolver is single-chord: KeyChord.fromKeyEvent -> Keymap.resolve fires and forgets one keystroke. Real Vim needs multi-key sequences (dd, gg, dw, ciw, yy) and count prefixes (5j, 3dd). Add a pending-sequence model to KeymapService: accumulate chords into a buffer, match against sequence bindings by prefix, fire on full match, reset on no-prefix-match or timeout. Add a KeySequence type (ordered list of KeyChord) alongside KeyChord. Extend the YAML schema to express sequences distinctly from alias-lists: keys:[d,d] today means ''either d or d'' (alias) — need a ''sequence:'' key or a chord-string notation (e.g. ''d d'' space-separated) for ordered sequences. Add count-prefix capture so a leading digit run is parsed as a repeat count passed to the intent. Keep single-chord resolution unchanged (fast path). This also unblocks JetBrains shift+shift (T-66) via a double-tap special-case. Decision-worthy: the sequence/notation choice may warrant a D-record.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-01 18:48:27', '2026-06-01 19:07:16', NULL, '63396c8b4d6ae568bde2b7465001c3ad', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-205', 'story', 'T-65', 'Key-sequence resolution in the keymap layer (Vim motions, counts)', 'The resolver is single-chord: KeyChord.fromKeyEvent -> Keymap.resolve fires and forgets one keystroke. Real Vim needs multi-key sequences (dd, gg, dw, ciw, yy) and count prefixes (5j, 3dd). Add a pending-sequence model to KeymapService: accumulate chords into a buffer, match against sequence bindings by prefix, fire on full match, reset on no-prefix-match or timeout. Add a KeySequence type (ordered list of KeyChord) alongside KeyChord. Extend the YAML schema to express sequences distinctly from alias-lists: keys:[d,d] today means ''either d or d'' (alias) — need a ''sequence:'' key or a chord-string notation (e.g. ''d d'' space-separated) for ordered sequences. Add count-prefix capture so a leading digit run is parsed as a repeat count passed to the intent. Keep single-chord resolution unchanged (fast path). This also unblocks JetBrains shift+shift (T-66) via a double-tap special-case. Decision-worthy: the sequence/notation choice may warrant a D-record.', 'done', 'high', NULL, NULL, NULL, '2026-06-01 18:48:27', '2026-06-01 19:12:01', NULL, '7cc1e3c9a5249e5d352b0107b1936327', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-206', 'story', 'T-65', 'Modal editor: Vim motion & edit intents', 'The editor (lib/builtin/editor/src/editor_view.dart) is a raw Flutter EditableText that only handles Cmd/Ctrl+S; in normal mode, letter keys must NOT insert text and must instead drive motions/edits. Add typed editor Intents + Action handlers wired into the editor that operate on the SyntaxTextController/TextEditingValue: cursor motions (cursorLeft/Down/Up/Right for h/j/k/l), word motions (wordForward/wordBackward/wordEnd for w/b/e), line motions (lineStart/lineEnd/firstNonBlank for 0/$/^), document motions (documentStart/End for gg/G), edits (deleteChar x, deleteLine dd, deleteWord dw, changeLine cc, changeWord cw, yankLine yy, paste p/P, openLineBelow/Above o/O), and insert-entry (enterInsert i/a/I/A/o). Motions+edits must compose with the count prefix (T-205) and operator+motion (dw, cw). The editor must read the active Vim mode (T-206 service) to decide whether a bare key inserts or commands — key interception must win over EditableText''s text input in normal mode. Acceptance: each intent has an Action that mutates the buffer correctly; regression tests cover j (cursor down), x (delete char), dd (delete line), i (enter insert).', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-01 18:48:40', '2026-06-01 19:16:58', NULL, '4627c4a8eb9257df10d0b3e82c7c1cc6', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/lib/builtin/editor/src/editor_view.dart b/lib/builtin/editor/src/editor_view.dart index 5df0a4ec..742e7ada 100644 --- a/lib/builtin/editor/src/editor_view.dart +++ b/lib/builtin/editor/src/editor_view.dart @@ -9,6 +9,7 @@ import 'package:flutter/widgets.dart'; import 'editor_controller.dart'; import 'syntax_text_controller.dart'; +import 'vim_edit_ops.dart'; /// Tier-2 editor pane. Shows one tab per open buffer via the shared /// [MultitabPane] (the same strip the Claude pane uses); the body @@ -36,6 +37,13 @@ class _EditorViewState extends State { late final FocusNode _focus; String? _lastRemoteContent; + /// Vim sequence matcher (built once the kernel is available) + the + /// yank register. Active only while a `vim.*` scope flag is set; under + /// non-Vim presets the editor types normally (T-206). + SequenceMatcher? _matcher; + VimRegister _register = VimRegister.empty; + KeymapService? _keymap; + /// Guards the controller→tabstrip reconcile so the tabstrip's own /// change notifications (from us mutating it) don't bounce back as /// daemon calls. @@ -56,6 +64,13 @@ class _EditorViewState extends State { if (_controller != null) return; final kernel = ClideKernel.of(context); _controller = EditorController(ipc: kernel.ipc, events: kernel.events)..addListener(_onControllerChanged); + _keymap = kernel.keymap; + _matcher = SequenceMatcher( + keymap: () => kernel.keymap.keymap ?? Keymap(const []), + context: () => kernel.keymap.scope, + ); + // Rebuild when the Vim mode flips so the editor toggles read-only. + kernel.keymap.addListener(_onModeChanged); unawaited(_controller!.hydrate()); } @@ -68,9 +83,22 @@ class _EditorViewState extends State { _tabs.dispose(); _controller?.removeListener(_onControllerChanged); _controller?.dispose(); + _keymap?.removeListener(_onModeChanged); super.dispose(); } + void _onModeChanged() { + if (mounted) setState(() {}); + } + + /// True while a `vim.*` command mode (normal/visual) is active — the + /// editor is read-only then, so printable keys delivered over the + /// TextInput channel can't type while motions drive the buffer (T-206). + bool get _vimCommandMode { + final scope = _keymap?.scope ?? const {}; + return scope['vim.normal'] == true || scope['vim.visual'] == true; + } + void _onControllerChanged() { final c = _controller!; _syncTabs(c); @@ -145,13 +173,70 @@ class _EditorViewState extends State { } KeyEventResult _onKey(FocusNode node, KeyEvent event) { - if (event is! KeyDownEvent) return KeyEventResult.ignored; - final isCmd = HardwareKeyboard.instance.isMetaPressed || HardwareKeyboard.instance.isControlPressed; + if (event is! KeyDownEvent && event is! KeyRepeatEvent) return KeyEventResult.ignored; + final hw = HardwareKeyboard.instance; + + // Save works in every mode. + final isCmd = hw.isMetaPressed || hw.isControlPressed; if (isCmd && event.logicalKey == LogicalKeyboardKey.keyS) { unawaited(_controller?.save()); return KeyEventResult.handled; } - return KeyEventResult.ignored; + + final kernel = ClideKernel.of(context); + final scope = kernel.keymap.scope; + final inNormal = scope['vim.normal'] == true; + final inVisual = scope['vim.visual'] == true; + final chord = KeyChord.fromKeyEvent(event, hw); + if (chord == null) return KeyEventResult.ignored; + + if (!inNormal && !inVisual) { + // Insert mode (or non-Vim preset): type normally, but still let a + // mode-change chord like Esc flip back to normal. + final intent = kernel.keymap.keymap?.resolve(chord, scope); + if (intent is InvokeCommandIntent && intent.commandId.startsWith('vim.mode.')) { + unawaited(kernel.commands.execute(intent.commandId)); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + // Normal / visual mode. Modified chords are app shortcuts (palette, + // find, …) — let them bubble to the global handler. Bare keys drive + // the Vim matcher and never reach text input. + if (chord.modifiers.isNotEmpty) return KeyEventResult.ignored; + + final r = _matcher!.feed(chord); + switch (r.outcome) { + case SeqOutcome.pending: + case SeqOutcome.unmatched: + // Swallow: a partial sequence, or a key Vim ignores in this mode. + return KeyEventResult.handled; + case SeqOutcome.fired: + _dispatchVim(r.intent!, r.count, kernel, visual: inVisual); + return KeyEventResult.handled; + } + } + + void _dispatchVim(Intent intent, int count, KernelServices kernel, {required bool visual}) { + if (intent is! InvokeCommandIntent) return; + final id = intent.commandId; + if (!id.startsWith('editor.vim.')) { + // Mode change (vim.mode.*) or any other command. + unawaited(kernel.commands.execute(id)); + return; + } + final result = applyVim(id, _text.value, register: _register, visual: visual, count: count); + if (result.register != null) _register = result.register!; + _text.value = result.value; // _onTextChanged persists content + caret + if (result.enterInsert) { + unawaited(kernel.commands.execute('vim.mode.insert')); + } else if (visual) { + // A visual range op (d/y/c) returns to normal mode; motions that + // merely extend the selection stay in visual. + const rangeOps = {VimAction.visualDelete, VimAction.visualYank, VimAction.visualChange}; + if (rangeOps.contains(id)) unawaited(kernel.commands.execute('vim.mode.normal')); + } } @override @@ -182,6 +267,7 @@ class _EditorViewState extends State { child: _TextBody( controller: _text, focus: _focus, + readOnly: _vimCommandMode, background: tokens.panelBackground, foreground: tokens.globalForeground, accent: tokens.globalFocus, @@ -197,6 +283,7 @@ class _TextBody extends StatelessWidget { const _TextBody({ required this.controller, required this.focus, + required this.readOnly, required this.background, required this.foreground, required this.accent, @@ -204,6 +291,7 @@ class _TextBody extends StatelessWidget { final TextEditingController controller; final FocusNode focus; + final bool readOnly; final Color background; final Color foreground; final Color accent; @@ -221,6 +309,7 @@ class _TextBody extends StatelessWidget { child: EditableText( controller: controller, focusNode: focus, + readOnly: readOnly, style: TextStyle( color: foreground, fontSize: clideFontMono, diff --git a/lib/builtin/editor/src/vim_edit_ops.dart b/lib/builtin/editor/src/vim_edit_ops.dart new file mode 100644 index 00000000..74b291e1 --- /dev/null +++ b/lib/builtin/editor/src/vim_edit_ops.dart @@ -0,0 +1,451 @@ +/// Pure Vim normal/visual-mode motion + edit engine (T-206). +/// +/// Operates on a [TextEditingValue] plus a yank [VimRegister] and returns +/// a [VimResult] — no widgets, no controller, no I/O — so the whole motion +/// grammar is unit-testable in isolation. The editor (`editor_view.dart`) +/// owns the thin wiring: it drives a [SequenceMatcher], and on a fired +/// `editor.vim.` intent calls [applyVim] `count` times, applying +/// the result to its `EditableText` controller. +/// +/// Scope is the muscle-memory set a Vim user reaches for first: hjkl / +/// w b e / 0 ^ $ / gg G motions, x dd D dw yy p P cc cw o O edits, the +/// i a I A insert entries, and d/y/c over a visual selection. It is a +/// faithful approximation, not a bit-exact Vim — counts apply by repeat, +/// and word motions use a three-class (word / punct / space) split. +library; + +import 'package:flutter/services.dart' show TextEditingValue, TextSelection; + +/// Action ids the `vim.yaml` preset binds to (as `command:editor.vim.`). +/// Kept as constants so the preset, the editor dispatch, and the tests +/// agree on one spelling. +class VimAction { + static const left = 'editor.vim.left'; + static const right = 'editor.vim.right'; + static const down = 'editor.vim.down'; + static const up = 'editor.vim.up'; + static const lineStart = 'editor.vim.lineStart'; + static const lineEnd = 'editor.vim.lineEnd'; + static const firstNonBlank = 'editor.vim.firstNonBlank'; + static const wordForward = 'editor.vim.wordForward'; + static const wordBackward = 'editor.vim.wordBackward'; + static const wordEnd = 'editor.vim.wordEnd'; + static const docStart = 'editor.vim.docStart'; + static const docEnd = 'editor.vim.docEnd'; + + static const deleteChar = 'editor.vim.deleteChar'; + static const deleteLine = 'editor.vim.deleteLine'; + static const deleteToEnd = 'editor.vim.deleteToEnd'; + static const deleteWord = 'editor.vim.deleteWord'; + static const yankLine = 'editor.vim.yankLine'; + static const paste = 'editor.vim.paste'; + static const pasteBefore = 'editor.vim.pasteBefore'; + static const changeLine = 'editor.vim.changeLine'; + static const changeWord = 'editor.vim.changeWord'; + static const openBelow = 'editor.vim.openBelow'; + static const openAbove = 'editor.vim.openAbove'; + + static const insert = 'editor.vim.insert'; + static const append = 'editor.vim.append'; + static const insertLineStart = 'editor.vim.insertLineStart'; + static const appendLineEnd = 'editor.vim.appendLineEnd'; + + static const visualDelete = 'editor.vim.visualDelete'; + static const visualYank = 'editor.vim.visualYank'; + static const visualChange = 'editor.vim.visualChange'; +} + +/// The unnamed yank register. [linewise] yanks (from `dd`/`yy`) paste onto +/// a new line; charwise yanks paste inline. +class VimRegister { + const VimRegister(this.text, {this.linewise = false}); + static const empty = VimRegister(''); + final String text; + final bool linewise; +} + +class VimResult { + const VimResult(this.value, {this.register, this.enterInsert = false}); + + final TextEditingValue value; + + /// Updated register, or null to leave it unchanged. + final VimRegister? register; + + /// True when the op requests a switch to insert mode (c/o/i/a families). + final bool enterInsert; +} + +/// Apply [action] to [v]. [count] repeats motions/line-edits; [visual] +/// selects between collapse-to-caret (normal) and extend-from-anchor +/// (visual) for motions, and enables the `visual*` range ops. +VimResult applyVim( + String action, + TextEditingValue v, { + VimRegister register = VimRegister.empty, + bool visual = false, + int count = 1, +}) { + final t = v.text; + final caret = v.selection.extentOffset.clamp(0, t.length); + final anchor = v.selection.baseOffset.clamp(0, t.length); + final n = count < 1 ? 1 : count; + + // --- Motions ---------------------------------------------------------- + final motion = _motions[action]; + if (motion != null) { + var off = caret; + for (var i = 0; i < n; i++) { + off = motion(t, off); + } + final sel = visual ? TextSelection(baseOffset: anchor, extentOffset: off) : TextSelection.collapsed(offset: off); + return VimResult(TextEditingValue(text: t, selection: sel)); + } + + // --- Insert-entry ----------------------------------------------------- + switch (action) { + case VimAction.insert: + return _insertAt(t, caret); + case VimAction.append: + return _insertAt(t, (caret < _lineEnd(t, caret)) ? caret + 1 : caret); + case VimAction.insertLineStart: + return _insertAt(t, _firstNonBlank(t, caret)); + case VimAction.appendLineEnd: + return _insertAt(t, _lineEnd(t, caret)); + } + + // --- Edits ------------------------------------------------------------ + switch (action) { + case VimAction.deleteChar: + return _deleteChar(t, caret, n); + case VimAction.deleteLine: + return _deleteLines(t, caret, n); + case VimAction.deleteToEnd: + return _deleteToEnd(t, caret); + case VimAction.deleteWord: + return _deleteToOffset(t, caret, _repeat(_wordForward, t, caret, n)); + case VimAction.changeWord: + return _deleteToOffset(t, caret, _repeat(_wordForward, t, caret, n), insert: true); + case VimAction.yankLine: + return _yankLines(t, caret, n); + case VimAction.paste: + return _paste(t, caret, register, before: false); + case VimAction.pasteBefore: + return _paste(t, caret, register, before: true); + case VimAction.changeLine: + return _changeLine(t, caret, n); + case VimAction.openBelow: + return _openLine(t, caret, below: true); + case VimAction.openAbove: + return _openLine(t, caret, below: false); + case VimAction.visualDelete: + return _deleteRange(t, anchor, caret, insert: false); + case VimAction.visualChange: + return _deleteRange(t, anchor, caret, insert: true); + case VimAction.visualYank: + return _yankRange(t, anchor, caret); + } + + // Unknown action — no change. + return VimResult(v); +} + +// -- Motion table ----------------------------------------------------------- + +typedef _Motion = int Function(String t, int off); + +final Map _motions = { + VimAction.left: (t, o) => o > _lineStart(t, o) ? o - 1 : o, + VimAction.right: (t, o) => o < _lineEnd(t, o) ? o + 1 : o, + VimAction.down: _down, + VimAction.up: _up, + VimAction.lineStart: (t, o) => _lineStart(t, o), + VimAction.lineEnd: (t, o) => _lineEnd(t, o), + VimAction.firstNonBlank: _firstNonBlank, + VimAction.wordForward: _wordForward, + VimAction.wordBackward: _wordBackward, + VimAction.wordEnd: _wordEnd, + VimAction.docStart: (t, o) => 0, + VimAction.docEnd: (t, o) => _firstNonBlank(t, _lineStart(t, t.length)), +}; + +int _repeat(_Motion m, String t, int off, int n) { + var o = off; + for (var i = 0; i < n; i++) { + o = m(t, o); + } + return o; +} + +// -- Offset helpers --------------------------------------------------------- + +int _clamp(int x, int lo, int hi) => x < lo ? lo : (x > hi ? hi : x); + +int _lineStart(String t, int off) { + if (off <= 0) return 0; + final i = t.lastIndexOf('\n', off - 1); + return i < 0 ? 0 : i + 1; +} + +/// Offset of the newline ending [off]'s line, or [t].length on the last line. +int _lineEnd(String t, int off) { + final i = t.indexOf('\n', off); + return i < 0 ? t.length : i; +} + +int _firstNonBlank(String t, int off) { + final ls = _lineStart(t, off); + final le = _lineEnd(t, off); + var i = ls; + while (i < le && (t[i] == ' ' || t[i] == '\t')) { + i++; + } + return i; +} + +int _down(String t, int off) { + final ls = _lineStart(t, off); + final le = _lineEnd(t, off); + if (le >= t.length) return off; // last line + final col = off - ls; + final nls = le + 1; + final nle = _lineEnd(t, nls); + return _clamp(nls + col, nls, nle); +} + +int _up(String t, int off) { + final ls = _lineStart(t, off); + if (ls == 0) return off; // first line + final col = off - ls; + final pls = _lineStart(t, ls - 1); + final ple = ls - 1; // the newline ending the previous line + return _clamp(pls + col, pls, ple); +} + +// 0 = whitespace, 1 = word char, 2 = punctuation. +int _cls(String ch) { + if (ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r') return 0; + final c = ch.codeUnitAt(0); + final isWord = (c >= 0x30 && c <= 0x39) || // 0-9 + (c >= 0x41 && c <= 0x5A) || // A-Z + (c >= 0x61 && c <= 0x7A) || // a-z + c == 0x5F; // _ + return isWord ? 1 : 2; +} + +int _wordForward(String t, int off) { + var i = off; + if (i >= t.length) return t.length; + final start = _cls(t[i]); + if (start != 0) { + while (i < t.length && _cls(t[i]) == start) { + i++; + } + } + while (i < t.length && _cls(t[i]) == 0) { + i++; + } + return i; +} + +int _wordBackward(String t, int off) { + var i = off; + if (i <= 0) return 0; + i--; + while (i > 0 && _cls(t[i]) == 0) { + i--; + } + final cls = _cls(t[i]); + while (i > 0 && _cls(t[i - 1]) == cls && cls != 0) { + i--; + } + return i; +} + +int _wordEnd(String t, int off) { + if (t.isEmpty) return 0; + var i = off + 1; + while (i < t.length && _cls(t[i]) == 0) { + i++; + } + if (i >= t.length) return t.length - 1; + final cls = _cls(t[i]); + while (i + 1 < t.length && _cls(t[i + 1]) == cls) { + i++; + } + return i; +} + +// -- Edit helpers ----------------------------------------------------------- + +VimResult _collapsed(String text, int caret) => VimResult( + TextEditingValue(text: text, selection: TextSelection.collapsed(offset: _clamp(caret, 0, text.length))), + ); + +VimResult _insertAt(String t, int caret) => VimResult( + TextEditingValue(text: t, selection: TextSelection.collapsed(offset: _clamp(caret, 0, t.length))), + enterInsert: true, + ); + +VimResult _deleteChar(String t, int caret, int count) { + final le = _lineEnd(t, caret); + final end = _clamp(caret + count, caret, le); + if (end == caret) return _collapsed(t, caret); + final removed = t.substring(caret, end); + final nt = t.replaceRange(caret, end, ''); + // Keep the caret on a real char: clamp to the (new) last char of the line. + final nls = _lineStart(nt, caret); + final nle = _lineEnd(nt, caret); + final ncaret = _clamp(caret, nls, nle > nls ? nle - 1 : nls); + return VimResult( + TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: ncaret)), + register: VimRegister(removed), + ); +} + +VimResult _deleteLines(String t, int caret, int count) { + final ls = _lineStart(t, caret); + var end = ls; + for (var i = 0; i < count; i++) { + final le = _lineEnd(t, end); + end = le < t.length ? le + 1 : le; + if (le >= t.length) break; + } + final removed = t.substring(ls, end); + final reg = VimRegister(removed.endsWith('\n') ? removed : '$removed\n', linewise: true); + String nt; + int caretLineStart; + if (end >= t.length && ls > 0) { + // Removed the final line(s): drop the preceding newline too. + nt = t.substring(0, ls - 1); + caretLineStart = _lineStart(nt, nt.length); + } else { + nt = t.replaceRange(ls, end, ''); + caretLineStart = ls; + } + return VimResult( + TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: _firstNonBlank(nt, caretLineStart))), + register: reg, + ); +} + +VimResult _deleteToEnd(String t, int caret) { + final le = _lineEnd(t, caret); + if (le == caret) return _collapsed(t, caret); + final removed = t.substring(caret, le); + final nt = t.replaceRange(caret, le, ''); + final ls = _lineStart(nt, caret); + final nle = _lineEnd(nt, caret); + return VimResult( + TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: _clamp(caret, ls, nle > ls ? nle - 1 : ls))), + register: VimRegister(removed), + ); +} + +VimResult _deleteToOffset(String t, int caret, int target, {bool insert = false}) { + final lo = caret < target ? caret : target; + final hi = caret < target ? target : caret; + if (lo == hi) return insert ? _insertAt(t, caret) : _collapsed(t, caret); + final removed = t.substring(lo, hi); + final nt = t.replaceRange(lo, hi, ''); + return VimResult( + TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: lo)), + register: VimRegister(removed), + enterInsert: insert, + ); +} + +VimResult _changeLine(String t, int caret, int count) { + // Like dd but keep one (empty) line and enter insert at its indent. + final ls = _lineStart(t, caret); + var end = ls; + for (var i = 0; i < count; i++) { + end = _lineEnd(t, end); + if (i < count - 1 && end < t.length) end++; + } + final removed = t.substring(ls, end); + final nt = t.replaceRange(ls, end, ''); + return VimResult( + TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: ls)), + register: VimRegister(removed.endsWith('\n') ? removed : '$removed\n', linewise: true), + enterInsert: true, + ); +} + +VimResult _yankLines(String t, int caret, int count) { + final ls = _lineStart(t, caret); + var end = ls; + for (var i = 0; i < count; i++) { + final le = _lineEnd(t, end); + end = le < t.length ? le + 1 : le; + if (le >= t.length) break; + } + final yanked = t.substring(ls, end); + return VimResult( + TextEditingValue(text: t, selection: TextSelection.collapsed(offset: caret)), + register: VimRegister(yanked.endsWith('\n') ? yanked : '$yanked\n', linewise: true), + ); +} + +VimResult _paste(String t, int caret, VimRegister reg, {required bool before}) { + if (reg.text.isEmpty) return _collapsed(t, caret); + if (reg.linewise) { + final body = reg.text.endsWith('\n') ? reg.text : '${reg.text}\n'; + if (before) { + final ls = _lineStart(t, caret); + final nt = t.replaceRange(ls, ls, body); + return VimResult(TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: _firstNonBlank(nt, ls)))); + } + final le = _lineEnd(t, caret); + final insertAt = le < t.length ? le + 1 : t.length; + // On the last line (no trailing newline) we must add a leading newline. + final chunk = le < t.length ? body : '\n${body.substring(0, body.length - 1)}'; + final nt = t.replaceRange(insertAt, insertAt, chunk); + final caretLine = le < t.length ? insertAt : insertAt + 1; + return VimResult(TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: _firstNonBlank(nt, caretLine)))); + } + // Charwise: p pastes after the caret, P at the caret. + final at = before ? caret : _clamp(caret + 1, 0, t.length); + final nt = t.replaceRange(at, at, reg.text); + return VimResult(TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: at + reg.text.length - 1))); +} + +VimResult _openLine(String t, int caret, {required bool below}) { + if (below) { + final le = _lineEnd(t, caret); + final nt = t.replaceRange(le, le, '\n'); + return VimResult( + TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: le + 1)), + enterInsert: true, + ); + } + final ls = _lineStart(t, caret); + final nt = t.replaceRange(ls, ls, '\n'); + return VimResult( + TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: ls)), + enterInsert: true, + ); +} + +VimResult _deleteRange(String t, int anchor, int caret, {required bool insert}) { + final lo = anchor < caret ? anchor : caret; + // Visual selection in Vim is inclusive of the char under the caret. + final hi = _clamp((anchor < caret ? caret : anchor) + 1, 0, t.length); + if (lo == hi) return insert ? _insertAt(t, lo) : _collapsed(t, lo); + final removed = t.substring(lo, hi); + final nt = t.replaceRange(lo, hi, ''); + return VimResult( + TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: lo)), + register: VimRegister(removed), + enterInsert: insert, + ); +} + +VimResult _yankRange(String t, int anchor, int caret) { + final lo = anchor < caret ? anchor : caret; + final hi = _clamp((anchor < caret ? caret : anchor) + 1, 0, t.length); + return VimResult( + TextEditingValue(text: t, selection: TextSelection.collapsed(offset: lo)), + register: VimRegister(t.substring(lo, hi)), + ); +} diff --git a/test/builtin/editor/vim_edit_ops_test.dart b/test/builtin/editor/vim_edit_ops_test.dart new file mode 100644 index 00000000..12a5a8ff --- /dev/null +++ b/test/builtin/editor/vim_edit_ops_test.dart @@ -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); + }); +} diff --git a/test/builtin/editor/vim_editor_test.dart b/test/builtin/editor/vim_editor_test.dart new file mode 100644 index 00000000..a1b44220 --- /dev/null +++ b/test/builtin/editor/vim_editor_test.dart @@ -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 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 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(find.byType(EditableText)).readOnly, isTrue); + + f.services.keymap.setScopeFlag('vim.normal', false); + await tester.pumpAndSettle(); + expect(tester.widget(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')); + }); +}