From ba304a95e6613d0a1ebadbb3983417c7817ff675 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 1 Jun 2026 21:11:59 +0200 Subject: [PATCH] add keymap multi-chord sequences and a stateful matcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- lib/kernel/kernel.dart | 1 + lib/kernel/src/keymap/key_chord.dart | 34 ++++ lib/kernel/src/keymap/keymap.dart | 80 +++++++- lib/kernel/src/keymap/keymap_service.dart | 2 +- lib/kernel/src/keymap/sequence_matcher.dart | 141 ++++++++++++++ test/builtin/vim/vim_mode_indicator_test.dart | 1 - .../src/keymap/sequence_matcher_test.dart | 175 ++++++++++++++++++ 7 files changed, 425 insertions(+), 9 deletions(-) create mode 100644 lib/kernel/src/keymap/sequence_matcher.dart create mode 100644 test/kernel/src/keymap/sequence_matcher_test.dart diff --git a/lib/kernel/kernel.dart b/lib/kernel/kernel.dart index e59a8cf3..cb4fe8ba 100644 --- a/lib/kernel/kernel.dart +++ b/lib/kernel/kernel.dart @@ -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'; diff --git a/lib/kernel/src/keymap/key_chord.dart b/lib/kernel/src/keymap/key_chord.dart index d8170546..9d320402 100644 --- a/lib/kernel/src/keymap/key_chord.dart +++ b/lib/kernel/src/keymap/key_chord.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 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 _byName = { 'backquote': LogicalKeyboardKey.backquote, '`': LogicalKeyboardKey.backquote, }; +const List _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) { diff --git a/lib/kernel/src/keymap/keymap.dart b/lib/kernel/src/keymap/keymap.dart index cdc565f3..7e06f9e9 100644 --- a/lib/kernel/src/keymap/keymap.dart +++ b/lib/kernel/src/keymap/keymap.dart @@ -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 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 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 buffer, Map 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 a, List b) { + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } + + static bool _isPrefixOf(List prefix, List 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 get effectiveBindings => List.unmodifiable(_effective); diff --git a/lib/kernel/src/keymap/keymap_service.dart b/lib/kernel/src/keymap/keymap_service.dart index a2272018..ac1f1a32 100644 --- a/lib/kernel/src/keymap/keymap_service.dart +++ b/lib/kernel/src/keymap/keymap_service.dart @@ -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), )); diff --git a/lib/kernel/src/keymap/sequence_matcher.dart b/lib/kernel/src/keymap/sequence_matcher.dart new file mode 100644 index 00000000..db5de36e --- /dev/null +++ b/lib/kernel/src/keymap/sequence_matcher.dart @@ -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 Function() context, + this.captureCounts = true, + }) : _keymap = keymap, + _context = context; + + final Keymap Function() _keymap; + final Map 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 _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(); + } +} diff --git a/test/builtin/vim/vim_mode_indicator_test.dart b/test/builtin/vim/vim_mode_indicator_test.dart index 1400bb44..55716bdb 100644 --- a/test/builtin/vim/vim_mode_indicator_test.dart +++ b/test/builtin/vim/vim_mode_indicator_test.dart @@ -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'; diff --git a/test/kernel/src/keymap/sequence_matcher_test.dart b/test/kernel/src/keymap/sequence_matcher_test.dart new file mode 100644 index 00000000..9fae0b63 --- /dev/null +++ b/test/kernel/src/keymap/sequence_matcher_test.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); + }); + }); +}