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,197 @@
|
||||
/// Kernel service that owns the active [Keymap], scope context, and
|
||||
/// resolution surface.
|
||||
///
|
||||
/// Layering (lowest precedence → highest):
|
||||
/// 1. The active preset (asset under `assets/keymaps/<preset>.yaml`).
|
||||
/// Selected by the `app.keymap.preset` setting; defaults to
|
||||
/// `default`.
|
||||
/// 2. A user keymap file at `<appDir>/keybindings.yaml` (per-user
|
||||
/// power-user overrides).
|
||||
/// 3. A settings-stored JSON overlay at `app.keymap.overrides` —
|
||||
/// list of `{intent, keys, when?}` maps in the same shape as
|
||||
/// preset YAML.
|
||||
///
|
||||
/// Scope context is a `Map<String, bool>` keyed by named flags (e.g.
|
||||
/// `palette.open`, `editor.focused`). Producing services call
|
||||
/// [setScopeFlag] when their state changes; consumers reference the
|
||||
/// flag name in when-clauses.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart' show AssetBundle, KeyEvent, HardwareKeyboard, rootBundle;
|
||||
|
||||
import '../settings.dart';
|
||||
import 'intents.dart';
|
||||
import 'key_chord.dart';
|
||||
import 'keymap.dart';
|
||||
import 'when_clause.dart';
|
||||
|
||||
/// Setting key for the active preset name.
|
||||
const String kKeymapPresetSetting = 'app.keymap.preset';
|
||||
|
||||
/// Setting key for the JSON overlay list.
|
||||
const String kKeymapOverridesSetting = 'app.keymap.overrides';
|
||||
|
||||
/// Filename for the user keymap file under the app dir.
|
||||
const String kKeymapUserFile = 'keybindings.yaml';
|
||||
|
||||
class KeymapService extends ChangeNotifier {
|
||||
KeymapService({
|
||||
required SettingsStore settings,
|
||||
required Directory appDir,
|
||||
AssetBundle? bundle,
|
||||
}) : _settings = settings,
|
||||
_appDir = appDir,
|
||||
_bundle = bundle ?? rootBundle;
|
||||
|
||||
final SettingsStore _settings;
|
||||
final Directory _appDir;
|
||||
final AssetBundle _bundle;
|
||||
|
||||
Keymap? _active;
|
||||
final Map<String, bool> _scope = {};
|
||||
|
||||
// The four layer slots, lowest to highest precedence. Held
|
||||
// separately so [registerCommandBinding] can refresh the
|
||||
// contributions layer without re-reading the preset / file /
|
||||
// settings.
|
||||
KeymapLayer? _preset;
|
||||
final List<KeymapBinding> _contributions = [];
|
||||
KeymapLayer? _userFile;
|
||||
KeymapLayer? _settingsOverlay;
|
||||
|
||||
/// The currently effective layered keymap. Null before [load] runs.
|
||||
Keymap? get keymap => _active;
|
||||
|
||||
/// Live read-only view of the scope context.
|
||||
Map<String, bool> get scope => Map.unmodifiable(_scope);
|
||||
|
||||
/// Read the preset from settings (default `default`), load all
|
||||
/// non-contribution layers, and rebuild the active keymap. Safe to
|
||||
/// call repeatedly. Contributions registered via
|
||||
/// [registerCommandBinding] are preserved across reloads.
|
||||
Future<void> load() async {
|
||||
final presetName = _settings.get<String>(kKeymapPresetSetting) ?? 'default';
|
||||
|
||||
// Preset (asset).
|
||||
try {
|
||||
final src = await _bundle.loadString('assets/keymaps/$presetName.yaml');
|
||||
_preset = KeymapLayer.fromYaml(src, nameOverride: presetName);
|
||||
} catch (_) {
|
||||
// A missing preset means we ship without a default. Tests can
|
||||
// inject a custom bundle. We don't surface this beyond an empty
|
||||
// active map.
|
||||
_preset = null;
|
||||
}
|
||||
|
||||
// User file overlay.
|
||||
final userFile = File('${_appDir.path}/$kKeymapUserFile');
|
||||
if (await userFile.exists()) {
|
||||
try {
|
||||
_userFile = KeymapLayer.fromYaml(await userFile.readAsString(), nameOverride: 'user-file');
|
||||
} on FormatException {
|
||||
_userFile = null;
|
||||
}
|
||||
} else {
|
||||
_userFile = null;
|
||||
}
|
||||
|
||||
// Settings overlay.
|
||||
final overlay = _settings.get<List<Object?>>(kKeymapOverridesSetting);
|
||||
if (overlay != null && overlay.isNotEmpty) {
|
||||
final asYaml = StringBuffer('name: settings-overlay\nbindings:\n');
|
||||
for (final entry in overlay) {
|
||||
if (entry is! Map) continue;
|
||||
asYaml.writeln(' - ${jsonEncode(entry)}');
|
||||
}
|
||||
try {
|
||||
_settingsOverlay = KeymapLayer.fromYaml(asYaml.toString(), nameOverride: 'settings-overlay');
|
||||
} on FormatException {
|
||||
_settingsOverlay = null;
|
||||
}
|
||||
} else {
|
||||
_settingsOverlay = null;
|
||||
}
|
||||
|
||||
_rebuildActive();
|
||||
}
|
||||
|
||||
/// Register a programmatic chord → command-id binding (typically
|
||||
/// from an extension's `defaultBinding`). Contributions form a
|
||||
/// layer between preset and user-file: extensions establish their
|
||||
/// defaults, the user can override either via the user file or
|
||||
/// settings overlay.
|
||||
void registerCommandBinding(String chordSpec, String commandId, {String? when}) {
|
||||
_contributions.add(KeymapBinding(
|
||||
chord: KeyChord.parse(chordSpec),
|
||||
intent: InvokeCommandIntent(commandId),
|
||||
when: WhenExpr.tryParse(when),
|
||||
));
|
||||
_rebuildActive();
|
||||
}
|
||||
|
||||
/// Remove all extension-contributed bindings for [commandId]. Used
|
||||
/// when an extension is disabled or unregistered.
|
||||
void unregisterCommandBindings(String commandId) {
|
||||
final before = _contributions.length;
|
||||
_contributions.removeWhere((b) {
|
||||
final i = b.intent;
|
||||
return i is InvokeCommandIntent && i.commandId == commandId;
|
||||
});
|
||||
if (_contributions.length != before) {
|
||||
_rebuildActive();
|
||||
}
|
||||
}
|
||||
|
||||
void _rebuildActive() {
|
||||
final layers = <KeymapLayer>[
|
||||
if (_preset != null) _preset!,
|
||||
KeymapLayer(name: 'contributions', bindings: List.unmodifiable(_contributions)),
|
||||
if (_userFile != null) _userFile!,
|
||||
if (_settingsOverlay != null) _settingsOverlay!,
|
||||
];
|
||||
_active = Keymap(layers);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Resolve a [KeyEvent] against the active keymap and current scope.
|
||||
/// Returns null when nothing matches.
|
||||
ClideIntent? resolveEvent(KeyEvent event, HardwareKeyboard kb) {
|
||||
final km = _active;
|
||||
if (km == null) return null;
|
||||
final chord = KeyChord.fromKeyEvent(event, kb);
|
||||
if (chord == null) return null;
|
||||
return km.resolve(chord, _scope);
|
||||
}
|
||||
|
||||
/// Set a named scope flag. Producers should call this when their
|
||||
/// state changes so when-clauses re-evaluate correctly. Notifies
|
||||
/// listeners when the value actually changes.
|
||||
void setScopeFlag(String name, bool value) {
|
||||
if (_scope[name] == value) return;
|
||||
_scope[name] = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Clear a named scope flag.
|
||||
void clearScopeFlag(String name) {
|
||||
if (!_scope.containsKey(name)) return;
|
||||
_scope.remove(name);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Switch presets. Persists the new preset name to settings and
|
||||
/// re-loads the layered keymap.
|
||||
Future<void> setPreset(String name) async {
|
||||
await _settings.set<String>(kKeymapPresetSetting, name);
|
||||
await load();
|
||||
}
|
||||
|
||||
/// All effective bindings, highest-precedence first. Useful for
|
||||
/// debug surfaces and keybinding hints in the UI.
|
||||
List<KeymapBinding> get effectiveBindings => _active?.effectiveBindings ?? const [];
|
||||
}
|
||||
Reference in New Issue
Block a user