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
+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 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) {