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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,10 @@ class RootShellState extends State<RootShell> {
|
||||
late final FocusNode _keyFocus;
|
||||
final MenuBarController _menuBar = MenuBarController();
|
||||
// Detects double-tapped bare modifiers (e.g. double-Shift → quick-open,
|
||||
// JetBrains "Search Everywhere"). Bare modifiers never resolve as a single
|
||||
// chord, so this is the only path that handles them (T-341).
|
||||
// JetBrains "Search Everywhere"). Fed from a HardwareKeyboard handler, not
|
||||
// the focus tree: a focused editor consumes the chorded key of `Shift+;`,
|
||||
// so the gesture must observe every event to know a press wasn't bare
|
||||
// (T-341, T-409).
|
||||
final ModifierTapTracker _modTap = ModifierTapTracker();
|
||||
|
||||
@override
|
||||
@@ -34,10 +36,12 @@ class RootShellState extends State<RootShell> {
|
||||
super.initState();
|
||||
_keyFocus = FocusNode()..requestFocus();
|
||||
widget.services.textZoom.addListener(_onZoom);
|
||||
HardwareKeyboard.instance.addHandler(_onRawKey);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
HardwareKeyboard.instance.removeHandler(_onRawKey);
|
||||
widget.services.textZoom.removeListener(_onZoom);
|
||||
_menuBar.dispose();
|
||||
_keyFocus.dispose();
|
||||
@@ -156,26 +160,36 @@ class RootShellState extends State<RootShell> {
|
||||
|
||||
void _onKey(KeyEvent event) {
|
||||
if (_handleMenuMnemonic(event)) return;
|
||||
// Double-tapped bare modifier (e.g. double-Shift → quick-open). Handle
|
||||
// it here because a bare modifier never forms a single chord — an
|
||||
// intervening non-modifier key breaks the gesture (T-341).
|
||||
if (event is KeyDownEvent) {
|
||||
final mod = KeyChord.modifierForLogicalKey(event.logicalKey);
|
||||
if (mod != null) {
|
||||
if (_modTap.tap(mod, DateTime.now()) != null) {
|
||||
final seq = [KeyChord.bareModifier(mod), KeyChord.bareModifier(mod)];
|
||||
final tapIntent = widget.services.keymap.resolveSequence(seq);
|
||||
if (tapIntent != null) _dispatchIntent(tapIntent);
|
||||
}
|
||||
return; // a bare modifier resolves nothing else
|
||||
}
|
||||
_modTap.reset();
|
||||
}
|
||||
final intent = widget.services.keymap.resolveEvent(event, HardwareKeyboard.instance);
|
||||
if (intent == null) return;
|
||||
_dispatchIntent(intent);
|
||||
}
|
||||
|
||||
/// Double-tapped bare modifier (e.g. double-Shift → quick-open). Observed
|
||||
/// at the HardwareKeyboard level — before focus dispatch and regardless of
|
||||
/// who consumes the event — so a chorded key the focused editor swallows
|
||||
/// (the `;` of `Shift+;`) still dirties the press (T-341, T-409). Fires on
|
||||
/// the second clean *release*; never consumes anything.
|
||||
bool _onRawKey(KeyEvent event) {
|
||||
if (event is KeyDownEvent) {
|
||||
var mod = KeyChord.modifierForLogicalKey(event.logicalKey);
|
||||
// A modifier pressed while a non-modifier is already held (rolled
|
||||
// `a`+Shift) is a chord, not a tap.
|
||||
if (mod != null && _nonModifierHeld()) mod = null;
|
||||
_modTap.down(mod);
|
||||
} else if (event is KeyUpEvent) {
|
||||
final mod = _modTap.up(KeyChord.modifierForLogicalKey(event.logicalKey), DateTime.now());
|
||||
if (mod != null) {
|
||||
final seq = [KeyChord.bareModifier(mod), KeyChord.bareModifier(mod)];
|
||||
final tapIntent = widget.services.keymap.resolveSequence(seq);
|
||||
if (tapIntent != null) _dispatchIntent(tapIntent);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _nonModifierHeld() => HardwareKeyboard.instance.logicalKeysPressed.any((k) => KeyChord.modifierForLogicalKey(k) == null);
|
||||
|
||||
void _dispatchIntent(Intent intent) {
|
||||
// Try the focused context first so feature widgets (palette, editor, …)
|
||||
// get a chance to handle their own intents; fall back to the app root's
|
||||
|
||||
Reference in New Issue
Block a user