keystroke mapper layer — intents, presets, when-clauses (T-117)
Build the upstream of every keyboard-driven feature: widgets bind
to typed Intents, the keymap resolves chord+context to an Intent,
and Flutter's Actions dispatches. The widget never touches a key.
Layers (low → high precedence):
1. preset YAML in assets/keymaps/<preset>.yaml
2. extension-registered command bindings (via
KeymapService.registerCommandBinding from ExtensionManager)
3. user file at <appDir>/keybindings.yaml
4. settings JSON overlay at app.keymap.overrides
The when-clause grammar is a tiny recursive-descent parser over
boolean expressions on a named context bag — VS-Code style
`palette.open && !textInputFocused`. Producing services publish
scope flags via setScopeFlag.
Keys reference LogicalKeyboardKey.keyId (stable across keyboard
layouts), not the locale-aware keyLabel the consultant flagged.
Ships:
- lib/kernel/src/keymap/{key_chord, when_clause, intents, keymap,
keymap_service}.dart
- assets/keymaps/default.yaml (the baseline preset)
- 90+ unit tests covering parser precedence, layering precedence,
scope evaluation, register/unregister, settings overlay,
malformed-input tolerance
- app.dart root handler routes through KeymapService → Actions
- ExtensionManager mirrors every legacy defaultBinding into the
keymap as a contribution layer
KeybindingResolver kept temporarily as a back-compat shim for
callers we haven't migrated yet; safe to delete once the last
caller goes through Actions.
Closes T-110 (consultant: scoped Shortcuts/Actions; off keyLabel).
Annotates T-23 with what's left for T-100. Unblocks T-64 / T-65 /
T-66 (preset data tickets).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
/// Unit tests for KeyChord parsing, equality, canonicalisation, and
|
||||
/// fromKeyEvent.
|
||||
library;
|
||||
|
||||
import 'package:clide/kernel/src/keymap/key_chord.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('KeyChord.parse', () {
|
||||
test('single key without modifiers', () {
|
||||
final c = KeyChord.parse('enter');
|
||||
expect(c.modifiers, isEmpty);
|
||||
expect(c.key, LogicalKeyboardKey.enter);
|
||||
expect(c.canonical, 'enter');
|
||||
});
|
||||
|
||||
test('modifier order is canonicalised', () {
|
||||
final a = KeyChord.parse('shift+ctrl+p');
|
||||
final b = KeyChord.parse('ctrl+shift+p');
|
||||
expect(a, b);
|
||||
expect(a.canonical, 'ctrl+shift+p');
|
||||
});
|
||||
|
||||
test('cmd/meta/command/super/win all alias the meta modifier', () {
|
||||
for (final spec in ['cmd+a', 'meta+a', 'command+a', 'super+a', 'win+a']) {
|
||||
expect(KeyChord.parse(spec).modifiers, [KeyModifier.meta], reason: spec);
|
||||
}
|
||||
});
|
||||
|
||||
test('punctuation keys are recognised by name or character', () {
|
||||
expect(KeyChord.parse('ctrl+slash').key, LogicalKeyboardKey.slash);
|
||||
expect(KeyChord.parse('ctrl+/').key, LogicalKeyboardKey.slash);
|
||||
expect(KeyChord.parse('ctrl+equal').key, LogicalKeyboardKey.equal);
|
||||
expect(KeyChord.parse('ctrl+=').key, LogicalKeyboardKey.equal);
|
||||
});
|
||||
|
||||
test('parse is case-insensitive on modifiers + key name', () {
|
||||
final c = KeyChord.parse('CTRL+SHIFT+P');
|
||||
expect(c.modifiers, [KeyModifier.ctrl, KeyModifier.shift]);
|
||||
expect(c.key, LogicalKeyboardKey.keyP);
|
||||
});
|
||||
});
|
||||
|
||||
group('KeyChord.parse — errors', () {
|
||||
test('empty string throws', () {
|
||||
expect(() => KeyChord.parse(''), throwsFormatException);
|
||||
});
|
||||
|
||||
test('unknown modifier throws', () {
|
||||
expect(() => KeyChord.parse('hyper+a'), throwsFormatException);
|
||||
});
|
||||
|
||||
test('unknown key throws', () {
|
||||
expect(() => KeyChord.parse('ctrl+definitely-not-a-key'), throwsFormatException);
|
||||
});
|
||||
|
||||
test('trailing + (missing key) throws', () {
|
||||
expect(() => KeyChord.parse('ctrl+'), throwsFormatException);
|
||||
});
|
||||
});
|
||||
|
||||
group('KeyChord display + canonical', () {
|
||||
test('display capitalises modifiers + key, joined with +', () {
|
||||
expect(KeyChord.parse('ctrl+shift+p').display, 'Ctrl+Shift+P');
|
||||
expect(KeyChord.parse('enter').display, 'ENTER');
|
||||
});
|
||||
|
||||
test('each modifier renders its own display string', () {
|
||||
expect(KeyChord.parse('ctrl+a').display, 'Ctrl+A');
|
||||
expect(KeyChord.parse('alt+a').display, 'Alt+A');
|
||||
expect(KeyChord.parse('shift+a').display, 'Shift+A');
|
||||
expect(KeyChord.parse('meta+a').display, 'Cmd+A');
|
||||
});
|
||||
|
||||
test('toString embeds the canonical form', () {
|
||||
expect(KeyChord.parse('ctrl+shift+p').toString(), 'KeyChord(ctrl+shift+p)');
|
||||
});
|
||||
});
|
||||
|
||||
group('KeyChord equality + hashing', () {
|
||||
test('equal chords have equal hash codes', () {
|
||||
final a = KeyChord.parse('ctrl+shift+p');
|
||||
final b = KeyChord.parse('shift+ctrl+p');
|
||||
expect(a, b);
|
||||
expect(a.hashCode, b.hashCode);
|
||||
});
|
||||
|
||||
test('different keys are not equal', () {
|
||||
expect(KeyChord.parse('ctrl+a'), isNot(KeyChord.parse('ctrl+b')));
|
||||
});
|
||||
|
||||
test('different modifier sets are not equal', () {
|
||||
expect(KeyChord.parse('ctrl+a'), isNot(KeyChord.parse('alt+a')));
|
||||
});
|
||||
});
|
||||
|
||||
group('KeyChord.fromKeyEvent', () {
|
||||
final kb = HardwareKeyboard.instance;
|
||||
tearDown(() => kb.clearState());
|
||||
|
||||
test('returns null for non-KeyDown / non-Repeat events', () {
|
||||
final up = KeyUpEvent(
|
||||
physicalKey: PhysicalKeyboardKey.keyA,
|
||||
logicalKey: LogicalKeyboardKey.keyA,
|
||||
timeStamp: Duration.zero,
|
||||
);
|
||||
expect(KeyChord.fromKeyEvent(up, kb), isNull);
|
||||
});
|
||||
|
||||
test('returns null for a bare modifier press', () {
|
||||
final down = KeyDownEvent(
|
||||
physicalKey: PhysicalKeyboardKey.controlLeft,
|
||||
logicalKey: LogicalKeyboardKey.controlLeft,
|
||||
timeStamp: Duration.zero,
|
||||
);
|
||||
expect(KeyChord.fromKeyEvent(down, kb), isNull);
|
||||
});
|
||||
|
||||
test('maps a plain key down to a modifier-free chord', () {
|
||||
final down = KeyDownEvent(
|
||||
physicalKey: PhysicalKeyboardKey.keyA,
|
||||
logicalKey: LogicalKeyboardKey.keyA,
|
||||
timeStamp: Duration.zero,
|
||||
);
|
||||
final chord = KeyChord.fromKeyEvent(down, kb)!;
|
||||
expect(chord.modifiers, isEmpty);
|
||||
expect(chord.key, LogicalKeyboardKey.keyA);
|
||||
});
|
||||
|
||||
test('records every held modifier in the resulting chord', () {
|
||||
// Press all four modifiers, then a non-modifier key.
|
||||
for (final m in [
|
||||
(PhysicalKeyboardKey.controlLeft, LogicalKeyboardKey.controlLeft),
|
||||
(PhysicalKeyboardKey.altLeft, LogicalKeyboardKey.altLeft),
|
||||
(PhysicalKeyboardKey.shiftLeft, LogicalKeyboardKey.shiftLeft),
|
||||
(PhysicalKeyboardKey.metaLeft, LogicalKeyboardKey.metaLeft),
|
||||
]) {
|
||||
kb.handleKeyEvent(KeyDownEvent(physicalKey: m.$1, logicalKey: m.$2, timeStamp: Duration.zero));
|
||||
}
|
||||
final down = KeyDownEvent(
|
||||
physicalKey: PhysicalKeyboardKey.keyP,
|
||||
logicalKey: LogicalKeyboardKey.keyP,
|
||||
timeStamp: Duration.zero,
|
||||
);
|
||||
final chord = KeyChord.fromKeyEvent(down, kb)!;
|
||||
expect(chord.key, LogicalKeyboardKey.keyP);
|
||||
expect(chord.modifiers.toSet(), {KeyModifier.ctrl, KeyModifier.alt, KeyModifier.shift, KeyModifier.meta});
|
||||
expect(chord.canonical, 'ctrl+alt+shift+meta+p');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
/// Tests for the KeymapService layering + scope context + resolve.
|
||||
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_service.dart';
|
||||
import 'package:clide/kernel/src/settings.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
late Directory appDir;
|
||||
late SettingsStore settings;
|
||||
|
||||
setUp(() async {
|
||||
appDir = await Directory.systemTemp.createTemp('clide_keymap_test_');
|
||||
settings = SettingsStore(appDir: appDir);
|
||||
await settings.load();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
settings.dispose();
|
||||
if (await appDir.exists()) await appDir.delete(recursive: true);
|
||||
});
|
||||
|
||||
group('load()', () {
|
||||
test('loads the default preset from the asset bundle', () async {
|
||||
final svc = KeymapService(
|
||||
settings: settings,
|
||||
appDir: appDir,
|
||||
bundle: _bundle({
|
||||
'assets/keymaps/default.yaml': 'name: default\nbindings:\n - intent: dismiss\n keys: escape\n',
|
||||
}),
|
||||
);
|
||||
await svc.load();
|
||||
expect(svc.keymap, isNotNull);
|
||||
expect(svc.keymap!.resolve(KeyChord.parse('escape'), const {}), isA<DismissIntent>());
|
||||
});
|
||||
|
||||
test('honours the app.keymap.preset setting', () async {
|
||||
await settings.set<String>(kKeymapPresetSetting, 'vscode');
|
||||
final svc = KeymapService(
|
||||
settings: settings,
|
||||
appDir: appDir,
|
||||
bundle: _bundle({
|
||||
'assets/keymaps/vscode.yaml': 'name: vscode\nbindings:\n - intent: palette.open\n keys: ctrl+shift+p\n',
|
||||
}),
|
||||
);
|
||||
await svc.load();
|
||||
expect(svc.keymap!.resolve(KeyChord.parse('ctrl+shift+p'), const {}), isA<PaletteOpenIntent>());
|
||||
});
|
||||
|
||||
test('missing preset asset is tolerated (active keymap is empty)', () async {
|
||||
final svc = KeymapService(settings: settings, appDir: appDir, bundle: _bundle(const {}));
|
||||
await svc.load();
|
||||
expect(svc.keymap, isNotNull);
|
||||
expect(svc.keymap!.effectiveBindings, isEmpty);
|
||||
});
|
||||
|
||||
test('layers a user file on top of the preset', () async {
|
||||
await File('${appDir.path}/keybindings.yaml').writeAsString(
|
||||
'name: user\nbindings:\n - intent: activate\n keys: ctrl+p\n',
|
||||
);
|
||||
final svc = KeymapService(
|
||||
settings: settings,
|
||||
appDir: appDir,
|
||||
bundle: _bundle({
|
||||
'assets/keymaps/default.yaml': 'name: default\nbindings:\n - intent: palette.open\n keys: ctrl+p\n',
|
||||
}),
|
||||
);
|
||||
await svc.load();
|
||||
expect(svc.keymap!.resolve(KeyChord.parse('ctrl+p'), const {}), isA<ActivateIntent>());
|
||||
});
|
||||
|
||||
test('layers a settings overlay above the user file', () async {
|
||||
await File('${appDir.path}/keybindings.yaml').writeAsString(
|
||||
'name: user\nbindings:\n - intent: activate\n keys: ctrl+p\n',
|
||||
);
|
||||
await settings.set<List<Object?>>(kKeymapOverridesSetting, [
|
||||
{'intent': 'dismiss', 'keys': 'ctrl+p'},
|
||||
]);
|
||||
final svc = KeymapService(
|
||||
settings: settings,
|
||||
appDir: appDir,
|
||||
bundle: _bundle({
|
||||
'assets/keymaps/default.yaml': 'name: default\nbindings:\n - intent: palette.open\n keys: ctrl+p\n',
|
||||
}),
|
||||
);
|
||||
await svc.load();
|
||||
expect(svc.keymap!.resolve(KeyChord.parse('ctrl+p'), const {}), isA<DismissIntent>());
|
||||
});
|
||||
|
||||
test('tolerates a malformed user file by ignoring it', () async {
|
||||
await File('${appDir.path}/keybindings.yaml').writeAsString('not: real keymap [yaml');
|
||||
final svc = KeymapService(
|
||||
settings: settings,
|
||||
appDir: appDir,
|
||||
bundle: _bundle({
|
||||
'assets/keymaps/default.yaml': 'name: default\nbindings:\n - intent: dismiss\n keys: escape\n',
|
||||
}),
|
||||
);
|
||||
await svc.load();
|
||||
expect(svc.keymap!.resolve(KeyChord.parse('escape'), const {}), isA<DismissIntent>());
|
||||
});
|
||||
|
||||
test('tolerates a malformed settings overlay entry by ignoring just the overlay', () async {
|
||||
await settings.set<List<Object?>>(kKeymapOverridesSetting, [
|
||||
{'intent': 'definitely.not.real', 'keys': 'ctrl+p'},
|
||||
]);
|
||||
final svc = KeymapService(
|
||||
settings: settings,
|
||||
appDir: appDir,
|
||||
bundle: _bundle({
|
||||
'assets/keymaps/default.yaml': 'name: default\nbindings:\n - intent: dismiss\n keys: escape\n',
|
||||
}),
|
||||
);
|
||||
await svc.load();
|
||||
expect(svc.keymap!.resolve(KeyChord.parse('escape'), const {}), isA<DismissIntent>());
|
||||
});
|
||||
});
|
||||
|
||||
group('registerCommandBinding / unregisterCommandBindings', () {
|
||||
test('adds an InvokeCommandIntent that resolves after load', () async {
|
||||
final svc = KeymapService(
|
||||
settings: settings,
|
||||
appDir: appDir,
|
||||
bundle: _bundle({'assets/keymaps/default.yaml': 'name: default\nbindings: []\n'}),
|
||||
);
|
||||
await svc.load();
|
||||
svc.registerCommandBinding('ctrl+shift+g', 'git.commit');
|
||||
final intent = svc.keymap!.resolve(KeyChord.parse('ctrl+shift+g'), const {});
|
||||
expect(intent, isA<InvokeCommandIntent>());
|
||||
final invoke = intent as InvokeCommandIntent;
|
||||
expect(invoke.commandId, 'git.commit');
|
||||
// `id` carries the command suffix for round-trip identification.
|
||||
expect(invoke.id, 'command:git.commit');
|
||||
});
|
||||
|
||||
test('honours a when-clause on the contribution', () async {
|
||||
final svc = KeymapService(
|
||||
settings: settings,
|
||||
appDir: appDir,
|
||||
bundle: _bundle({'assets/keymaps/default.yaml': 'name: default\nbindings: []\n'}),
|
||||
);
|
||||
await svc.load();
|
||||
svc.registerCommandBinding('ctrl+s', 'editor.save', when: 'editor.focused');
|
||||
expect(svc.keymap!.resolve(KeyChord.parse('ctrl+s'), const {}), isNull);
|
||||
expect(svc.keymap!.resolve(KeyChord.parse('ctrl+s'), {'editor.focused': true}), isA<InvokeCommandIntent>());
|
||||
});
|
||||
|
||||
test('user file overrides a contributed binding for the same chord', () async {
|
||||
await File('${appDir.path}/keybindings.yaml').writeAsString(
|
||||
'name: user\nbindings:\n - intent: dismiss\n keys: ctrl+x\n',
|
||||
);
|
||||
final svc = KeymapService(
|
||||
settings: settings,
|
||||
appDir: appDir,
|
||||
bundle: _bundle({'assets/keymaps/default.yaml': 'name: default\nbindings: []\n'}),
|
||||
);
|
||||
await svc.load();
|
||||
svc.registerCommandBinding('ctrl+x', 'editor.cut');
|
||||
expect(svc.keymap!.resolve(KeyChord.parse('ctrl+x'), const {}), isA<DismissIntent>());
|
||||
});
|
||||
|
||||
test('unregisterCommandBindings removes prior contributions', () async {
|
||||
final svc = KeymapService(
|
||||
settings: settings,
|
||||
appDir: appDir,
|
||||
bundle: _bundle({'assets/keymaps/default.yaml': 'name: default\nbindings: []\n'}),
|
||||
);
|
||||
await svc.load();
|
||||
svc.registerCommandBinding('ctrl+x', 'editor.cut');
|
||||
expect(svc.keymap!.resolve(KeyChord.parse('ctrl+x'), const {}), isA<InvokeCommandIntent>());
|
||||
svc.unregisterCommandBindings('editor.cut');
|
||||
expect(svc.keymap!.resolve(KeyChord.parse('ctrl+x'), const {}), isNull);
|
||||
});
|
||||
|
||||
test('unregister of an unknown command is a no-op', () async {
|
||||
final svc = KeymapService(
|
||||
settings: settings,
|
||||
appDir: appDir,
|
||||
bundle: _bundle({'assets/keymaps/default.yaml': 'name: default\nbindings: []\n'}),
|
||||
);
|
||||
await svc.load();
|
||||
svc.unregisterCommandBindings('nothing-registered'); // doesn't throw
|
||||
});
|
||||
});
|
||||
|
||||
group('scope flags', () {
|
||||
test('setScopeFlag updates the context; notifies listeners on change', () async {
|
||||
final svc = KeymapService(
|
||||
settings: settings,
|
||||
appDir: appDir,
|
||||
bundle: _bundle({'assets/keymaps/default.yaml': 'name: default\nbindings: []\n'}),
|
||||
);
|
||||
await svc.load();
|
||||
var notified = 0;
|
||||
svc.addListener(() => notified++);
|
||||
svc.setScopeFlag('palette.open', true);
|
||||
expect(svc.scope['palette.open'], isTrue);
|
||||
expect(notified, 1);
|
||||
svc.setScopeFlag('palette.open', true); // no-op
|
||||
expect(notified, 1);
|
||||
svc.setScopeFlag('palette.open', false);
|
||||
expect(notified, 2);
|
||||
});
|
||||
|
||||
test('clearScopeFlag removes the entry; no-op when absent', () async {
|
||||
final svc = KeymapService(
|
||||
settings: settings,
|
||||
appDir: appDir,
|
||||
bundle: _bundle({'assets/keymaps/default.yaml': 'name: default\nbindings: []\n'}),
|
||||
);
|
||||
await svc.load();
|
||||
svc.setScopeFlag('foo', true);
|
||||
svc.clearScopeFlag('foo');
|
||||
expect(svc.scope.containsKey('foo'), isFalse);
|
||||
svc.clearScopeFlag('foo'); // no-op
|
||||
});
|
||||
});
|
||||
|
||||
group('setPreset', () {
|
||||
test('switches presets and reloads', () async {
|
||||
final svc = KeymapService(
|
||||
settings: settings,
|
||||
appDir: appDir,
|
||||
bundle: _bundle({
|
||||
'assets/keymaps/default.yaml': 'name: default\nbindings:\n - intent: dismiss\n keys: escape\n',
|
||||
'assets/keymaps/vim.yaml': 'name: vim\nbindings:\n - intent: activate\n keys: escape\n',
|
||||
}),
|
||||
);
|
||||
await svc.load();
|
||||
expect(svc.keymap!.resolve(KeyChord.parse('escape'), const {}), isA<DismissIntent>());
|
||||
await svc.setPreset('vim');
|
||||
expect(settings.get<String>(kKeymapPresetSetting), 'vim');
|
||||
expect(svc.keymap!.resolve(KeyChord.parse('escape'), const {}), isA<ActivateIntent>());
|
||||
});
|
||||
});
|
||||
|
||||
group('resolveEvent', () {
|
||||
test('returns null before load()', () {
|
||||
final svc = KeymapService(settings: settings, appDir: appDir, bundle: _bundle(const {}));
|
||||
final down = KeyDownEvent(
|
||||
physicalKey: PhysicalKeyboardKey.escape,
|
||||
logicalKey: LogicalKeyboardKey.escape,
|
||||
timeStamp: Duration.zero,
|
||||
);
|
||||
expect(svc.resolveEvent(down, HardwareKeyboard.instance), isNull);
|
||||
});
|
||||
|
||||
test('returns the bound intent for a matched chord', () async {
|
||||
final svc = KeymapService(
|
||||
settings: settings,
|
||||
appDir: appDir,
|
||||
bundle: _bundle({
|
||||
'assets/keymaps/default.yaml': 'name: default\nbindings:\n - intent: dismiss\n keys: escape\n',
|
||||
}),
|
||||
);
|
||||
await svc.load();
|
||||
final down = KeyDownEvent(
|
||||
physicalKey: PhysicalKeyboardKey.escape,
|
||||
logicalKey: LogicalKeyboardKey.escape,
|
||||
timeStamp: Duration.zero,
|
||||
);
|
||||
expect(svc.resolveEvent(down, HardwareKeyboard.instance), isA<DismissIntent>());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// In-memory AssetBundle that returns whatever the constructor map says.
|
||||
AssetBundle _bundle(Map<String, String> files) => _MapBundle(files);
|
||||
|
||||
class _MapBundle extends CachingAssetBundle {
|
||||
_MapBundle(this._files);
|
||||
final Map<String, String> _files;
|
||||
|
||||
@override
|
||||
Future<ByteData> load(String key) async {
|
||||
final s = _files[key];
|
||||
if (s == null) throw Exception('asset not in fake bundle: $key');
|
||||
return ByteData.view(Uint8List.fromList(s.codeUnits).buffer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/// Unit tests for Keymap layering + resolution + KeymapLayer YAML parsing.
|
||||
library;
|
||||
|
||||
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:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('KeymapLayer.fromYaml', () {
|
||||
test('parses a single binding with one chord', () {
|
||||
const src = '''
|
||||
name: test
|
||||
bindings:
|
||||
- intent: activate
|
||||
keys: enter
|
||||
''';
|
||||
final layer = KeymapLayer.fromYaml(src);
|
||||
expect(layer.name, 'test');
|
||||
expect(layer.bindings, hasLength(1));
|
||||
expect(layer.bindings.single.chord, KeyChord.parse('enter'));
|
||||
expect(layer.bindings.single.intent, isA<ActivateIntent>());
|
||||
expect(layer.bindings.single.when, isNull);
|
||||
});
|
||||
|
||||
test('expands `keys:` list into one binding per chord', () {
|
||||
const src = '''
|
||||
name: t
|
||||
bindings:
|
||||
- intent: activate
|
||||
keys: [enter, space]
|
||||
''';
|
||||
final layer = KeymapLayer.fromYaml(src);
|
||||
expect(layer.bindings, hasLength(2));
|
||||
expect(layer.bindings[0].chord, KeyChord.parse('enter'));
|
||||
expect(layer.bindings[1].chord, KeyChord.parse('space'));
|
||||
// Same intent instance reused — fine because intents are const.
|
||||
expect(layer.bindings[0].intent, isA<ActivateIntent>());
|
||||
});
|
||||
|
||||
test('stores when-clause parsed into an evaluable WhenExpr', () {
|
||||
const src = '''
|
||||
name: t
|
||||
bindings:
|
||||
- intent: palette.selectNext
|
||||
keys: down
|
||||
when: palette.open && !textInputFocused
|
||||
''';
|
||||
final layer = KeymapLayer.fromYaml(src);
|
||||
final w = layer.bindings.single.when!;
|
||||
expect(w.evaluate({'palette.open': true, 'textInputFocused': false}), isTrue);
|
||||
expect(w.evaluate({'palette.open': true, 'textInputFocused': true}), isFalse);
|
||||
expect(w.evaluate({'palette.open': false, 'textInputFocused': false}), isFalse);
|
||||
});
|
||||
|
||||
test('command: prefix resolves to InvokeCommandIntent', () {
|
||||
const src = '''
|
||||
name: t
|
||||
bindings:
|
||||
- intent: command:git.commit
|
||||
keys: ctrl+shift+g
|
||||
''';
|
||||
final layer = KeymapLayer.fromYaml(src);
|
||||
final intent = layer.bindings.single.intent;
|
||||
expect(intent, isA<InvokeCommandIntent>());
|
||||
expect((intent as InvokeCommandIntent).commandId, 'git.commit');
|
||||
});
|
||||
|
||||
test('nameOverride wins over `name:`', () {
|
||||
const src = 'name: ignored\nbindings: []\n';
|
||||
final layer = KeymapLayer.fromYaml(src, nameOverride: 'user-file');
|
||||
expect(layer.name, 'user-file');
|
||||
});
|
||||
|
||||
test('rejects unknown intent id', () {
|
||||
const src = 'name: t\nbindings:\n - intent: definitely.not.real\n keys: enter\n';
|
||||
expect(() => KeymapLayer.fromYaml(src), throwsFormatException);
|
||||
});
|
||||
|
||||
test('rejects missing keys', () {
|
||||
const src = 'name: t\nbindings:\n - intent: activate\n';
|
||||
expect(() => KeymapLayer.fromYaml(src), throwsFormatException);
|
||||
});
|
||||
|
||||
test('rejects non-map top level', () {
|
||||
expect(() => KeymapLayer.fromYaml('- one\n- two\n'), throwsFormatException);
|
||||
});
|
||||
|
||||
test('rejects missing bindings list', () {
|
||||
expect(() => KeymapLayer.fromYaml('name: t\n'), throwsFormatException);
|
||||
});
|
||||
|
||||
test('rejects non-string entries in keys list', () {
|
||||
const src = 'name: t\nbindings:\n - intent: activate\n keys: [42]\n';
|
||||
expect(() => KeymapLayer.fromYaml(src), throwsFormatException);
|
||||
});
|
||||
|
||||
test('rejects unparseable keys value (neither string nor list)', () {
|
||||
const src = 'name: t\nbindings:\n - intent: activate\n keys: {wrong: shape}\n';
|
||||
expect(() => KeymapLayer.fromYaml(src), throwsFormatException);
|
||||
});
|
||||
});
|
||||
|
||||
group('Keymap + KeymapLayer toString', () {
|
||||
test('layer toString includes name + binding count', () {
|
||||
final layer = KeymapLayer.fromYaml('name: t\nbindings:\n - intent: dismiss\n keys: escape\n');
|
||||
expect(layer.toString(), 'KeymapLayer(t, 1 binding)');
|
||||
});
|
||||
|
||||
test('keymap toString lists layers low-to-high', () {
|
||||
final a = KeymapLayer.fromYaml('name: a\nbindings: []\n');
|
||||
final b = KeymapLayer.fromYaml('name: b\nbindings: []\n');
|
||||
expect(Keymap([a, b]).toString(), 'Keymap(a < b)');
|
||||
});
|
||||
});
|
||||
|
||||
group('Keymap.resolve — single layer', () {
|
||||
final layer = KeymapLayer.fromYaml('''
|
||||
name: t
|
||||
bindings:
|
||||
- intent: activate
|
||||
keys: enter
|
||||
- intent: palette.selectNext
|
||||
keys: down
|
||||
when: palette.open
|
||||
''');
|
||||
|
||||
test('matches an unconditional binding', () {
|
||||
final km = Keymap([layer]);
|
||||
expect(km.resolve(KeyChord.parse('enter'), const {}), isA<ActivateIntent>());
|
||||
});
|
||||
|
||||
test('matches a when-gated binding when the flag is true', () {
|
||||
final km = Keymap([layer]);
|
||||
expect(km.resolve(KeyChord.parse('down'), {'palette.open': true}), isA<PaletteSelectNextIntent>());
|
||||
});
|
||||
|
||||
test('skips a when-gated binding when the flag is false', () {
|
||||
final km = Keymap([layer]);
|
||||
expect(km.resolve(KeyChord.parse('down'), const {}), isNull);
|
||||
});
|
||||
|
||||
test('returns null when the chord has no binding', () {
|
||||
final km = Keymap([layer]);
|
||||
expect(km.resolve(KeyChord.parse('ctrl+x'), const {}), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('Keymap.resolve — layering precedence', () {
|
||||
test('later layer replaces earlier binding for the same chord', () {
|
||||
final preset = KeymapLayer.fromYaml('''
|
||||
name: preset
|
||||
bindings:
|
||||
- intent: palette.open
|
||||
keys: ctrl+shift+p
|
||||
''');
|
||||
final user = KeymapLayer.fromYaml('''
|
||||
name: user
|
||||
bindings:
|
||||
- intent: activate
|
||||
keys: ctrl+shift+p
|
||||
''');
|
||||
final km = Keymap([preset, user]);
|
||||
// User layer wins — Activate, not PaletteOpen.
|
||||
expect(km.resolve(KeyChord.parse('ctrl+shift+p'), const {}), isA<ActivateIntent>());
|
||||
});
|
||||
|
||||
test('preset binding survives when no later layer overrides it', () {
|
||||
final preset = KeymapLayer.fromYaml('''
|
||||
name: preset
|
||||
bindings:
|
||||
- intent: dismiss
|
||||
keys: escape
|
||||
''');
|
||||
final user = KeymapLayer.fromYaml('''
|
||||
name: user
|
||||
bindings:
|
||||
- intent: activate
|
||||
keys: enter
|
||||
''');
|
||||
final km = Keymap([preset, user]);
|
||||
expect(km.resolve(KeyChord.parse('escape'), const {}), isA<DismissIntent>());
|
||||
expect(km.resolve(KeyChord.parse('enter'), const {}), isA<ActivateIntent>());
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/// Unit tests for the when-clause parser + evaluator.
|
||||
library;
|
||||
|
||||
import 'package:clide/kernel/src/keymap/when_clause.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('WhenExpr.parse — grammar', () {
|
||||
test('single identifier', () {
|
||||
final e = WhenExpr.parse('foo');
|
||||
expect(e, isA<WhenIdent>());
|
||||
expect(e.evaluate({'foo': true}), isTrue);
|
||||
expect(e.evaluate({'foo': false}), isFalse);
|
||||
expect(e.evaluate(const {}), isFalse, reason: 'missing identifier evaluates to false');
|
||||
});
|
||||
|
||||
test('negation', () {
|
||||
final e = WhenExpr.parse('!foo');
|
||||
expect(e.evaluate({'foo': true}), isFalse);
|
||||
expect(e.evaluate({'foo': false}), isTrue);
|
||||
expect(e.evaluate(const {}), isTrue, reason: '!missing → true');
|
||||
});
|
||||
|
||||
test('double negation', () {
|
||||
final e = WhenExpr.parse('!!foo');
|
||||
expect(e.evaluate({'foo': true}), isTrue);
|
||||
expect(e.evaluate({'foo': false}), isFalse);
|
||||
});
|
||||
|
||||
test('conjunction is left-associative', () {
|
||||
final e = WhenExpr.parse('a && b && c');
|
||||
expect(e.evaluate({'a': true, 'b': true, 'c': true}), isTrue);
|
||||
expect(e.evaluate({'a': true, 'b': false, 'c': true}), isFalse);
|
||||
});
|
||||
|
||||
test('disjunction is left-associative', () {
|
||||
final e = WhenExpr.parse('a || b || c');
|
||||
expect(e.evaluate({'a': false, 'b': false, 'c': true}), isTrue);
|
||||
expect(e.evaluate({'a': false, 'b': false, 'c': false}), isFalse);
|
||||
});
|
||||
|
||||
test('and binds tighter than or', () {
|
||||
// a || b && c == a || (b && c)
|
||||
final e = WhenExpr.parse('a || b && c');
|
||||
expect(e.evaluate({'a': false, 'b': true, 'c': false}), isFalse);
|
||||
expect(e.evaluate({'a': false, 'b': true, 'c': true}), isTrue);
|
||||
expect(e.evaluate({'a': true, 'b': false, 'c': false}), isTrue);
|
||||
});
|
||||
|
||||
test('parens override precedence', () {
|
||||
// (a || b) && c
|
||||
final e = WhenExpr.parse('(a || b) && c');
|
||||
expect(e.evaluate({'a': true, 'b': false, 'c': false}), isFalse);
|
||||
expect(e.evaluate({'a': true, 'b': false, 'c': true}), isTrue);
|
||||
});
|
||||
|
||||
test('not binds tighter than and/or', () {
|
||||
// !a && b → (!a) && b
|
||||
final e = WhenExpr.parse('!a && b');
|
||||
expect(e.evaluate({'a': false, 'b': true}), isTrue);
|
||||
expect(e.evaluate({'a': true, 'b': true}), isFalse);
|
||||
});
|
||||
|
||||
test('identifiers may contain dots, hyphens, underscores', () {
|
||||
final e = WhenExpr.parse('palette.is-open && _editor_focused');
|
||||
expect(e.evaluate({'palette.is-open': true, '_editor_focused': true}), isTrue);
|
||||
});
|
||||
|
||||
test('whitespace is tolerated', () {
|
||||
final e = WhenExpr.parse(' a && ( b || !c ) ');
|
||||
expect(e.evaluate({'a': true, 'b': true, 'c': true}), isTrue);
|
||||
expect(e.evaluate({'a': true, 'b': false, 'c': true}), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('WhenExpr.parse — errors', () {
|
||||
test('empty input throws', () {
|
||||
expect(() => WhenExpr.parse(''), throwsFormatException);
|
||||
});
|
||||
|
||||
test('unbalanced paren throws', () {
|
||||
expect(() => WhenExpr.parse('(a && b'), throwsFormatException);
|
||||
});
|
||||
|
||||
test('trailing junk throws', () {
|
||||
expect(() => WhenExpr.parse('a && b foo'), throwsFormatException);
|
||||
});
|
||||
|
||||
test('missing operand after operator throws', () {
|
||||
expect(() => WhenExpr.parse('a &&'), throwsFormatException);
|
||||
});
|
||||
|
||||
test('bare ! throws', () {
|
||||
expect(() => WhenExpr.parse('!'), throwsFormatException);
|
||||
});
|
||||
});
|
||||
|
||||
group('WhenExpr.tryParse', () {
|
||||
test('null and empty return null', () {
|
||||
expect(WhenExpr.tryParse(null), isNull);
|
||||
expect(WhenExpr.tryParse(' '), isNull);
|
||||
});
|
||||
|
||||
test('non-empty delegates to parse', () {
|
||||
expect(WhenExpr.tryParse('foo'), isA<WhenIdent>());
|
||||
});
|
||||
});
|
||||
|
||||
group('WhenExpr.toString', () {
|
||||
test('round-trips each node shape', () {
|
||||
expect(WhenExpr.parse('foo').toString(), 'foo');
|
||||
expect(WhenExpr.parse('!foo').toString(), '!foo');
|
||||
expect(WhenExpr.parse('a && b').toString(), '(a && b)');
|
||||
expect(WhenExpr.parse('a || b').toString(), '(a || b)');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user