add Vim mode service, mode commands, and status indicator

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 20:59:01 +02:00
co-authored by Claude Opus 4.8
parent 4aa24a9898
commit 61b969d010
12 changed files with 448 additions and 0 deletions
+80
View File
@@ -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<CommandContribution>();
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);
});
}
@@ -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);
});
}
@@ -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);
});
}