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:
@@ -14,6 +14,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';
|
||||
@@ -40,6 +41,7 @@ class ExtensionManager extends ChangeNotifier {
|
||||
required this.commands,
|
||||
required this.palette,
|
||||
required this.keybindings,
|
||||
required this.keymap,
|
||||
required this.clipboard,
|
||||
required this.files,
|
||||
required this.notify,
|
||||
@@ -64,6 +66,7 @@ class ExtensionManager extends ChangeNotifier {
|
||||
final CommandRegistry commands;
|
||||
final PaletteController palette;
|
||||
final KeybindingResolver keybindings;
|
||||
final KeymapService keymap;
|
||||
final ClideClipboard clipboard;
|
||||
final FileServices files;
|
||||
final Notifications notify;
|
||||
@@ -171,7 +174,11 @@ class ExtensionManager extends ChangeNotifier {
|
||||
commands.register(cmd);
|
||||
final binding = cmd.defaultBinding;
|
||||
if (binding != null) {
|
||||
// Legacy KeybindingResolver still wired for back-compat
|
||||
// until all callers migrate; the keymap layer is the
|
||||
// canonical home for chord → command bindings (T-117).
|
||||
keybindings.bind(Keybinding.parse(binding), cmd.command);
|
||||
keymap.registerCommandBinding(binding, cmd.command);
|
||||
}
|
||||
case TrayItemContribution t:
|
||||
tray.add(t);
|
||||
@@ -194,6 +201,7 @@ class ExtensionManager extends ChangeNotifier {
|
||||
if (binding != null) {
|
||||
keybindings.unbind(Keybinding.parse(binding));
|
||||
}
|
||||
keymap.unregisterCommandBindings(cmd.command);
|
||||
case TrayItemContribution t:
|
||||
tray.remove(t.id);
|
||||
case LayoutPresetContribution _:
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'dart:io';
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/src/clipboard.dart';
|
||||
import 'package:clide/kernel/src/commands/keybindings.dart';
|
||||
import 'package:clide/kernel/src/keymap/keymap_service.dart';
|
||||
import 'package:clide/kernel/src/commands/palette.dart';
|
||||
import 'package:clide/kernel/src/commands/registry.dart';
|
||||
import 'package:clide/kernel/src/dialog.dart';
|
||||
@@ -63,6 +64,7 @@ class KernelServices {
|
||||
required this.window,
|
||||
required this.toolchain,
|
||||
required this.scheduler,
|
||||
required this.keymap,
|
||||
});
|
||||
|
||||
final Logger log;
|
||||
@@ -91,6 +93,7 @@ class KernelServices {
|
||||
final WindowControls window;
|
||||
final Toolchain toolchain;
|
||||
final SchedulerService scheduler;
|
||||
final KeymapService keymap;
|
||||
|
||||
static Future<KernelServices> boot({
|
||||
required Directory appDir,
|
||||
@@ -132,6 +135,8 @@ class KernelServices {
|
||||
final arrangement = LayoutArrangement();
|
||||
final commands = CommandRegistry();
|
||||
final keybindings = KeybindingResolver();
|
||||
final keymap = KeymapService(settings: settings, appDir: appDir);
|
||||
await keymap.load();
|
||||
final palette = PaletteController(commands);
|
||||
final clipboard = ClideClipboard();
|
||||
final files = FileServices(events);
|
||||
@@ -174,6 +179,7 @@ class KernelServices {
|
||||
commands: commands,
|
||||
palette: palette,
|
||||
keybindings: keybindings,
|
||||
keymap: keymap,
|
||||
clipboard: clipboard,
|
||||
files: files,
|
||||
notify: notify,
|
||||
@@ -218,6 +224,7 @@ class KernelServices {
|
||||
window: window,
|
||||
toolchain: tc,
|
||||
scheduler: scheduler,
|
||||
keymap: keymap,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -239,6 +246,7 @@ class KernelServices {
|
||||
project.dispose();
|
||||
extensions.dispose();
|
||||
await scheduler.dispose();
|
||||
keymap.dispose();
|
||||
await log.dispose();
|
||||
messages.dispose();
|
||||
await events.dispose();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)})';
|
||||
}
|
||||
@@ -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(' < ')})';
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user