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:
2026-06-01 21:27:15 +02:00
co-authored by Claude Opus 4.8
parent ba304a95e6
commit caca816233
6 changed files with 859 additions and 3 deletions
@@ -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;
+2
View File
@@ -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);
+92 -3
View File
@@ -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<EditorView> {
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<EditorView> {
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<EditorView> {
_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 <String, bool>{};
return scope['vim.normal'] == true || scope['vim.visual'] == true;
}
void _onControllerChanged() {
final c = _controller!;
_syncTabs(c);
@@ -145,13 +173,70 @@ class _EditorViewState extends State<EditorView> {
}
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<EditorView> {
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,
+451
View File
@@ -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.<action>` 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.<id>`).
/// 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<String, _Motion> _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)),
);
}
+190
View File
@@ -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);
});
}
+122
View File
@@ -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'));
});
}