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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
@@ -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