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:
2026-06-01 21:11:59 +02:00
co-authored by Claude Opus 4.8
parent ff85e12895
commit ba304a95e6
7 changed files with 425 additions and 9 deletions
+1
View File
@@ -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';
+34
View File
@@ -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 09 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) {
+73 -7
View File
@@ -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);
+1 -1
View File
@@ -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),
));
+141
View File
@@ -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);
});
});
}