keymap: support bare-modifier double-tap chords; double-Shift → quick-open (T-341)

The chord matcher couldn't represent a bare or double-tapped modifier:
KeyChord.parse required a base key, so `shift shift` failed, and JetBrains
"Search Everywhere" (double-Shift) was unbindable.

Design decision: search-everywhere aliases clide's existing quick-open
finder (not a new overlay) — bound across all four presets per the user.

Changes:
- KeyChord: a bare modifier name (`shift`, `ctrl`, `cmd`, …) parses as a
  modifier-free chord on that modifier's logical key, so parseSequence(
  'shift shift') yields a two-chord double-tap. Adds KeyChord.bareModifier
  and modifierForLogicalKey.
- ModifierTapTracker: headless, clock-injected double-tap detector. A bare
  modifier never forms a single chord; an intervening key breaks the gesture.
- app.dart global handler feeds bare-modifier KeyDowns to the tracker and,
  on a double-tap, resolves the 2-chord sequence via the new
  KeymapService.resolveSequence. The existing single-chord path is untouched
  (zero behavioural risk to normal keys).
- Presets: default/vim/vscode/jetbrains add `shift shift` → quickOpen.open.
  jetbrains header updated (the gesture is now expressible).

Tests: bare-modifier parse/equality/round-trip; tracker window/reset/
different-modifier/consume; each shipped preset resolves double-Shift to
QuickOpenIntent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 13:26:54 +02:00
co-authored by Claude Opus 4.8
parent c1b78d2845
commit 430189d714
15 changed files with 312 additions and 12 deletions
@@ -135,4 +135,51 @@ void main() {
expect(chord.canonical, 'ctrl+alt+shift+meta+p');
});
});
group('KeyChord bare modifier (T-341)', () {
test('parse(modifier-name) yields a modifier-free chord on the modifier key', () {
final c = KeyChord.parse('shift');
expect(c.modifiers, isEmpty);
expect(c.key, LogicalKeyboardKey.shift);
expect(c.canonical, 'shift');
});
test('every modifier name (and its aliases) parses as a bare key', () {
expect(KeyChord.parse('ctrl').key, LogicalKeyboardKey.control);
expect(KeyChord.parse('control').key, LogicalKeyboardKey.control);
expect(KeyChord.parse('alt').key, LogicalKeyboardKey.alt);
expect(KeyChord.parse('option').key, LogicalKeyboardKey.alt);
expect(KeyChord.parse('meta').key, LogicalKeyboardKey.meta);
expect(KeyChord.parse('cmd').key, LogicalKeyboardKey.meta);
});
test('KeyChord.bareModifier equals the parsed form (lookup key for the double-tap)', () {
expect(KeyChord.bareModifier(KeyModifier.shift), KeyChord.parse('shift'));
expect(KeyChord.bareModifier(KeyModifier.meta), KeyChord.parse('cmd'));
});
test("parseSequence('shift shift') is a two-chord double-tap", () {
final seq = KeyChord.parseSequence('shift shift');
expect(seq, hasLength(2));
expect(seq[0], KeyChord.bareModifier(KeyModifier.shift));
expect(seq[1], KeyChord.bareModifier(KeyModifier.shift));
});
test('canonical round-trips through parse', () {
for (final m in KeyModifier.values) {
final c = KeyChord.bareModifier(m);
expect(KeyChord.parse(c.canonical), c, reason: m.name);
}
});
test('modifierForLogicalKey collapses left/right/generic variants', () {
expect(KeyChord.modifierForLogicalKey(LogicalKeyboardKey.shiftLeft), KeyModifier.shift);
expect(KeyChord.modifierForLogicalKey(LogicalKeyboardKey.shiftRight), KeyModifier.shift);
expect(KeyChord.modifierForLogicalKey(LogicalKeyboardKey.shift), KeyModifier.shift);
expect(KeyChord.modifierForLogicalKey(LogicalKeyboardKey.controlLeft), KeyModifier.ctrl);
expect(KeyChord.modifierForLogicalKey(LogicalKeyboardKey.altRight), KeyModifier.alt);
expect(KeyChord.modifierForLogicalKey(LogicalKeyboardKey.metaLeft), KeyModifier.meta);
expect(KeyChord.modifierForLogicalKey(LogicalKeyboardKey.keyA), isNull);
});
});
}
@@ -0,0 +1,56 @@
/// Unit tests for ModifierTapTracker — double-tapped bare-modifier
/// detection (T-341).
library;
import 'package:clide/kernel/src/keymap/key_chord.dart';
import 'package:clide/kernel/src/keymap/modifier_tap.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
// A fixed base instant; offsets are in milliseconds. (Date.now-free so the
// test is deterministic.)
final t0 = DateTime(2026, 1, 1, 12);
DateTime at(int ms) => t0.add(Duration(milliseconds: ms));
group('ModifierTapTracker', () {
test('two taps of the same modifier within the window fire', () {
final t = ModifierTapTracker(window: const Duration(milliseconds: 350));
expect(t.tap(KeyModifier.shift, at(0)), isNull); // first tap arms
expect(t.tap(KeyModifier.shift, at(200)), KeyModifier.shift); // double-tap
});
test('the second tap just outside the window does not fire', () {
final t = ModifierTapTracker(window: const Duration(milliseconds: 350));
expect(t.tap(KeyModifier.shift, at(0)), isNull);
expect(t.tap(KeyModifier.shift, at(400)), isNull); // too slow
});
test('a slow second tap re-arms, so a prompt third tap fires', () {
final t = ModifierTapTracker(window: const Duration(milliseconds: 350));
expect(t.tap(KeyModifier.shift, at(0)), isNull);
expect(t.tap(KeyModifier.shift, at(500)), isNull); // re-arms from here
expect(t.tap(KeyModifier.shift, at(600)), KeyModifier.shift);
});
test('different modifiers never form a double-tap', () {
final t = ModifierTapTracker();
expect(t.tap(KeyModifier.shift, at(0)), isNull);
expect(t.tap(KeyModifier.ctrl, at(100)), isNull); // ctrl != shift
});
test('an intervening key (reset) breaks the gesture', () {
final t = ModifierTapTracker();
expect(t.tap(KeyModifier.shift, at(0)), isNull);
t.reset(); // e.g. a letter was pressed: Shift a Shift
expect(t.tap(KeyModifier.shift, at(100)), isNull);
});
test('firing consumes the pair — a third tap re-arms, not re-fires', () {
final t = ModifierTapTracker();
expect(t.tap(KeyModifier.shift, at(0)), isNull);
expect(t.tap(KeyModifier.shift, at(100)), KeyModifier.shift); // fires + resets
expect(t.tap(KeyModifier.shift, at(150)), isNull); // back to arming
expect(t.tap(KeyModifier.shift, at(200)), KeyModifier.shift);
});
});
}
@@ -16,6 +16,7 @@ library;
import 'dart:io';
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:flutter/widgets.dart' show NextFocusIntent, PreviousFocusIntent;
import 'package:flutter_test/flutter_test.dart';
@@ -49,4 +50,16 @@ void main() {
expect(layer.bindings.any((b) => b.intent is NextFocusIntent), isTrue);
expect(layer.bindings.any((b) => b.intent is PreviousFocusIntent), isTrue);
});
// Every shipped preset aliases the double-Shift "Search Everywhere"
// gesture to quick-open (T-341).
final doubleShift = [KeyChord.bareModifier(KeyModifier.shift), KeyChord.bareModifier(KeyModifier.shift)];
for (final file in presets) {
final name = file.uri.pathSegments.last;
test('$name resolves `shift shift` (double-tap) to quick-open', () {
final km = Keymap([KeymapLayer.fromYaml(file.readAsStringSync())]);
final m = km.match(doubleShift, const {});
expect(m.exact, isA<QuickOpenIntent>(), reason: '$name should bind double-Shift to quickOpen.open');
});
}
}