Files
clide/lib/kernel/src/keymap/when_clause.dart
T
jpmschweitzerandClaude Opus 4.7 798ba524f1 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>
2026-05-17 21:40:02 +02:00

179 lines
4.5 KiB
Dart

/// 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;
}