diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index a4946d62..657df591 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -45,3 +45,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-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; +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', 'in_progress', 'done', NULL, '2026-06-01 19:27:18', '2026-06-01 19:27:18', '2026-06-01 19:27:18', NULL, '9ec3404aa10ff5c655124c4e5b857a31', 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-65', 'status', 'backlog', 'in_progress', NULL, '2026-06-01 19:29:15', '2026-06-01 19:29:15', '2026-06-01 19:29:15', NULL, '021a754b25453a1548ee9278f2719072', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index d5547e4e..98696f41 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -35,3 +35,18 @@ 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-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); +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).', 'done', 'high', NULL, NULL, NULL, '2026-06-01 18:48:40', '2026-06-01 19:27:18', NULL, '2810354fde44fa08199b38acb9c017a4', 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-65', 'story', NULL, 'Vim keybinding preset', 'Ship a Vim-compatible keybinding preset with modal editing support (normal/insert/visual modes). Maps Vim motions and commands to clide editor and navigation actions. Users select it in settings. + +**Unblocked by T-117 (2026-05-17):** the keystroke mapper layer is in place; modal Vim presets are more involved than the VS Code preset (T-64) because modes need to be expressed as scope flags (`vim.normal`, `vim.insert`, `vim.visual`) that the when-clause grammar can branch on. Implementation work: + +1. Author `assets/keymaps/vim.yaml` using the typed Intents + `command:` bindings. +2. Add a small mode-tracking service that publishes `vim.` scope flags via `KeymapService.setScopeFlag`. +3. Bind `Esc` to mode-reset → normal; `i` (when `vim.normal`) → enter insert; etc. + +**Acceptance:** +1. `assets/keymaps/vim.yaml` ships covering the documented Vim default keybindings for editor / navigation / panes. +2. `KeymapService.setPreset("vim")` + the mode-tracking service together produce correct mode transitions. +3. A regression test exercises a representative motion (`j` → cursor down) and a mode change (`i` → insert). + +Refinement (2026-06-01): scoped Vim-first for a Vim-power-user demo this weekend. ''Author a YAML file'' was wrong — T-117 shipped single-chord resolution + scope flags + when-clauses only. Decomposed into children: T-204 (fix dead default preset — live bug), T-205 (key-sequence + count resolution), T-206 (modal editor motion/edit intents), T-207 (Vim mode service + status indicator). T-65 itself becomes assets/keymaps/vim.yaml + regression tests once children land. T-64/T-66 deferred (single-chord, easy; T-205 hands JetBrains shift+shift later). Foundation is shared, not Vim-only.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-04-24 06:34:16', '2026-06-01 19:29:15', NULL, 'efc76aa91cfc889968d5bda60f897afa', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/CHANGELOG.md b/CHANGELOG.md index 5774344c..d7d1776a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- Vim keymap preset: a modal editor (normal/insert/visual) with hjkl/w/b/e + motions, dd/dw/x/D/yy/p/cc/cw edits, counts (`5j`), visual-range d/y/c, and + a status-bar mode indicator. Switch presets from the palette (`Keymap: Vim` + / `Keymap: Default`). Built on a new keymap key-sequence layer (D-82). (T-65) - Search-and-replace across the workspace: enter a replacement in the search panel to preview each rewritten line, then Replace all (regex capture groups supported). Guarded by a clean-git-tree gate — git is the undo — and a diff --git a/assets/keymaps/vim.yaml b/assets/keymaps/vim.yaml new file mode 100644 index 00000000..17a254f5 --- /dev/null +++ b/assets/keymaps/vim.yaml @@ -0,0 +1,169 @@ +# clide Vim keymap preset (T-65). +# +# Modal editing built on the keymap sequence layer (D-82). The Vim mode +# service (builtin.vim) publishes vim.normal / vim.insert / vim.visual +# scope flags; bindings here are guarded on them. Motions/edits resolve to +# `command:editor.vim.`, which the editor applies to the buffer +# (read-only in command mode so keys never type); mode changes resolve to +# `command:vim.mode.`. +# +# Notation (D-82): `+` joins a chord, a space sequences (`d d`), a YAML +# list alternates (`[d, x]`). Repeat counts (`5j`) are captured by the +# matcher — no binding needed. +# +# Scope: the muscle-memory set, not bit-exact Vim. cw/$/^ are +# approximations; see vim_edit_ops.dart. + +name: vim + +bindings: + # ---- App shortcuts (shared with the default preset) ----------------- + # Modified chords bubble past the editor to the global handler, so these + # keep working with the editor focused and under every mode. + - intent: palette.open + keys: [ctrl+shift+p, meta+shift+p] + - intent: palette.selectNext + keys: [down, ctrl+n] + when: palette.open + - intent: palette.selectPrevious + keys: [up, ctrl+p] + when: palette.open + - intent: palette.accept + keys: enter + when: palette.open + - intent: dismiss + keys: escape + when: palette.open + - intent: quickOpen.open + keys: [ctrl+p, meta+p] + when: "!palette.open" + - intent: quickOpen.selectNext + keys: [down, ctrl+n] + when: quickOpen.open + - intent: quickOpen.selectPrevious + keys: up + when: quickOpen.open + - intent: quickOpen.accept + keys: enter + when: quickOpen.open + - intent: dismiss + keys: escape + when: quickOpen.open + - intent: findInFiles.open + keys: [ctrl+shift+f, meta+shift+f] + - intent: focus.nextPanel + keys: f6 + - intent: focus.previousPanel + keys: shift+f6 + - intent: text.scaleIncrease + keys: [ctrl+equal, ctrl+shift+equal, meta+equal, meta+shift+equal] + - intent: text.scaleDecrease + keys: [ctrl+minus, meta+minus] + - intent: text.scaleReset + keys: [ctrl+0, meta+0] + + # ---- Mode transitions ------------------------------------------------ + - intent: command:vim.mode.visual + keys: v + when: vim.normal + - intent: command:vim.mode.normal + keys: escape + when: "vim.insert || vim.visual" + + # ---- Insert entry (normal → insert), positioned by the editor -------- + - intent: command:editor.vim.insert + keys: i + when: vim.normal + - intent: command:editor.vim.append + keys: a + when: vim.normal + - intent: command:editor.vim.insertLineStart + keys: shift+i # I + when: vim.normal + - intent: command:editor.vim.appendLineEnd + keys: shift+a # A + when: vim.normal + - intent: command:editor.vim.openBelow + keys: o + when: vim.normal + - intent: command:editor.vim.openAbove + keys: shift+o # O + when: vim.normal + + # ---- Motions (normal + visual) --------------------------------------- + - intent: command:editor.vim.left + keys: h + when: "vim.normal || vim.visual" + - intent: command:editor.vim.down + keys: j + when: "vim.normal || vim.visual" + - intent: command:editor.vim.up + keys: k + when: "vim.normal || vim.visual" + - intent: command:editor.vim.right + keys: l + when: "vim.normal || vim.visual" + - intent: command:editor.vim.wordForward + keys: w + when: "vim.normal || vim.visual" + - intent: command:editor.vim.wordBackward + keys: b + when: "vim.normal || vim.visual" + - intent: command:editor.vim.wordEnd + keys: e + when: "vim.normal || vim.visual" + - intent: command:editor.vim.lineStart + keys: "0" + when: "vim.normal || vim.visual" + - intent: command:editor.vim.lineEnd + keys: shift+4 # $ + when: "vim.normal || vim.visual" + - intent: command:editor.vim.firstNonBlank + keys: shift+6 # ^ + when: "vim.normal || vim.visual" + - intent: command:editor.vim.docStart + keys: "g g" # gg + when: "vim.normal || vim.visual" + - intent: command:editor.vim.docEnd + keys: shift+g # G + when: "vim.normal || vim.visual" + + # ---- Normal-mode edits ---------------------------------------------- + - intent: command:editor.vim.deleteChar + keys: x + when: vim.normal + - intent: command:editor.vim.deleteLine + keys: "d d" + when: vim.normal + - intent: command:editor.vim.deleteWord + keys: "d w" + when: vim.normal + - intent: command:editor.vim.deleteToEnd + keys: shift+d # D + when: vim.normal + - intent: command:editor.vim.changeLine + keys: "c c" + when: vim.normal + - intent: command:editor.vim.changeWord + keys: "c w" + when: vim.normal + - intent: command:editor.vim.yankLine + keys: "y y" + when: vim.normal + - intent: command:editor.vim.paste + keys: p + when: vim.normal + - intent: command:editor.vim.pasteBefore + keys: shift+p # P + when: vim.normal + + # ---- Visual-mode operators ------------------------------------------ + - intent: command:editor.vim.visualDelete + keys: [d, x] + when: vim.visual + - intent: command:editor.vim.visualYank + keys: y + when: vim.visual + - intent: command:editor.vim.visualChange + keys: c + when: vim.visual diff --git a/lib/builtin/editor/src/editor_view.dart b/lib/builtin/editor/src/editor_view.dart index 742e7ada..850e04b0 100644 --- a/lib/builtin/editor/src/editor_view.dart +++ b/lib/builtin/editor/src/editor_view.dart @@ -201,10 +201,13 @@ class _EditorViewState extends State { 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; + // Normal / visual mode. Ctrl/Alt/Meta chords are app shortcuts + // (palette, find, …) — let them bubble to the global handler. Bare + // keys and Shift chords (Vim's capitals: G, D, A, P, $) drive the + // matcher and never reach text input. + if (chord.modifiers.any((m) => m != KeyModifier.shift)) { + return KeyEventResult.ignored; + } final r = _matcher!.feed(chord); switch (r.outcome) { diff --git a/lib/builtin/keybindings_ui/src/extension.dart b/lib/builtin/keybindings_ui/src/extension.dart index c94aa25f..9e1e1541 100644 --- a/lib/builtin/keybindings_ui/src/extension.dart +++ b/lib/builtin/keybindings_ui/src/extension.dart @@ -1,17 +1,49 @@ +import 'package:clide/clide.dart'; import 'package:clide/extension/extension.dart'; +import 'package:clide/kernel/kernel.dart'; -/// Tier-0 stub. Real implementation lands in a later tier; the extension -/// is registered so the extensions-ui surface can list it as "installed, -/// not yet implemented" and its id is reserved. +/// Keymap preset switching. The full keybindings editor lands in a later +/// tier; today this contributes palette commands to switch the active +/// keymap preset (the user-facing "select it in settings" affordance for +/// the Vim / VS Code / JetBrains presets — T-64/65/66). class KeybindingsUiExtension extends ClideExtension { @override String get id => 'builtin.keybindings-ui'; @override String get title => 'Keybindings UI'; @override - String get version => '0.0.0-stub'; + String get version => '0.1.0'; @override List get dependsOn => const []; + + KeymapService? _keymap; + @override - List get contributions => const []; + Future activate(ClideExtensionContext ctx) async { + _keymap = ctx.keymap; + } + + @override + Future deactivate() async => _keymap = null; + + /// Presets that ship today. VS Code / JetBrains (T-64/T-66) join here + /// once their YAMLs land. + static const _presets = { + 'default': 'Keymap: Default', + 'vim': 'Keymap: Vim', + }; + + @override + List get contributions => [ + for (final entry in _presets.entries) + CommandContribution( + id: 'keymap.preset.${entry.key}', + command: 'keymap.preset.${entry.key}', + title: entry.value, + run: (_) async { + await _keymap?.setPreset(entry.key); + return IpcResponse.ok(id: '', data: {'preset': entry.key}); + }, + ), + ]; } diff --git a/test/builtin/editor/vim_preset_test.dart b/test/builtin/editor/vim_preset_test.dart new file mode 100644 index 00000000..5683bca3 --- /dev/null +++ b/test/builtin/editor/vim_preset_test.dart @@ -0,0 +1,86 @@ +/// T-65: the shipped Vim preset. Loads the real assets/keymaps/vim.yaml +/// and asserts representative bindings resolve in the right mode — motions, +/// the i→insert / Esc→normal / v→visual mode changes, a dd sequence, a +/// shifted capital (G), and that app shortcuts (palette) survive. +library; + +import 'dart:io'; + +import 'package:clide/kernel/src/keymap/intents.dart'; +import 'package:clide/kernel/src/keymap/key_chord.dart'; +import 'package:clide/kernel/src/keymap/keymap.dart'; +import 'package:clide/kernel/src/keymap/sequence_matcher.dart'; +import 'package:flutter/widgets.dart' show Intent; +import 'package:flutter_test/flutter_test.dart'; + +String? _cmd(Intent? i) => i is InvokeCommandIntent ? i.commandId : null; + +void main() { + late Keymap km; + setUp(() { + final src = File('assets/keymaps/vim.yaml').readAsStringSync(); + km = Keymap([KeymapLayer.fromYaml(src)]); + }); + + const normal = {'vim.normal': true}; + const insert = {'vim.insert': true}; + const visual = {'vim.visual': true}; + + Intent? resolve(String chord, Map scope) => km.resolve(KeyChord.parse(chord), scope); + + test('motions resolve in normal mode', () { + expect(_cmd(resolve('j', normal)), 'editor.vim.down'); + expect(_cmd(resolve('h', normal)), 'editor.vim.left'); + expect(_cmd(resolve('w', normal)), 'editor.vim.wordForward'); + expect(_cmd(resolve('0', normal)), 'editor.vim.lineStart'); + }); + + test('capitals (shifted) resolve', () { + expect(_cmd(resolve('shift+g', normal)), 'editor.vim.docEnd'); + expect(_cmd(resolve('shift+d', normal)), 'editor.vim.deleteToEnd'); + expect(_cmd(resolve('shift+a', normal)), 'editor.vim.appendLineEnd'); + }); + + test('motions also resolve in visual mode', () { + expect(_cmd(resolve('l', visual)), 'editor.vim.right'); + expect(_cmd(resolve('w', visual)), 'editor.vim.wordForward'); + }); + + test('mode changes', () { + expect(_cmd(resolve('i', normal)), 'editor.vim.insert'); + expect(_cmd(resolve('v', normal)), 'vim.mode.visual'); + expect(_cmd(resolve('escape', insert)), 'vim.mode.normal'); + expect(_cmd(resolve('escape', visual)), 'vim.mode.normal'); + }); + + test('normal-mode i does not fire in visual mode', () { + expect(resolve('i', visual), isNull); + }); + + test('visual operators resolve only in visual mode', () { + expect(_cmd(resolve('y', visual)), 'editor.vim.visualYank'); + expect(_cmd(resolve('d', visual)), 'editor.vim.visualDelete'); // single d in visual + }); + + test('app shortcuts survive under the vim preset', () { + expect(resolve('ctrl+shift+p', normal), isA()); + expect(resolve('ctrl+shift+f', insert), isA()); + }); + + test('dd / dw sequences resolve through the matcher in normal mode', () { + final m = SequenceMatcher(keymap: () => km, context: () => normal); + expect(m.feed(KeyChord.parse('d')).outcome, SeqOutcome.pending); + final r = m.feed(KeyChord.parse('d')); + expect(_cmd(r.intent), 'editor.vim.deleteLine'); + + m.reset(); + m.feed(KeyChord.parse('d')); + expect(_cmd(m.feed(KeyChord.parse('w')).intent), 'editor.vim.deleteWord'); + }); + + test('gg sequence resolves to docStart', () { + final m = SequenceMatcher(keymap: () => km, context: () => normal); + expect(m.feed(KeyChord.parse('g')).outcome, SeqOutcome.pending); + expect(_cmd(m.feed(KeyChord.parse('g')).intent), 'editor.vim.docStart'); + }); +} diff --git a/test/builtin/keybindings_ui/preset_commands_test.dart b/test/builtin/keybindings_ui/preset_commands_test.dart new file mode 100644 index 00000000..0365f1a0 --- /dev/null +++ b/test/builtin/keybindings_ui/preset_commands_test.dart @@ -0,0 +1,35 @@ +/// T-65: the keybindings UI contributes palette commands that switch the +/// active keymap preset. +library; + +import 'package:clide/builtin/keybindings_ui/keybindings_ui.dart'; +import 'package:clide/kernel/kernel.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; + +void main() { + late KernelFixture f; + setUp(() async { + f = await KernelFixture.create(); + f.services.extensions.register(KeybindingsUiExtension()); + await f.services.extensions.activate('builtin.keybindings-ui'); + }); + tearDown(() => f.dispose()); + + test('contributes a switch command per shipped preset', () { + expect(f.services.commands.get('keymap.preset.vim'), isNotNull); + expect(f.services.commands.get('keymap.preset.default'), isNotNull); + }); + + test('running the vim command switches the active preset', () async { + await f.services.commands.execute('keymap.preset.vim'); + expect(f.services.settings.get(kKeymapPresetSetting), 'vim'); + }); + + test('switching back to default works', () async { + await f.services.commands.execute('keymap.preset.vim'); + await f.services.commands.execute('keymap.preset.default'); + expect(f.services.settings.get(kKeymapPresetSetting), 'default'); + }); +}