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:
2026-05-17 21:40:02 +02:00
co-authored by Claude Opus 4.7
parent a8729db893
commit 798ba524f1
18 changed files with 1944 additions and 47 deletions
+152
View File
@@ -0,0 +1,152 @@
/// Typed [Intent]s the keymap dispatches.
///
/// Widgets bind Actions to Intent types via `Actions.handler`. Preset
/// YAML files reference Intents by their string id (`activate`,
/// `palette.selectNext`, …). The id stays stable across SDK reshapes;
/// the Dart class name can move without invalidating user keymaps.
///
/// To add a new Intent: declare a subclass with a unique [id] and
/// register it in [allIntents]. Widget integration is per-feature
/// (Actions wiring lives in the consuming widget).
library;
import 'package:flutter/widgets.dart';
/// Base for every keymap-dispatched intent. The [id] is the YAML
/// identifier (e.g. `palette.selectNext`).
abstract class ClideIntent extends Intent {
const ClideIntent();
String get id;
}
// -- Activation / navigation ------------------------------------------------
/// "Click this thing" — fired on Enter/Space against any focusable
/// `ClideTappable`-rooted widget.
class ActivateIntent extends ClideIntent {
const ActivateIntent();
@override
String get id => 'activate';
}
/// "Cancel / dismiss the current modal / overlay".
class DismissIntent extends ClideIntent {
const DismissIntent();
@override
String get id => 'dismiss';
}
/// "Move focus to the next focusable in tab order".
class FocusNextIntent extends ClideIntent {
const FocusNextIntent();
@override
String get id => 'focus.next';
}
/// "Move focus to the previous focusable".
class FocusPreviousIntent extends ClideIntent {
const FocusPreviousIntent();
@override
String get id => 'focus.previous';
}
// -- Command palette --------------------------------------------------------
/// Open the command palette.
class PaletteOpenIntent extends ClideIntent {
const PaletteOpenIntent();
@override
String get id => 'palette.open';
}
/// Highlight the next palette result.
class PaletteSelectNextIntent extends ClideIntent {
const PaletteSelectNextIntent();
@override
String get id => 'palette.selectNext';
}
/// Highlight the previous palette result.
class PaletteSelectPreviousIntent extends ClideIntent {
const PaletteSelectPreviousIntent();
@override
String get id => 'palette.selectPrevious';
}
/// Invoke the highlighted palette result.
class PaletteAcceptIntent extends ClideIntent {
const PaletteAcceptIntent();
@override
String get id => 'palette.accept';
}
// -- Text scale -------------------------------------------------------------
class TextScaleIncreaseIntent extends ClideIntent {
const TextScaleIncreaseIntent();
@override
String get id => 'text.scaleIncrease';
}
class TextScaleDecreaseIntent extends ClideIntent {
const TextScaleDecreaseIntent();
@override
String get id => 'text.scaleDecrease';
}
class TextScaleResetIntent extends ClideIntent {
const TextScaleResetIntent();
@override
String get id => 'text.scaleReset';
}
// -- Command bridge ---------------------------------------------------------
/// Generic "invoke this CommandRegistry command id" intent. Used for
/// bindings that target a contributed command rather than a typed
/// intent. The keymap creates one per binding; the Actions handler
/// dispatches to the [CommandRegistry].
class InvokeCommandIntent extends ClideIntent {
const InvokeCommandIntent(this.commandId);
final String commandId;
@override
String get id => 'command:$commandId';
}
// -- Lookup -----------------------------------------------------------------
/// Map from YAML id → factory. Preset files reference intents by id;
/// the keymap loader uses this to instantiate them. Intents with a
/// configurable payload (only `InvokeCommandIntent` today) are not in
/// the map — the loader recognises the `command:` prefix and
/// instantiates them inline.
final Map<String, ClideIntent Function()> builtinIntents = {
for (final i in _allBuiltin) i.id: () => i,
};
const List<ClideIntent> _allBuiltin = [
ActivateIntent(),
DismissIntent(),
FocusNextIntent(),
FocusPreviousIntent(),
PaletteOpenIntent(),
PaletteSelectNextIntent(),
PaletteSelectPreviousIntent(),
PaletteAcceptIntent(),
TextScaleIncreaseIntent(),
TextScaleDecreaseIntent(),
TextScaleResetIntent(),
];
/// Parse an intent id into a [ClideIntent]. Returns null if the id is
/// unknown. Recognises:
/// - any builtin intent by its stable id
/// - `command:<command-id>` → [InvokeCommandIntent]
ClideIntent? parseIntentId(String id) {
final builtin = builtinIntents[id];
if (builtin != null) return builtin();
if (id.startsWith('command:')) {
return InvokeCommandIntent(id.substring('command:'.length));
}
return null;
}
+213
View File
@@ -0,0 +1,213 @@
/// Layout-independent representation of a single keystroke.
///
/// The consultant's note (T-110) flagged the old `KeybindingResolver`
/// for keying off `LogicalKeyboardKey.keyLabel`, which is locale-aware
/// (US-QWERTY `Ctrl+/` differs from AZERTY `Ctrl+:`). We key off
/// `LogicalKeyboardKey.keyId` instead — a stable u32 that survives
/// layout changes.
library;
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
/// One of the four POSIX-style modifier keys. Order is the canonical
/// presentation order in YAML and toString output.
enum KeyModifier {
ctrl,
alt,
shift,
meta;
/// Lowercase short form used in YAML (`ctrl`, `alt`, `shift`, `meta`).
String get yaml => name;
/// Display string used in palette + tooltip hints.
String get display => switch (this) {
KeyModifier.ctrl => 'Ctrl',
KeyModifier.alt => 'Alt',
KeyModifier.shift => 'Shift',
KeyModifier.meta => 'Cmd',
};
}
/// A modifier-set + a single key, identified by layout-independent
/// `LogicalKeyboardKey.keyId`. Canonicalised on construction
/// (modifiers sorted by enum order) so equality + hashing work for
/// lookup-keying.
@immutable
class KeyChord {
factory KeyChord({Set<KeyModifier> modifiers = const {}, required LogicalKeyboardKey key}) {
final sorted = modifiers.toList()..sort((a, b) => a.index.compareTo(b.index));
return KeyChord._(List.unmodifiable(sorted), key);
}
const KeyChord._(this.modifiers, this.key);
final List<KeyModifier> modifiers;
final LogicalKeyboardKey key;
/// Build from a Flutter [KeyEvent]. Returns null for non-down events
/// or events whose logical key has no meaningful id (e.g. a bare
/// modifier press in isolation).
static KeyChord? fromKeyEvent(KeyEvent event, HardwareKeyboard kb) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) return null;
final logical = event.logicalKey;
// Bare modifier presses don't form a chord on their own.
if (_isBareModifier(logical)) return null;
final mods = <KeyModifier>{
if (kb.isControlPressed) KeyModifier.ctrl,
if (kb.isAltPressed) KeyModifier.alt,
if (kb.isShiftPressed) KeyModifier.shift,
if (kb.isMetaPressed) KeyModifier.meta,
};
return KeyChord(modifiers: mods, key: logical);
}
/// Parse a YAML chord spec like `ctrl+shift+p`, `cmd+enter`, `escape`.
/// Whitespace tolerated. Throws [FormatException] on unknown tokens
/// or empty input.
static KeyChord parse(String spec) {
final trimmed = spec.trim();
if (trimmed.isEmpty) throw const FormatException('empty key chord');
final parts = trimmed.split('+').map((s) => s.trim()).toList();
final keyName = parts.removeLast();
if (keyName.isEmpty) throw FormatException('missing key in chord: "$spec"');
final mods = <KeyModifier>{};
for (final m in parts) {
final mod = _modByName(m);
if (mod == null) throw FormatException('unknown modifier "$m" in chord: "$spec"');
mods.add(mod);
}
final key = _keyByName(keyName);
if (key == null) throw FormatException('unknown key "$keyName" in chord: "$spec"');
return KeyChord(modifiers: mods, key: key);
}
/// Canonical YAML form: `ctrl+shift+p`.
String get canonical {
final modPart = modifiers.map((m) => m.yaml).join('+');
final keyPart = _keyName(key);
return modPart.isEmpty ? keyPart : '$modPart+$keyPart';
}
/// Display form for UI hints: `Ctrl+Shift+P`.
String get display {
final modPart = modifiers.map((m) => m.display).join('+');
final keyPart = _keyName(key).toUpperCase();
return modPart.isEmpty ? keyPart : '$modPart+$keyPart';
}
@override
bool operator ==(Object other) => other is KeyChord && other.key == key && listEquals(other.modifiers, modifiers);
@override
int get hashCode => Object.hash(key, Object.hashAll(modifiers));
@override
String toString() => 'KeyChord($canonical)';
}
bool _isBareModifier(LogicalKeyboardKey k) =>
k == LogicalKeyboardKey.control ||
k == LogicalKeyboardKey.controlLeft ||
k == LogicalKeyboardKey.controlRight ||
k == LogicalKeyboardKey.alt ||
k == LogicalKeyboardKey.altLeft ||
k == LogicalKeyboardKey.altRight ||
k == LogicalKeyboardKey.shift ||
k == LogicalKeyboardKey.shiftLeft ||
k == LogicalKeyboardKey.shiftRight ||
k == LogicalKeyboardKey.meta ||
k == LogicalKeyboardKey.metaLeft ||
k == LogicalKeyboardKey.metaRight ||
k == LogicalKeyboardKey.fn;
KeyModifier? _modByName(String name) {
switch (name.toLowerCase()) {
case 'ctrl':
case 'control':
return KeyModifier.ctrl;
case 'alt':
case 'option':
return KeyModifier.alt;
case 'shift':
return KeyModifier.shift;
case 'meta':
case 'cmd':
case 'command':
case 'super':
case 'win':
return KeyModifier.meta;
}
return null;
}
// -- Key name <-> LogicalKeyboardKey ----------------------------------------
//
// We map YAML names to LogicalKeyboardKey instances. The map covers
// every key a binding can plausibly want; unknown names throw on parse.
const Map<String, LogicalKeyboardKey> _byName = {
// Letters
'a': LogicalKeyboardKey.keyA, 'b': LogicalKeyboardKey.keyB, 'c': LogicalKeyboardKey.keyC,
'd': LogicalKeyboardKey.keyD, 'e': LogicalKeyboardKey.keyE, 'f': LogicalKeyboardKey.keyF,
'g': LogicalKeyboardKey.keyG, 'h': LogicalKeyboardKey.keyH, 'i': LogicalKeyboardKey.keyI,
'j': LogicalKeyboardKey.keyJ, 'k': LogicalKeyboardKey.keyK, 'l': LogicalKeyboardKey.keyL,
'm': LogicalKeyboardKey.keyM, 'n': LogicalKeyboardKey.keyN, 'o': LogicalKeyboardKey.keyO,
'p': LogicalKeyboardKey.keyP, 'q': LogicalKeyboardKey.keyQ, 'r': LogicalKeyboardKey.keyR,
's': LogicalKeyboardKey.keyS, 't': LogicalKeyboardKey.keyT, 'u': LogicalKeyboardKey.keyU,
'v': LogicalKeyboardKey.keyV, 'w': LogicalKeyboardKey.keyW, 'x': LogicalKeyboardKey.keyX,
'y': LogicalKeyboardKey.keyY, 'z': LogicalKeyboardKey.keyZ,
// Digits
'0': LogicalKeyboardKey.digit0, '1': LogicalKeyboardKey.digit1, '2': LogicalKeyboardKey.digit2,
'3': LogicalKeyboardKey.digit3, '4': LogicalKeyboardKey.digit4, '5': LogicalKeyboardKey.digit5,
'6': LogicalKeyboardKey.digit6, '7': LogicalKeyboardKey.digit7, '8': LogicalKeyboardKey.digit8,
'9': LogicalKeyboardKey.digit9,
// Function keys
'f1': LogicalKeyboardKey.f1, 'f2': LogicalKeyboardKey.f2, 'f3': LogicalKeyboardKey.f3,
'f4': LogicalKeyboardKey.f4, 'f5': LogicalKeyboardKey.f5, 'f6': LogicalKeyboardKey.f6,
'f7': LogicalKeyboardKey.f7, 'f8': LogicalKeyboardKey.f8, 'f9': LogicalKeyboardKey.f9,
'f10': LogicalKeyboardKey.f10, 'f11': LogicalKeyboardKey.f11, 'f12': LogicalKeyboardKey.f12,
// Arrows
'left': LogicalKeyboardKey.arrowLeft,
'right': LogicalKeyboardKey.arrowRight,
'up': LogicalKeyboardKey.arrowUp,
'down': LogicalKeyboardKey.arrowDown,
// Common control keys
'enter': LogicalKeyboardKey.enter,
'return': LogicalKeyboardKey.enter,
'escape': LogicalKeyboardKey.escape,
'esc': LogicalKeyboardKey.escape,
'tab': LogicalKeyboardKey.tab,
'space': LogicalKeyboardKey.space,
'backspace': LogicalKeyboardKey.backspace,
'delete': LogicalKeyboardKey.delete,
'home': LogicalKeyboardKey.home,
'end': LogicalKeyboardKey.end,
'pageup': LogicalKeyboardKey.pageUp,
'pagedown': LogicalKeyboardKey.pageDown,
'insert': LogicalKeyboardKey.insert,
// Punctuation (US-QWERTY positions; preset authors can rely on these names).
'minus': LogicalKeyboardKey.minus, '-': LogicalKeyboardKey.minus,
'equal': LogicalKeyboardKey.equal, '=': LogicalKeyboardKey.equal,
'comma': LogicalKeyboardKey.comma, ',': LogicalKeyboardKey.comma,
'period': LogicalKeyboardKey.period, '.': LogicalKeyboardKey.period,
'slash': LogicalKeyboardKey.slash, '/': LogicalKeyboardKey.slash,
'backslash': LogicalKeyboardKey.backslash, r'\\': LogicalKeyboardKey.backslash,
'semicolon': LogicalKeyboardKey.semicolon, ';': LogicalKeyboardKey.semicolon,
'quote': LogicalKeyboardKey.quote, "'": LogicalKeyboardKey.quote,
'bracketLeft': LogicalKeyboardKey.bracketLeft, '[': LogicalKeyboardKey.bracketLeft,
'bracketRight': LogicalKeyboardKey.bracketRight, ']': LogicalKeyboardKey.bracketRight,
'backquote': LogicalKeyboardKey.backquote, '`': LogicalKeyboardKey.backquote,
};
LogicalKeyboardKey? _keyByName(String name) => _byName[name.toLowerCase()];
String _keyName(LogicalKeyboardKey key) {
// Reverse lookup; prefer the canonical (first) name for each key.
for (final entry in _byName.entries) {
if (entry.value == key) return entry.key;
}
// Fallback: use the debugName-like representation.
return key.keyLabel.isNotEmpty ? key.keyLabel.toLowerCase() : 'key(0x${key.keyId.toRadixString(16)})';
}
+147
View File
@@ -0,0 +1,147 @@
/// In-memory representation of a layered keymap.
///
/// A [Keymap] is built from one or more [KeymapLayer]s (preset →
/// user-file overlay → settings overlay). Each layer contributes
/// [KeymapBinding]s; later layers replace earlier bindings with the
/// same (chord, when-clause) tuple.
///
/// At resolve time, the [Keymap] walks the layered list once per
/// (chord, scope) and returns the [ClideIntent] bound by the highest-
/// precedence matching layer.
library;
import 'package:flutter/foundation.dart';
import 'package:yaml/yaml.dart';
import 'intents.dart';
import 'key_chord.dart';
import 'when_clause.dart';
/// One row in a layer: a chord, an optional when-clause, and the
/// intent to fire when the chord matches and the when-clause is true.
@immutable
class KeymapBinding {
const KeymapBinding({
required this.chord,
required this.intent,
this.when,
});
final KeyChord chord;
final ClideIntent intent;
final WhenExpr? when;
@override
String toString() => 'Binding($chord${intent.id}${when == null ? '' : ' when $when'})';
}
/// One source of bindings. Layers are merged in order — later layers
/// take precedence on (chord, when) collisions.
@immutable
class KeymapLayer {
const KeymapLayer({required this.name, required this.bindings});
final String name;
final List<KeymapBinding> bindings;
/// Parse a YAML document into a layer. Expected shape:
///
/// ```yaml
/// name: default
/// bindings:
/// - intent: activate
/// keys: [enter, space]
/// when: focused
/// - intent: palette.selectNext
/// keys: [down]
/// when: palette.open
/// ```
///
/// `keys:` may be a single chord string or a list. `when:` is
/// optional. Unknown intent ids cause a [FormatException].
factory KeymapLayer.fromYaml(String source, {String? nameOverride}) {
final doc = loadYaml(source);
if (doc is! YamlMap) {
throw const FormatException('keymap YAML must be a map at top level');
}
final name = nameOverride ?? (doc['name'] as String? ?? 'unnamed');
final raw = doc['bindings'];
if (raw is! YamlList) {
throw const FormatException('keymap YAML must define `bindings:` as a list');
}
final out = <KeymapBinding>[];
for (final entry in raw) {
if (entry is! YamlMap) {
throw FormatException('binding entries must be maps; got $entry');
}
final intentId = entry['intent'] as String?;
if (intentId == null) {
throw FormatException('binding missing `intent:` — $entry');
}
final intent = parseIntentId(intentId);
if (intent == null) {
throw FormatException('unknown intent id "$intentId" — $entry');
}
final keysRaw = entry['keys'];
final keySpecs = <String>[];
if (keysRaw is String) {
keySpecs.add(keysRaw);
} else if (keysRaw is YamlList) {
for (final k in keysRaw) {
if (k is! String) throw FormatException('keys must be strings; got $k in $entry');
keySpecs.add(k);
}
} else {
throw FormatException('binding missing `keys:` (string or list of strings) — $entry');
}
final when = WhenExpr.tryParse(entry['when'] as String?);
for (final spec in keySpecs) {
out.add(KeymapBinding(chord: KeyChord.parse(spec), intent: intent, when: when));
}
}
return KeymapLayer(name: name, bindings: out);
}
@override
String toString() => 'KeymapLayer($name, ${bindings.length} binding${bindings.length == 1 ? '' : 's'})';
}
/// A flattened keymap, ready for resolution.
@immutable
class Keymap {
Keymap(this.layers) : _effective = _flatten(layers);
final List<KeymapLayer> layers;
final List<KeymapBinding> _effective;
/// Resolve a [chord] against the current [context]. Returns the
/// highest-precedence binding whose chord matches and whose when-
/// clause (if any) evaluates true. Returns null if no match.
ClideIntent? resolve(KeyChord chord, Map<String, bool> context) {
// Effective list is highest-precedence-first; first match wins.
for (final b in _effective) {
if (b.chord != chord) continue;
if (b.when != null && !b.when!.evaluate(context)) continue;
return b.intent;
}
return null;
}
/// All resolved bindings in effective-precedence order. Exposed for
/// debug surfaces (keybindings UI, palette hints).
List<KeymapBinding> get effectiveBindings => List.unmodifiable(_effective);
/// Concatenate layers in REVERSE order (last layer first). Later
/// layers fully shadow earlier (chord, when) collisions: when we walk
/// the list, the first matching entry wins, so highest-precedence
/// must come first. We don't dedupe — a no-op match in a later layer
/// just earns the first slot.
static List<KeymapBinding> _flatten(List<KeymapLayer> layers) {
return [
for (final l in layers.reversed) ...l.bindings,
];
}
@override
String toString() => 'Keymap(${layers.map((l) => l.name).join(' < ')})';
}
+197
View File
@@ -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 [];
}
+178
View File
@@ -0,0 +1,178 @@
/// Boolean "when:" expressions over a named context bag, VS-Code style.
///
/// Grammar:
/// expr := or
/// or := and ('||' and)*
/// and := unary ('&&' unary)*
/// unary := '!' unary | atom
/// atom := IDENT | '(' expr ')'
/// IDENT := [a-zA-Z_][a-zA-Z0-9._-]*
///
/// Identifiers resolve against a `Map<String, bool>` context. A missing
/// identifier evaluates to `false` — bindings can assume any required
/// scope flag is published by the producing service.
///
/// The grammar is intentionally small: no equality, no arithmetic, no
/// string literals. If a binding needs more, the producing service
/// should publish a richer named flag (e.g. `editor.dirty`).
library;
import 'package:flutter/foundation.dart';
@immutable
sealed class WhenExpr {
const WhenExpr();
/// Evaluate against [context]. Missing identifiers are `false`.
bool evaluate(Map<String, bool> context);
/// Parse [source]. Throws [FormatException] on syntax errors.
static WhenExpr parse(String source) => _Parser(source).parseAll();
/// Convenience: null on empty input, otherwise [parse].
static WhenExpr? tryParse(String? source) {
if (source == null || source.trim().isEmpty) return null;
return parse(source);
}
}
class WhenIdent extends WhenExpr {
const WhenIdent(this.name);
final String name;
@override
bool evaluate(Map<String, bool> context) => context[name] ?? false;
@override
String toString() => name;
}
class WhenNot extends WhenExpr {
const WhenNot(this.child);
final WhenExpr child;
@override
bool evaluate(Map<String, bool> context) => !child.evaluate(context);
@override
String toString() => '!$child';
}
class WhenAnd extends WhenExpr {
const WhenAnd(this.left, this.right);
final WhenExpr left;
final WhenExpr right;
@override
bool evaluate(Map<String, bool> context) => left.evaluate(context) && right.evaluate(context);
@override
String toString() => '($left && $right)';
}
class WhenOr extends WhenExpr {
const WhenOr(this.left, this.right);
final WhenExpr left;
final WhenExpr right;
@override
bool evaluate(Map<String, bool> context) => left.evaluate(context) || right.evaluate(context);
@override
String toString() => '($left || $right)';
}
// -- Parser -----------------------------------------------------------------
class _Parser {
_Parser(this._src);
final String _src;
int _pos = 0;
WhenExpr parseAll() {
_skip();
final e = _or();
_skip();
if (_pos != _src.length) {
throw FormatException('unexpected "${_src[_pos]}" at column ${_pos + 1} in when-clause: "$_src"');
}
return e;
}
WhenExpr _or() {
var left = _and();
while (_consume('||')) {
final right = _and();
left = WhenOr(left, right);
}
return left;
}
WhenExpr _and() {
var left = _unary();
while (_consume('&&')) {
final right = _unary();
left = WhenAnd(left, right);
}
return left;
}
WhenExpr _unary() {
_skip();
if (_consume('!')) {
return WhenNot(_unary());
}
return _atom();
}
WhenExpr _atom() {
_skip();
if (_consume('(')) {
final inner = _or();
_skip();
if (!_consume(')')) {
throw FormatException('expected ")" at column ${_pos + 1} in when-clause: "$_src"');
}
return inner;
}
final ident = _ident();
if (ident == null) {
final at = _pos < _src.length ? '"${_src[_pos]}"' : 'end of input';
throw FormatException('expected identifier at column ${_pos + 1} in when-clause: "$_src" (got $at)');
}
return WhenIdent(ident);
}
String? _ident() {
_skip();
final start = _pos;
if (_pos >= _src.length) return null;
final first = _src.codeUnitAt(_pos);
if (!_isIdentStart(first)) return null;
_pos++;
while (_pos < _src.length && _isIdentCont(_src.codeUnitAt(_pos))) {
_pos++;
}
return _src.substring(start, _pos);
}
bool _consume(String token) {
_skip();
if (_src.startsWith(token, _pos)) {
_pos += token.length;
return true;
}
return false;
}
void _skip() {
while (_pos < _src.length && _isSpace(_src.codeUnitAt(_pos))) {
_pos++;
}
}
}
bool _isSpace(int c) => c == 0x20 || c == 0x09 || c == 0x0A || c == 0x0D;
bool _isIdentStart(int c) {
// a-z | A-Z | _
return (c >= 0x61 && c <= 0x7A) || (c >= 0x41 && c <= 0x5A) || c == 0x5F;
}
bool _isIdentCont(int c) {
// a-z | A-Z | 0-9 | _ . -
return _isIdentStart(c) || (c >= 0x30 && c <= 0x39) || c == 0x2E || c == 0x2D;
}