From 61b969d010bdc07abdd2f1db5a462c34115a4cc3 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 1 Jun 2026 20:59:01 +0200 Subject: [PATCH] add Vim mode service, mode commands, and status indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T-207, first foundation piece of the Vim layer (T-65 epic). A VimModeService (ChangeNotifier) owns the normal/insert/visual mode and mirrors it into the keymap as mutually-exclusive vim.normal/vim.insert/ vim.visual scope flags. Those flags are the public mode interface: the editor (T-206) will read them to decide insert-vs-command, and vim.yaml (T-65) guards bindings with `when: vim.*`. Nothing reaches across the builtin boundary into the service object. The layer is gated on the active preset — the builtin.vim extension ties VimModeService.enabled to app.keymap.preset and re-checks on every keymap reload, so i/v/Esc never hijack input under non-Vim presets. Mode commands (vim.mode.{normal,insert,visual}) carry no default binding for the same reason; only vim.yaml binds keys to them. A status-bar item shows `-- NORMAL --` etc. while enabled. Exposes KeymapService on the extension context so the layer can publish scope flags. Co-Authored-By: Claude Opus 4.8 (1M context) --- .pql/changelog/ticket_history/2026-06.sql | 2 + .pql/changelog/tickets/2026-06.sql | 2 + lib/builtin/vim/src/extension.dart | 89 +++++++++++++++++++ lib/builtin/vim/src/vim_mode_indicator.dart | 33 +++++++ lib/builtin/vim/src/vim_mode_service.dart | 88 ++++++++++++++++++ lib/builtin/vim/vim.dart | 7 ++ lib/extension/src/extension.dart | 2 + lib/kernel/src/extensions_manager.dart | 2 + lib/main.dart | 2 + test/builtin/vim/vim_extension_test.dart | 80 +++++++++++++++++ test/builtin/vim/vim_mode_indicator_test.dart | 58 ++++++++++++ test/builtin/vim/vim_mode_service_test.dart | 83 +++++++++++++++++ 12 files changed, 448 insertions(+) create mode 100644 lib/builtin/vim/src/extension.dart create mode 100644 lib/builtin/vim/src/vim_mode_indicator.dart create mode 100644 lib/builtin/vim/src/vim_mode_service.dart create mode 100644 lib/builtin/vim/vim.dart create mode 100644 test/builtin/vim/vim_extension_test.dart create mode 100644 test/builtin/vim/vim_mode_indicator_test.dart create mode 100644 test/builtin/vim/vim_mode_service_test.dart diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index d254b44d..2f3825d7 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -39,3 +39,5 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, 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.', NULL, '2026-06-01 18:49:02', '2026-06-01 18:49:02', '2026-06-01 18:49:02', NULL, '3f379507780d78aa9f76b28f4a5c3179', 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-204', 'status', 'backlog', 'in_progress', NULL, '2026-06-01 18:49:17', '2026-06-01 18:49:17', '2026-06-01 18:49:17', NULL, 'b3a889c39a03676489bf19b8d3343cf9', 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', 'backlog', 'in_progress', NULL, '2026-06-01 18:51:08', '2026-06-01 18:51:08', '2026-06-01 18:51:08', NULL, 'cbbf55fdf6ac11f3146f41d0a6c0ad2f', 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-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; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 2270fd19..c3ce4c41 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -29,3 +29,5 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority, 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.', 'backlog', 'medium', NULL, NULL, NULL, '2026-04-24 06:34:16', '2026-06-01 18:49:02', NULL, '8a7f2eee186a7824893e7f2d22604156', 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-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.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-01 18:48:19', '2026-06-01 18:49:17', NULL, '01fc224dd1bef50e6c42340ec3a3d81f', 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.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-01 18:48:47', '2026-06-01 18:51:08', NULL, '387db5a29a081da46a96dcb8672ccb79', 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-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); diff --git a/lib/builtin/vim/src/extension.dart b/lib/builtin/vim/src/extension.dart new file mode 100644 index 00000000..7a22383b --- /dev/null +++ b/lib/builtin/vim/src/extension.dart @@ -0,0 +1,89 @@ +import 'package:clide/builtin/vim/src/vim_mode_indicator.dart'; +import 'package:clide/builtin/vim/src/vim_mode_service.dart'; +import 'package:clide/clide.dart'; +import 'package:clide/extension/extension.dart'; +import 'package:clide/kernel/kernel.dart'; +import 'package:flutter/widgets.dart'; + +/// Vim layer (T-65 epic). Owns the [VimModeService], registers the +/// mode-transition commands the `vim.yaml` preset binds to, and shows the +/// current mode in the status bar. +/// +/// The layer is inert unless the active preset is `vim`: [activate] ties +/// [VimModeService.enabled] to the `app.keymap.preset` setting and +/// re-checks it whenever the keymap reloads (preset switches go through +/// `KeymapService.load`, which notifies). That keeps `i` / `v` / `Esc` +/// from hijacking input under the default / VS Code / JetBrains presets. +/// +/// Mode commands carry NO `defaultBinding` — binding them globally would +/// fire under every preset. Only `vim.yaml` (guarded by `when: vim.*`) +/// binds keys to them. +class VimExtension extends ClideExtension { + @override + String get id => 'builtin.vim'; + @override + String get title => 'Vim'; + @override + String get version => '0.1.0'; + @override + List get dependsOn => const []; + + VimModeService? _mode; + KeymapService? _keymap; + SettingsStore? _settings; + + /// Exposed for tests/host wiring; null before [activate]. + VimModeService? get modeService => _mode; + + @override + Future activate(ClideExtensionContext ctx) async { + final mode = VimModeService(ctx.keymap); + _mode = mode; + _keymap = ctx.keymap; + _settings = ctx.settings; + _syncEnabled(); + ctx.keymap.addListener(_syncEnabled); + } + + /// Enable the Vim layer iff the active preset is `vim`. + void _syncEnabled() { + final preset = _settings?.get(kKeymapPresetSetting) ?? 'default'; + _mode?.enabled = preset == 'vim'; + } + + @override + Future deactivate() async { + _keymap?.removeListener(_syncEnabled); + _mode?.enabled = false; + _mode?.dispose(); + _mode = null; + } + + @override + List get contributions => [ + _modeCommand('vim.mode.normal', 'Vim: Normal mode', () => _mode?.enterNormal()), + _modeCommand('vim.mode.insert', 'Vim: Insert mode', () => _mode?.enterInsert()), + _modeCommand('vim.mode.visual', 'Vim: Visual mode', () => _mode?.enterVisual()), + StatusItemContribution( + id: 'vim.mode', + priority: -50, // left group, near the other editor status items + listenable: _mode, + build: (_) { + final m = _mode; + return m == null ? const SizedBox.shrink() : VimModeIndicator(service: m); + }, + ), + ]; + + CommandContribution _modeCommand(String id, String title, void Function() apply) { + return CommandContribution( + id: id, + command: id, + title: title, + run: (_) async { + apply(); + return IpcResponse.ok(id: '', data: {'mode': _mode?.mode.name ?? 'disabled'}); + }, + ); + } +} diff --git a/lib/builtin/vim/src/vim_mode_indicator.dart b/lib/builtin/vim/src/vim_mode_indicator.dart new file mode 100644 index 00000000..8f443dbc --- /dev/null +++ b/lib/builtin/vim/src/vim_mode_indicator.dart @@ -0,0 +1,33 @@ +import 'package:clide/builtin/vim/src/vim_mode_service.dart'; +import 'package:clide/kernel/kernel.dart'; +import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; + +/// Status-bar item showing the current Vim mode (`-- NORMAL --`). Renders +/// nothing while the Vim layer is disabled, so it's invisible under +/// non-Vim presets (T-207). +class VimModeIndicator extends StatelessWidget { + const VimModeIndicator({super.key, required this.service}); + + final VimModeService service; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: service, + builder: (context, _) { + if (!service.enabled) return const SizedBox.shrink(); + final tokens = ClideTheme.of(context).surface; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: ClideText( + '-- ${service.mode.label} --', + fontFamily: clideMonoFamily, + fontSize: clideFontCaption, + color: tokens.statusBarForeground, + ), + ); + }, + ); + } +} diff --git a/lib/builtin/vim/src/vim_mode_service.dart b/lib/builtin/vim/src/vim_mode_service.dart new file mode 100644 index 00000000..a2fe7a1e --- /dev/null +++ b/lib/builtin/vim/src/vim_mode_service.dart @@ -0,0 +1,88 @@ +/// Owns the current Vim editing mode and mirrors it into the keymap as +/// scope flags (T-207). +/// +/// The mode is the single source of truth for "is the editor modal right +/// now, and in which mode" — but the *public* interface other subsystems +/// consume is the set of `vim.*` scope flags this service pushes into +/// [KeymapService]. The editor (T-206) decides whether a bare key inserts +/// text or drives a motion by reading `vim.normal` from the keymap scope; +/// the `vim.yaml` preset (T-65) guards its bindings with `when: vim.normal` +/// etc. Nothing reaches into this object across the builtin boundary. +/// +/// The whole layer is gated by [enabled], which the Vim extension ties to +/// the active preset: under a non-Vim preset the flags are cleared so they +/// can never affect another preset's bindings. +library; + +import 'package:clide/kernel/src/keymap/keymap_service.dart'; +import 'package:flutter/foundation.dart'; + +/// The three editing modes clide models. Vim's other sub-modes +/// (visual-line, visual-block, replace, command-line) are out of scope +/// for the first pass; `command-line` is surfaced separately as a +/// transient overlay rather than a persistent mode. +enum VimMode { + normal('vim.normal', 'NORMAL'), + insert('vim.insert', 'INSERT'), + visual('vim.visual', 'VISUAL'); + + const VimMode(this.scopeFlag, this.label); + + /// The keymap scope flag set true exactly when this mode is active. + final String scopeFlag; + + /// Status-bar label, shown as `-- NORMAL --` (Vim's own vocabulary; + /// intentionally untranslated, like Vim itself). + final String label; +} + +class VimModeService extends ChangeNotifier { + VimModeService(this._keymap); + + final KeymapService _keymap; + + bool _enabled = false; + VimMode _mode = VimMode.normal; + + /// Whether the Vim layer is live. False under non-Vim presets. + bool get enabled => _enabled; + + /// The active mode. Meaningful only while [enabled]; defaults to + /// [VimMode.normal] and resets to it whenever the layer is enabled. + VimMode get mode => _mode; + + /// Turn the Vim layer on or off. Enabling resets to normal mode and + /// publishes the scope flags; disabling clears every `vim.*` flag so a + /// non-Vim preset's bindings are never shadowed. + set enabled(bool value) { + if (_enabled == value) return; + _enabled = value; + if (_enabled) { + _mode = VimMode.normal; + _publish(); + } else { + for (final m in VimMode.values) { + _keymap.clearScopeFlag(m.scopeFlag); + } + } + notifyListeners(); + } + + void enterNormal() => _setMode(VimMode.normal); + void enterInsert() => _setMode(VimMode.insert); + void enterVisual() => _setMode(VimMode.visual); + + void _setMode(VimMode mode) { + if (!_enabled || _mode == mode) return; + _mode = mode; + _publish(); + notifyListeners(); + } + + /// Set exactly one `vim.*` flag (the active mode) true, the rest false. + void _publish() { + for (final m in VimMode.values) { + _keymap.setScopeFlag(m.scopeFlag, m == _mode); + } + } +} diff --git a/lib/builtin/vim/vim.dart b/lib/builtin/vim/vim.dart new file mode 100644 index 00000000..cc8c2d00 --- /dev/null +++ b/lib/builtin/vim/vim.dart @@ -0,0 +1,7 @@ +/// Vim layer (T-65): modal mode tracking, mode-transition commands, and a +/// status-bar mode indicator. Active only under the `vim` keymap preset. +library; + +export 'src/extension.dart'; +export 'src/vim_mode_indicator.dart'; +export 'src/vim_mode_service.dart'; diff --git a/lib/extension/src/extension.dart b/lib/extension/src/extension.dart index 7cb8828a..1e6b1d51 100644 --- a/lib/extension/src/extension.dart +++ b/lib/extension/src/extension.dart @@ -10,6 +10,7 @@ import 'package:clide/kernel/src/files.dart'; import 'package:clide/kernel/src/focus.dart'; import 'package:clide/kernel/src/i18n/i18n.dart'; import 'package:clide/kernel/src/ipc/client.dart'; +import 'package:clide/kernel/src/keymap/keymap_service.dart'; import 'package:clide/kernel/src/log.dart'; import 'package:clide/kernel/src/net.dart'; import 'package:clide/kernel/src/notify.dart'; @@ -63,6 +64,7 @@ abstract class ClideExtensionContext { PanelRegistry get panels; LayoutArrangement get arrangement; CommandRegistry get commands; + KeymapService get keymap; PaletteController get palette; ReaderNavRegistry get readerNav; ClideClipboard get clipboard; diff --git a/lib/kernel/src/extensions_manager.dart b/lib/kernel/src/extensions_manager.dart index 03da196b..cc808398 100644 --- a/lib/kernel/src/extensions_manager.dart +++ b/lib/kernel/src/extensions_manager.dart @@ -290,6 +290,8 @@ class _ExtensionContext implements ClideExtensionContext { @override CommandRegistry get commands => manager.commands; @override + KeymapService get keymap => manager.keymap; + @override PaletteController get palette => manager.palette; @override ReaderNavRegistry get readerNav => manager.readerNav; diff --git a/lib/main.dart b/lib/main.dart index 8d00d538..40067947 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -24,6 +24,7 @@ import 'package:clide/builtin/settings_ui/settings_ui.dart'; import 'package:clide/builtin/terminal/terminal.dart'; import 'package:clide/builtin/theme_picker/theme_picker.dart'; import 'package:clide/builtin/view/view.dart'; +import 'package:clide/builtin/vim/vim.dart'; import 'package:clide/builtin/tickets/tickets.dart'; import 'package:clide/builtin/todos/todos.dart'; import 'package:clide/builtin/welcome/welcome.dart'; @@ -265,6 +266,7 @@ Future main() async { ..register(ClaudeExtension()) ..register(TerminalExtension()) ..register(EditorExtension()) + ..register(VimExtension()) ..register(DiffExtension()) // Format engines + stubs ..register(GrammarsCoreExtension()) diff --git a/test/builtin/vim/vim_extension_test.dart b/test/builtin/vim/vim_extension_test.dart new file mode 100644 index 00000000..bfe77ae1 --- /dev/null +++ b/test/builtin/vim/vim_extension_test.dart @@ -0,0 +1,80 @@ +/// T-207: the Vim extension enables its layer only under the `vim` preset, +/// registers mode-transition commands (no global bindings), and drives the +/// mode through those commands. +library; + +import 'package:clide/builtin/vim/vim.dart'; +import 'package:clide/extension/extension.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; + +void main() { + late KernelFixture f; + late VimExtension ext; + + setUp(() async { + f = await KernelFixture.create(); + ext = VimExtension(); + f.services.extensions.register(ext); + }); + tearDown(() => f.dispose()); + + bool flag(String name) => f.services.keymap.scope[name] ?? false; + + test('inert under the default preset', () async { + await f.services.extensions.activate('builtin.vim'); + expect(ext.modeService!.enabled, isFalse); + expect(flag('vim.normal'), isFalse); + }); + + test('enables when the vim preset is active', () async { + await f.services.keymap.setPreset('vim'); + await f.services.extensions.activate('builtin.vim'); + expect(ext.modeService!.enabled, isTrue); + expect(flag('vim.normal'), isTrue); + }); + + test('follows a live preset switch', () async { + await f.services.extensions.activate('builtin.vim'); + expect(ext.modeService!.enabled, isFalse); + + await f.services.keymap.setPreset('vim'); + expect(ext.modeService!.enabled, isTrue); + + await f.services.keymap.setPreset('default'); + expect(ext.modeService!.enabled, isFalse); + expect(flag('vim.normal'), isFalse); + }); + + test('mode commands drive the scope flags', () async { + await f.services.keymap.setPreset('vim'); + await f.services.extensions.activate('builtin.vim'); + + await f.services.commands.execute('vim.mode.insert'); + expect(flag('vim.insert'), isTrue); + expect(flag('vim.normal'), isFalse); + + await f.services.commands.execute('vim.mode.normal'); + expect(flag('vim.normal'), isTrue); + expect(flag('vim.insert'), isFalse); + }); + + test('mode commands carry no default binding', () { + final cmds = ext.contributions.whereType(); + expect(cmds, isNotEmpty); + expect(cmds.every((c) => c.defaultBinding == null), isTrue); + }); + + test('deactivate clears flags and disables the layer', () async { + await f.services.keymap.setPreset('vim'); + await f.services.extensions.activate('builtin.vim'); + await f.services.commands.execute('vim.mode.insert'); + expect(flag('vim.insert'), isTrue); + + final svc = ext.modeService!; + await f.services.extensions.deactivate('builtin.vim'); + expect(svc.enabled, isFalse); + expect(flag('vim.insert'), isFalse); + }); +} diff --git a/test/builtin/vim/vim_mode_indicator_test.dart b/test/builtin/vim/vim_mode_indicator_test.dart new file mode 100644 index 00000000..1400bb44 --- /dev/null +++ b/test/builtin/vim/vim_mode_indicator_test.dart @@ -0,0 +1,58 @@ +/// T-207: the status-bar mode indicator shows `-- MODE --` while the Vim +/// layer is enabled and renders nothing otherwise. +library; + +import 'package:clide/builtin/vim/vim.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; +import '../../helpers/widget_harness.dart'; + +void main() { + late KernelFixture f; + late VimModeService mode; + + setUp(() async { + f = await KernelFixture.create(); + mode = VimModeService(f.services.keymap); + }); + tearDown(() { + mode.dispose(); + return f.dispose(); + }); + + testWidgets('renders nothing while disabled', (tester) async { + await tester.pumpWidget(harness(f, VimModeIndicator(service: mode))); + await tester.pumpAndSettle(); + expect(find.textContaining('NORMAL'), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('shows the active mode and updates on transition', (tester) async { + mode.enabled = true; + await tester.pumpWidget(harness(f, VimModeIndicator(service: mode))); + await tester.pumpAndSettle(); + expect(find.text('-- NORMAL --'), findsOneWidget); + + mode.enterInsert(); + await tester.pumpAndSettle(); + expect(find.text('-- INSERT --'), findsOneWidget); + expect(find.text('-- NORMAL --'), findsNothing); + + mode.enterVisual(); + await tester.pumpAndSettle(); + expect(find.text('-- VISUAL --'), findsOneWidget); + }); + + testWidgets('disabling hides the indicator', (tester) async { + mode.enabled = true; + await tester.pumpWidget(harness(f, VimModeIndicator(service: mode))); + await tester.pumpAndSettle(); + expect(find.text('-- NORMAL --'), findsOneWidget); + + mode.enabled = false; + await tester.pumpAndSettle(); + expect(find.textContaining('NORMAL'), findsNothing); + }); +} diff --git a/test/builtin/vim/vim_mode_service_test.dart b/test/builtin/vim/vim_mode_service_test.dart new file mode 100644 index 00000000..588331ec --- /dev/null +++ b/test/builtin/vim/vim_mode_service_test.dart @@ -0,0 +1,83 @@ +/// T-207: the Vim mode service mirrors the active mode into the keymap as +/// mutually-exclusive `vim.*` scope flags, and clears them when disabled. +library; + +import 'package:clide/builtin/vim/vim.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; + +void main() { + late KernelFixture f; + late VimModeService mode; + + setUp(() async { + f = await KernelFixture.create(); + mode = VimModeService(f.services.keymap); + }); + tearDown(() { + mode.dispose(); + return f.dispose(); + }); + + bool flag(String name) => f.services.keymap.scope[name] ?? false; + + test('disabled by default and pushes no scope flags', () { + expect(mode.enabled, isFalse); + expect(flag('vim.normal'), isFalse); + expect(flag('vim.insert'), isFalse); + expect(flag('vim.visual'), isFalse); + }); + + test('enabling resets to normal and publishes exactly one flag', () { + mode.enabled = true; + expect(mode.mode, VimMode.normal); + expect(flag('vim.normal'), isTrue); + expect(flag('vim.insert'), isFalse); + expect(flag('vim.visual'), isFalse); + }); + + test('mode transitions keep flags mutually exclusive', () { + mode.enabled = true; + + mode.enterInsert(); + expect(mode.mode, VimMode.insert); + expect(flag('vim.insert'), isTrue); + expect(flag('vim.normal'), isFalse); + + mode.enterVisual(); + expect(flag('vim.visual'), isTrue); + expect(flag('vim.insert'), isFalse); + + mode.enterNormal(); + expect(flag('vim.normal'), isTrue); + expect(flag('vim.visual'), isFalse); + }); + + test('transitions are inert while disabled', () { + mode.enterInsert(); + expect(mode.mode, VimMode.normal); // unchanged + expect(flag('vim.insert'), isFalse); + }); + + test('disabling clears every vim flag', () { + mode.enabled = true; + mode.enterInsert(); + expect(flag('vim.insert'), isTrue); + + mode.enabled = false; + expect(flag('vim.normal'), isFalse); + expect(flag('vim.insert'), isFalse); + expect(flag('vim.visual'), isFalse); + }); + + test('notifies listeners on enable and on mode change', () { + var n = 0; + mode.addListener(() => n++); + mode.enabled = true; // 1 + mode.enterInsert(); // 2 + mode.enterInsert(); // no-op, same mode + mode.enterNormal(); // 3 + expect(n, 3); + }); +}