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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<String> get dependsOn => const [];
|
||||
|
||||
VimModeService? _mode;
|
||||
KeymapService? _keymap;
|
||||
SettingsStore? _settings;
|
||||
|
||||
/// Exposed for tests/host wiring; null before [activate].
|
||||
VimModeService? get modeService => _mode;
|
||||
|
||||
@override
|
||||
Future<void> 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<String>(kKeymapPresetSetting) ?? 'default';
|
||||
_mode?.enabled = preset == 'vim';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deactivate() async {
|
||||
_keymap?.removeListener(_syncEnabled);
|
||||
_mode?.enabled = false;
|
||||
_mode?.dispose();
|
||||
_mode = null;
|
||||
}
|
||||
|
||||
@override
|
||||
List<ContributionPoint> 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'});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<void> main() async {
|
||||
..register(ClaudeExtension())
|
||||
..register(TerminalExtension())
|
||||
..register(EditorExtension())
|
||||
..register(VimExtension())
|
||||
..register(DiffExtension())
|
||||
// Format engines + stubs
|
||||
..register(GrammarsCoreExtension())
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user