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,5 +1,5 @@
|
||||
/// Unit tests for ModifierTapTracker — double-tapped bare-modifier
|
||||
/// detection (T-341).
|
||||
/// detection (T-341), clean-release semantics (T-409).
|
||||
library;
|
||||
|
||||
import 'package:clide/kernel/src/keymap/key_chord.dart';
|
||||
@@ -12,45 +12,97 @@ void main() {
|
||||
final t0 = DateTime(2026, 1, 1, 12);
|
||||
DateTime at(int ms) => t0.add(Duration(milliseconds: ms));
|
||||
|
||||
// A clean tap: bare press + release.
|
||||
KeyModifier? tap(ModifierTapTracker t, KeyModifier m, int ms) {
|
||||
t.down(m);
|
||||
return t.up(m, at(ms));
|
||||
}
|
||||
|
||||
group('ModifierTapTracker', () {
|
||||
test('two taps of the same modifier within the window fire', () {
|
||||
test('two clean 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
|
||||
expect(tap(t, KeyModifier.shift, 0), isNull); // first tap arms
|
||||
expect(tap(t, KeyModifier.shift, 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
|
||||
expect(tap(t, KeyModifier.shift, 0), isNull);
|
||||
expect(tap(t, KeyModifier.shift, 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);
|
||||
expect(tap(t, KeyModifier.shift, 0), isNull);
|
||||
expect(tap(t, KeyModifier.shift, 500), isNull); // re-arms from here
|
||||
expect(tap(t, KeyModifier.shift, 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
|
||||
expect(tap(t, KeyModifier.shift, 0), isNull);
|
||||
expect(tap(t, KeyModifier.ctrl, 100), isNull); // ctrl != shift
|
||||
});
|
||||
|
||||
test('an intervening key (reset) breaks the gesture', () {
|
||||
test('an intervening key between taps 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);
|
||||
expect(tap(t, KeyModifier.shift, 0), isNull);
|
||||
t.down(null); // a letter: Shift a Shift
|
||||
t.up(null, at(50));
|
||||
expect(tap(t, KeyModifier.shift, 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);
|
||||
expect(tap(t, KeyModifier.shift, 0), isNull);
|
||||
expect(tap(t, KeyModifier.shift, 100), KeyModifier.shift); // fires + resets
|
||||
expect(tap(t, KeyModifier.shift, 150), isNull); // back to arming
|
||||
expect(tap(t, KeyModifier.shift, 200), KeyModifier.shift);
|
||||
});
|
||||
|
||||
// T-409 regression: a chorded press (Shift+; typing a colon) is not a tap.
|
||||
test('a key chorded onto a held modifier dirties the press', () {
|
||||
final t = ModifierTapTracker();
|
||||
t.down(KeyModifier.shift);
|
||||
t.down(null); // `;` while Shift held — typing `:`
|
||||
t.up(null, at(50));
|
||||
expect(t.up(KeyModifier.shift, at(80)), isNull); // dirty press, no tap
|
||||
});
|
||||
|
||||
test('typing two colons rapidly never fires', () {
|
||||
final t = ModifierTapTracker();
|
||||
for (final base in [0, 120]) {
|
||||
t.down(KeyModifier.shift);
|
||||
t.down(null);
|
||||
t.up(null, at(base + 40));
|
||||
expect(t.up(KeyModifier.shift, at(base + 60)), isNull);
|
||||
}
|
||||
});
|
||||
|
||||
test('a chorded press also breaks an armed first tap', () {
|
||||
final t = ModifierTapTracker();
|
||||
expect(tap(t, KeyModifier.shift, 0), isNull); // clean tap arms
|
||||
t.down(KeyModifier.shift);
|
||||
t.down(null); // Shift+; — chord, must disarm
|
||||
t.up(null, at(40));
|
||||
expect(t.up(KeyModifier.shift, at(60)), isNull);
|
||||
// The next single clean tap re-arms but must not fire either.
|
||||
expect(tap(t, KeyModifier.shift, 100), isNull);
|
||||
});
|
||||
|
||||
test('a second modifier chorded onto the first is not a tap', () {
|
||||
final t = ModifierTapTracker();
|
||||
t.down(KeyModifier.shift);
|
||||
t.down(KeyModifier.ctrl); // ctrl while shift held
|
||||
expect(t.up(KeyModifier.ctrl, at(30)), isNull);
|
||||
expect(t.up(KeyModifier.shift, at(50)), isNull);
|
||||
});
|
||||
|
||||
test('a release without a tracked press is ignored', () {
|
||||
final t = ModifierTapTracker();
|
||||
expect(t.up(KeyModifier.shift, at(0)), isNull); // stale release
|
||||
expect(tap(t, KeyModifier.shift, 50), isNull); // arms normally after
|
||||
expect(tap(t, KeyModifier.shift, 150), KeyModifier.shift);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user