add keymap multi-chord sequences and a stateful matcher
T-205, the resolver foundation for Vim motions (dd, gg, dw, ciw) and repeat counts (5j). KeymapBinding now holds an ordered chord sequence (length 1 for the common single-chord case); `keys:` parses a space- separated spec into that sequence (D-82). Keymap.resolve keeps the single-chord fast path; a new stateless Keymap.match answers exact/prefix/none for a pending buffer. SequenceMatcher wraps that query with a pending buffer, a repeat-count prefix (leading digits, 0 excluded since it's the line-start motion), the d-vs-dd timeout case (flush fires the buffered exact), and broken- sequence recovery (discard, restart on the last chord). It is headless — no keyboard reads, no event swallowing — so the editor (T-206) can drive it from Focus.onKeyEvent and act on the result. Also drops a stray unused import in the Vim indicator test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,7 @@ export 'src/keymap/intents.dart';
|
||||
export 'src/keymap/key_chord.dart';
|
||||
export 'src/keymap/keymap.dart';
|
||||
export 'src/keymap/keymap_service.dart';
|
||||
export 'src/keymap/sequence_matcher.dart';
|
||||
export 'src/keymap/when_clause.dart';
|
||||
export 'src/dialog.dart';
|
||||
export 'src/extensions_manager.dart';
|
||||
|
||||
@@ -83,6 +83,27 @@ class KeyChord {
|
||||
return KeyChord(modifiers: mods, key: key);
|
||||
}
|
||||
|
||||
/// Parse a sequence spec — one or more chords separated by whitespace,
|
||||
/// e.g. `d d`, `g g`, `ctrl+k ctrl+s`. A single chord yields a
|
||||
/// one-element list. Whitespace means "then" (D-82); the space *key*
|
||||
/// is always spelled `space`, so a literal space never collides.
|
||||
/// Throws [FormatException] on empty input or an unknown chord.
|
||||
static List<KeyChord> parseSequence(String spec) {
|
||||
final parts = spec.trim().split(RegExp(r'\s+')).where((s) => s.isNotEmpty).toList();
|
||||
if (parts.isEmpty) throw FormatException('empty key sequence: "$spec"');
|
||||
return [for (final p in parts) KeyChord.parse(p)];
|
||||
}
|
||||
|
||||
/// The digit 0–9 if this is a bare digit key with no modifiers, else
|
||||
/// null. Used to capture Vim repeat-count prefixes (`5j`).
|
||||
int? get digit {
|
||||
if (modifiers.isNotEmpty) return null;
|
||||
for (var d = 0; d <= 9; d++) {
|
||||
if (key == _digitKeys[d]) return d;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Canonical YAML form: `ctrl+shift+p`.
|
||||
String get canonical {
|
||||
final modPart = modifiers.map((m) => m.yaml).join('+');
|
||||
@@ -201,6 +222,19 @@ const Map<String, LogicalKeyboardKey> _byName = {
|
||||
'backquote': LogicalKeyboardKey.backquote, '`': LogicalKeyboardKey.backquote,
|
||||
};
|
||||
|
||||
const List<LogicalKeyboardKey> _digitKeys = [
|
||||
LogicalKeyboardKey.digit0,
|
||||
LogicalKeyboardKey.digit1,
|
||||
LogicalKeyboardKey.digit2,
|
||||
LogicalKeyboardKey.digit3,
|
||||
LogicalKeyboardKey.digit4,
|
||||
LogicalKeyboardKey.digit5,
|
||||
LogicalKeyboardKey.digit6,
|
||||
LogicalKeyboardKey.digit7,
|
||||
LogicalKeyboardKey.digit8,
|
||||
LogicalKeyboardKey.digit9,
|
||||
];
|
||||
|
||||
LogicalKeyboardKey? _keyByName(String name) => _byName[name.toLowerCase()];
|
||||
|
||||
String _keyName(LogicalKeyboardKey key) {
|
||||
|
||||
@@ -18,22 +18,52 @@ 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.
|
||||
/// One row in a layer: an ordered chord [sequence] (length 1 for the
|
||||
/// common single-chord binding), an optional when-clause, and the intent
|
||||
/// to fire when the sequence matches and the when-clause is true.
|
||||
@immutable
|
||||
class KeymapBinding {
|
||||
const KeymapBinding({
|
||||
required this.chord,
|
||||
required this.sequence,
|
||||
required this.intent,
|
||||
this.when,
|
||||
});
|
||||
|
||||
final KeyChord chord;
|
||||
/// Convenience constructor for a single-chord binding.
|
||||
KeymapBinding.chord(KeyChord chord, {required this.intent, this.when}) : sequence = [chord];
|
||||
|
||||
final List<KeyChord> sequence;
|
||||
final Intent intent;
|
||||
final WhenExpr? when;
|
||||
|
||||
/// The first (often only) chord. Kept for single-chord call sites and
|
||||
/// debug/hint surfaces.
|
||||
KeyChord get chord => sequence.first;
|
||||
|
||||
/// Whether this binding requires more than one chord (D-82).
|
||||
bool get isSequence => sequence.length > 1;
|
||||
|
||||
@override
|
||||
String toString() => 'Binding($chord → ${intent.runtimeType}${when == null ? '' : ' when $when'})';
|
||||
String toString() {
|
||||
final keys = sequence.map((c) => c.canonical).join(' ');
|
||||
return 'Binding($keys → ${intent.runtimeType}${when == null ? '' : ' when $when'})';
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of matching a pending chord buffer against a [Keymap] (D-82).
|
||||
/// [exact] is the highest-precedence binding whose full sequence equals
|
||||
/// the buffer (and whose when-clause passes); [isPrefix] is true when
|
||||
/// some binding's sequence strictly extends the buffer, so more input
|
||||
/// could still complete a longer match.
|
||||
@immutable
|
||||
class SequenceMatch {
|
||||
const SequenceMatch({this.exact, required this.isPrefix});
|
||||
|
||||
final Intent? exact;
|
||||
final bool isPrefix;
|
||||
|
||||
/// No binding matches the buffer exactly and none extends it.
|
||||
bool get none => exact == null && !isPrefix;
|
||||
}
|
||||
|
||||
/// One source of bindings. Layers are merged in order — later layers
|
||||
@@ -96,8 +126,10 @@ class KeymapLayer {
|
||||
throw FormatException('binding missing `keys:` (string or list of strings) — $entry');
|
||||
}
|
||||
final when = WhenExpr.tryParse(entry['when'] as String?);
|
||||
// A YAML list alternates (any element fires); each element may
|
||||
// itself be a space-separated sequence (D-82).
|
||||
for (final spec in keySpecs) {
|
||||
out.add(KeymapBinding(chord: KeyChord.parse(spec), intent: intent, when: when));
|
||||
out.add(KeymapBinding(sequence: KeyChord.parseSequence(spec), intent: intent, when: when));
|
||||
}
|
||||
}
|
||||
return KeymapLayer(name: name, bindings: out);
|
||||
@@ -120,14 +152,48 @@ class Keymap {
|
||||
/// clause (if any) evaluates true. Returns null if no match.
|
||||
Intent? resolve(KeyChord chord, Map<String, bool> context) {
|
||||
// Effective list is highest-precedence-first; first match wins.
|
||||
// Single-chord fast path — multi-chord bindings only fire through
|
||||
// [match] / a SequenceMatcher (D-82).
|
||||
for (final b in _effective) {
|
||||
if (b.chord != chord) continue;
|
||||
if (b.isSequence || b.chord != chord) continue;
|
||||
if (b.when != null && !b.when!.evaluate(context)) continue;
|
||||
return b.intent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Match a pending chord [buffer] against every binding (single or
|
||||
/// sequence), respecting when-clauses and precedence. See [SequenceMatch].
|
||||
SequenceMatch match(List<KeyChord> buffer, Map<String, bool> context) {
|
||||
if (buffer.isEmpty) return const SequenceMatch(isPrefix: false);
|
||||
Intent? exact;
|
||||
var isPrefix = false;
|
||||
for (final b in _effective) {
|
||||
if (b.when != null && !b.when!.evaluate(context)) continue;
|
||||
final seq = b.sequence;
|
||||
if (seq.length == buffer.length) {
|
||||
if (exact == null && _seqEquals(seq, buffer)) exact = b.intent;
|
||||
} else if (seq.length > buffer.length && _isPrefixOf(buffer, seq)) {
|
||||
isPrefix = true;
|
||||
}
|
||||
}
|
||||
return SequenceMatch(exact: exact, isPrefix: isPrefix);
|
||||
}
|
||||
|
||||
static bool _seqEquals(List<KeyChord> a, List<KeyChord> b) {
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
if (a[i] != b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool _isPrefixOf(List<KeyChord> prefix, List<KeyChord> full) {
|
||||
for (var i = 0; i < prefix.length; i++) {
|
||||
if (prefix[i] != full[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// All resolved bindings in effective-precedence order. Exposed for
|
||||
/// debug surfaces (keybindings UI, palette hints).
|
||||
List<KeymapBinding> get effectiveBindings => List.unmodifiable(_effective);
|
||||
|
||||
@@ -128,7 +128,7 @@ class KeymapService extends ChangeNotifier {
|
||||
/// settings overlay.
|
||||
void registerCommandBinding(String chordSpec, String commandId, {String? when}) {
|
||||
_contributions.add(KeymapBinding(
|
||||
chord: KeyChord.parse(chordSpec),
|
||||
sequence: KeyChord.parseSequence(chordSpec),
|
||||
intent: InvokeCommandIntent(commandId),
|
||||
when: WhenExpr.tryParse(when),
|
||||
));
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/// Stateful multi-chord matcher (D-82, T-205).
|
||||
///
|
||||
/// Wraps a [Keymap]'s stateless [Keymap.match] query with a pending-chord
|
||||
/// buffer, a Vim repeat-count prefix, and the prefix/timeout bookkeeping a
|
||||
/// real key handler needs. It is deliberately headless: it neither reads
|
||||
/// the keyboard nor swallows events. The consumer (the editor's
|
||||
/// `Focus.onKeyEvent`, T-206) feeds it chords and acts on the [SeqResult] —
|
||||
/// the global key handler can't, because it's a passive `KeyboardListener`
|
||||
/// that can't consume events (D-82).
|
||||
library;
|
||||
|
||||
import 'package:flutter/widgets.dart' show Intent, immutable;
|
||||
|
||||
import 'key_chord.dart';
|
||||
import 'keymap.dart';
|
||||
|
||||
enum SeqOutcome {
|
||||
/// A binding's full sequence matched — fire [SeqResult.intent],
|
||||
/// [SeqResult.count] times.
|
||||
fired,
|
||||
|
||||
/// The buffer is a live prefix of a longer binding (or a pure count so
|
||||
/// far). The consumer should swallow the key and wait for more input;
|
||||
/// start/refresh a timeout and call [SequenceMatcher.flush] when it
|
||||
/// expires.
|
||||
pending,
|
||||
|
||||
/// The buffer matches nothing. [SeqResult.passKey] is the lone key the
|
||||
/// consumer should let through to normal text handling (insert a char);
|
||||
/// null when a multi-chord sequence simply broke and is discarded.
|
||||
unmatched,
|
||||
}
|
||||
|
||||
@immutable
|
||||
class SeqResult {
|
||||
const SeqResult.fired(Intent this.intent, this.count)
|
||||
: outcome = SeqOutcome.fired,
|
||||
passKey = null;
|
||||
const SeqResult.pending()
|
||||
: outcome = SeqOutcome.pending,
|
||||
intent = null,
|
||||
count = 1,
|
||||
passKey = null;
|
||||
const SeqResult.unmatched(this.passKey)
|
||||
: outcome = SeqOutcome.unmatched,
|
||||
intent = null,
|
||||
count = 1;
|
||||
|
||||
final SeqOutcome outcome;
|
||||
final Intent? intent;
|
||||
final int count;
|
||||
final KeyChord? passKey;
|
||||
}
|
||||
|
||||
class SequenceMatcher {
|
||||
SequenceMatcher({
|
||||
required Keymap Function() keymap,
|
||||
required Map<String, bool> Function() context,
|
||||
this.captureCounts = true,
|
||||
}) : _keymap = keymap,
|
||||
_context = context;
|
||||
|
||||
final Keymap Function() _keymap;
|
||||
final Map<String, bool> Function() _context;
|
||||
|
||||
/// When true, a leading digit run (`5` in `5j`) is captured as a repeat
|
||||
/// count rather than treated as a key. `0` is never a leading count
|
||||
/// digit — in Vim it's the line-start motion.
|
||||
final bool captureCounts;
|
||||
|
||||
final List<KeyChord> _pending = [];
|
||||
int _count = 0;
|
||||
Intent? _pendingExact;
|
||||
|
||||
/// Number of chords buffered (excludes any count prefix).
|
||||
int get pendingLength => _pending.length;
|
||||
|
||||
/// The effective repeat count (>= 1).
|
||||
int get count => _count == 0 ? 1 : _count;
|
||||
|
||||
/// Whether any input is buffered (a count and/or a partial sequence).
|
||||
bool get hasPending => _pending.isNotEmpty || _count > 0;
|
||||
|
||||
void reset() {
|
||||
_pending.clear();
|
||||
_count = 0;
|
||||
_pendingExact = null;
|
||||
}
|
||||
|
||||
/// Feed the next chord. See [SeqOutcome] for how to act on the result.
|
||||
SeqResult feed(KeyChord chord) {
|
||||
// Count prefix — only between sequences, never mid-sequence.
|
||||
if (captureCounts && _pending.isEmpty) {
|
||||
final d = chord.digit;
|
||||
if (d != null && !(d == 0 && _count == 0)) {
|
||||
_count = _count * 10 + d;
|
||||
return const SeqResult.pending();
|
||||
}
|
||||
}
|
||||
|
||||
_pending.add(chord);
|
||||
final m = _keymap().match(_pending, _context());
|
||||
|
||||
if (m.exact != null && !m.isPrefix) {
|
||||
final r = SeqResult.fired(m.exact!, count);
|
||||
reset();
|
||||
return r;
|
||||
}
|
||||
if (m.isPrefix) {
|
||||
// A longer binding could still complete. If an exact match also
|
||||
// exists (`d` while `dd` is bound), stash it to fire on timeout.
|
||||
_pendingExact = m.exact;
|
||||
return const SeqResult.pending();
|
||||
}
|
||||
|
||||
// Nothing matches and nothing extends the buffer.
|
||||
if (_pending.length == 1) {
|
||||
final only = _pending.first;
|
||||
reset();
|
||||
return SeqResult.unmatched(only);
|
||||
}
|
||||
// A multi-chord sequence broke; discard it but let the last chord
|
||||
// start a fresh sequence (matches Vim's behaviour).
|
||||
final last = _pending.last;
|
||||
reset();
|
||||
return feed(last);
|
||||
}
|
||||
|
||||
/// Resolve a wait (timeout elapsed, focus lost, …). Fires a buffered
|
||||
/// exact match if one is pending (the `d`-vs-`dd` case); otherwise lets
|
||||
/// a lone buffered key through; otherwise a no-op.
|
||||
SeqResult flush() {
|
||||
final exact = _pendingExact;
|
||||
final c = count;
|
||||
final lone = _pending.length == 1 ? _pending.first : null;
|
||||
reset();
|
||||
if (exact != null) return SeqResult.fired(exact, c);
|
||||
if (lone != null) return SeqResult.unmatched(lone);
|
||||
return const SeqResult.pending();
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/vim/vim.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
/// T-205 / D-82: multi-chord sequences, the stateless [Keymap.match]
|
||||
/// query, and the stateful [SequenceMatcher] (prefix buffering, repeat
|
||||
/// counts, the d-vs-dd timeout case, and broken-sequence recovery).
|
||||
library;
|
||||
|
||||
import 'package:clide/kernel/src/keymap/intents.dart';
|
||||
import 'package:clide/kernel/src/keymap/key_chord.dart';
|
||||
import 'package:clide/kernel/src/keymap/keymap.dart';
|
||||
import 'package:clide/kernel/src/keymap/sequence_matcher.dart';
|
||||
import 'package:flutter/widgets.dart' show Intent;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
const _yaml = '''
|
||||
name: t
|
||||
bindings:
|
||||
- intent: command:del.pending
|
||||
keys: d
|
||||
- intent: command:del.line
|
||||
keys: 'd d'
|
||||
- intent: command:del.word
|
||||
keys: 'd w'
|
||||
- intent: command:goto.top
|
||||
keys: 'g g'
|
||||
- intent: command:down
|
||||
keys: j
|
||||
- intent: command:line.start
|
||||
keys: '0'
|
||||
- intent: command:del.char
|
||||
keys: x
|
||||
- intent: command:open
|
||||
keys: [ctrl+p, meta+p]
|
||||
''';
|
||||
|
||||
String? _cmd(Intent? i) => i is InvokeCommandIntent ? i.commandId : null;
|
||||
|
||||
void main() {
|
||||
group('KeyChord.parseSequence', () {
|
||||
test('single chord → one element', () {
|
||||
expect(KeyChord.parseSequence('escape'), hasLength(1));
|
||||
});
|
||||
test('space separates a sequence', () {
|
||||
final seq = KeyChord.parseSequence('d d');
|
||||
expect(seq, hasLength(2));
|
||||
expect(seq[0], KeyChord.parse('d'));
|
||||
expect(seq[1], KeyChord.parse('d'));
|
||||
});
|
||||
test('chorded sequence (ctrl+k ctrl+s)', () {
|
||||
final seq = KeyChord.parseSequence('ctrl+k ctrl+s');
|
||||
expect(seq, hasLength(2));
|
||||
expect(seq[1], KeyChord.parse('ctrl+s'));
|
||||
});
|
||||
test('empty throws', () {
|
||||
expect(() => KeyChord.parseSequence(' '), throwsFormatException);
|
||||
});
|
||||
});
|
||||
|
||||
group('KeyChord.digit', () {
|
||||
test('bare digit', () => expect(KeyChord.parse('5').digit, 5));
|
||||
test('letter is not a digit', () => expect(KeyChord.parse('j').digit, isNull));
|
||||
test('modified digit is not a count digit', () => expect(KeyChord.parse('ctrl+5').digit, isNull));
|
||||
});
|
||||
|
||||
group('Keymap.match', () {
|
||||
late Keymap km;
|
||||
setUp(() => km = Keymap([KeymapLayer.fromYaml(_yaml)]));
|
||||
|
||||
test('lone d is both an exact (del.pending) and a prefix (dd/dw)', () {
|
||||
final m = km.match([KeyChord.parse('d')], const {});
|
||||
expect(_cmd(m.exact), 'del.pending');
|
||||
expect(m.isPrefix, isTrue);
|
||||
});
|
||||
test('d d is an exact, not a prefix', () {
|
||||
final m = km.match(KeyChord.parseSequence('d d'), const {});
|
||||
expect(_cmd(m.exact), 'del.line');
|
||||
expect(m.isPrefix, isFalse);
|
||||
});
|
||||
test('g is a pure prefix (no single-g binding)', () {
|
||||
final m = km.match([KeyChord.parse('g')], const {});
|
||||
expect(m.exact, isNull);
|
||||
expect(m.isPrefix, isTrue);
|
||||
});
|
||||
test('unbound key matches nothing', () {
|
||||
final m = km.match([KeyChord.parse('z')], const {});
|
||||
expect(m.none, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('SequenceMatcher', () {
|
||||
late SequenceMatcher matcher;
|
||||
setUp(() {
|
||||
final km = Keymap([KeymapLayer.fromYaml(_yaml)]);
|
||||
matcher = SequenceMatcher(keymap: () => km, context: () => const {});
|
||||
});
|
||||
|
||||
SeqResult feed(String spec) => matcher.feed(KeyChord.parse(spec));
|
||||
|
||||
test('completes d d → del.line', () {
|
||||
expect(feed('d').outcome, SeqOutcome.pending);
|
||||
final r = feed('d');
|
||||
expect(r.outcome, SeqOutcome.fired);
|
||||
expect(_cmd(r.intent), 'del.line');
|
||||
expect(r.count, 1);
|
||||
});
|
||||
|
||||
test('completes d w → del.word', () {
|
||||
feed('d');
|
||||
expect(_cmd(feed('w').intent), 'del.word');
|
||||
});
|
||||
|
||||
test('g g → goto.top', () {
|
||||
expect(feed('g').outcome, SeqOutcome.pending);
|
||||
expect(_cmd(feed('g').intent), 'goto.top');
|
||||
});
|
||||
|
||||
test('single-chord binding fires immediately', () {
|
||||
final r = feed('j');
|
||||
expect(r.outcome, SeqOutcome.fired);
|
||||
expect(_cmd(r.intent), 'down');
|
||||
});
|
||||
|
||||
test('repeat count: 5 j → down ×5', () {
|
||||
expect(feed('5').outcome, SeqOutcome.pending);
|
||||
final r = feed('j');
|
||||
expect(r.outcome, SeqOutcome.fired);
|
||||
expect(_cmd(r.intent), 'down');
|
||||
expect(r.count, 5);
|
||||
});
|
||||
|
||||
test('multi-digit count accumulates', () {
|
||||
feed('1');
|
||||
feed('2');
|
||||
expect(feed('j').count, 12);
|
||||
});
|
||||
|
||||
test('leading 0 is the line-start motion, not a count', () {
|
||||
final r = feed('0');
|
||||
expect(r.outcome, SeqOutcome.fired);
|
||||
expect(_cmd(r.intent), 'line.start');
|
||||
expect(r.count, 1);
|
||||
});
|
||||
|
||||
test('unbound key passes through for insertion', () {
|
||||
final r = feed('z');
|
||||
expect(r.outcome, SeqOutcome.unmatched);
|
||||
expect(r.passKey, KeyChord.parse('z'));
|
||||
});
|
||||
|
||||
test('d-then-flush fires the pending single-d binding', () {
|
||||
expect(feed('d').outcome, SeqOutcome.pending);
|
||||
final r = matcher.flush();
|
||||
expect(r.outcome, SeqOutcome.fired);
|
||||
expect(_cmd(r.intent), 'del.pending');
|
||||
});
|
||||
|
||||
test('broken sequence restarts on the last chord', () {
|
||||
feed('d'); // pending (prefix of dd/dw)
|
||||
final r = feed('x'); // d x matches nothing → discard, retry x
|
||||
expect(r.outcome, SeqOutcome.fired);
|
||||
expect(_cmd(r.intent), 'del.char');
|
||||
});
|
||||
|
||||
test('reset clears pending and count', () {
|
||||
feed('5');
|
||||
feed('d');
|
||||
expect(matcher.hasPending, isTrue);
|
||||
matcher.reset();
|
||||
expect(matcher.hasPending, isFalse);
|
||||
expect(matcher.pendingLength, 0);
|
||||
});
|
||||
|
||||
test('flush with nothing buffered is a no-op', () {
|
||||
expect(matcher.flush().outcome, SeqOutcome.pending);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user