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>
214 lines
8.6 KiB
Dart
214 lines
8.6 KiB
Dart
/// 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)})';
|
|
}
|