require a clean release for double-tap modifier detection (T-409)

Typing Shift+; opened quick-open instead of a colon. Two flaws in the
T-341 detector: it counted a tap on the Shift keydown (so a chorded
press could complete the gesture before the chord key arrived), and it
relied on the chorded key bubbling to the root KeyboardListener to
break the gesture — but a focused editor or text field consumes that
event, so the tracker never saw it.

The tracker now models press/release: a tap is a press with no other
key going down while the modifier is held, and the gesture fires on
the second clean release. The root shell feeds it from a
HardwareKeyboard handler, which observes every event before focus
dispatch regardless of who consumes it, and treats a modifier pressed
while a non-modifier is already held as a chord.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 08:49:20 +02:00
co-authored by Claude Fable 5
parent 70d71f2a3a
commit 8966a159db
7 changed files with 264 additions and 59 deletions
+46 -23
View File
@@ -1,10 +1,16 @@
/// Detects a double-tapped bare modifier (e.g. JetBrains "Search
/// Everywhere" = double-Shift). (T-341)
///
/// Headless and clock-injected: the caller (the global key handler) passes
/// the event time so it neither reads a clock nor consumes events. Feed it
/// every [KeyDownEvent]: a bare modifier press via [tap], any other key via
/// [reset] (an intervening key breaks the gesture, e.g. `Shift a Shift`).
/// A "tap" is a clean press-and-release: no other key may go down while the
/// modifier is held, otherwise the press was a chord (`Shift+;` typing a
/// colon) and must not count (T-409). The gesture therefore completes on the
/// second clean *release*, never on a key-down — at down time it's unknowable
/// whether the press will stay bare.
///
/// Headless and clock-injected: the caller (the root shell's raw-keyboard
/// handler) passes the event time so it neither reads a clock nor consumes
/// events. Feed every [KeyDownEvent] to [down] and every [KeyUpEvent] to
/// [up], passing the event's [KeyModifier] (null for non-modifier keys).
library;
import 'key_chord.dart';
@@ -12,33 +18,50 @@ import 'key_chord.dart';
class ModifierTapTracker {
ModifierTapTracker({this.window = const Duration(milliseconds: 350)});
/// Max gap between the two taps to count as a double-tap.
/// Max gap between the two tap releases to count as a double-tap.
final Duration window;
KeyModifier? _last;
DateTime? _lastAt;
/// Modifier currently held whose press is still bare (no chorded key yet).
KeyModifier? _pressing;
/// Record a bare-modifier press at [now]. Returns the modifier when this
/// press completes a double-tap of the *same* modifier within [window];
/// otherwise records it as the first tap and returns null.
KeyModifier? tap(KeyModifier m, DateTime now) {
final last = _last;
final lastAt = _lastAt;
if (last == m && lastAt != null) {
final gap = now.difference(lastAt);
/// Modifier of the last completed clean tap, arming the double-tap.
KeyModifier? _armed;
DateTime? _armedAt;
/// Record a key press. A non-modifier key ([mod] == null) — or any key
/// landing while a modifier is already held — is a chord: it dirties the
/// held press and breaks the armed gesture.
void down(KeyModifier? mod) {
if (mod == null || _pressing != null) {
_pressing = null;
_disarm();
return;
}
_pressing = mod;
}
/// Record a key release at [now]. Returns the modifier when this release
/// completes a double-tap: the second clean tap of the *same* modifier
/// within [window] of the first tap's release.
KeyModifier? up(KeyModifier? mod, DateTime now) {
if (mod == null) return null;
final pressing = _pressing;
_pressing = null;
if (pressing != mod) return null; // press went dirty (chorded) or stale
if (_armed == mod && _armedAt != null) {
final gap = now.difference(_armedAt!);
if (gap >= Duration.zero && gap <= window) {
reset();
return m;
_disarm();
return mod;
}
}
_last = m;
_lastAt = now;
_armed = mod;
_armedAt = now;
return null;
}
/// Break the gesture — any non-modifier key press resets the tracker.
void reset() {
_last = null;
_lastAt = null;
void _disarm() {
_armed = null;
_armedAt = null;
}
}