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,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