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:
@@ -43,9 +43,34 @@ class KeyChord {
|
||||
|
||||
const KeyChord._(this.modifiers, this.key);
|
||||
|
||||
/// A bare modifier press as a chord — no modifier set, the modifier key
|
||||
/// itself as the base key. Lets a preset bind `shift` (and a double-tap
|
||||
/// as the sequence `shift shift`, e.g. JetBrains "Search Everywhere").
|
||||
/// (T-341)
|
||||
factory KeyChord.bareModifier(KeyModifier m) => KeyChord(key: _modifierKey[m]!);
|
||||
|
||||
final List<KeyModifier> modifiers;
|
||||
final LogicalKeyboardKey key;
|
||||
|
||||
/// The [KeyModifier] a bare modifier-key press maps to (left/right/generic
|
||||
/// variants collapse to one), or null if [logical] isn't a modifier key.
|
||||
/// Used by the global handler's double-tap detector. (T-341)
|
||||
static KeyModifier? modifierForLogicalKey(LogicalKeyboardKey logical) {
|
||||
if (logical == LogicalKeyboardKey.control || logical == LogicalKeyboardKey.controlLeft || logical == LogicalKeyboardKey.controlRight) {
|
||||
return KeyModifier.ctrl;
|
||||
}
|
||||
if (logical == LogicalKeyboardKey.alt || logical == LogicalKeyboardKey.altLeft || logical == LogicalKeyboardKey.altRight) {
|
||||
return KeyModifier.alt;
|
||||
}
|
||||
if (logical == LogicalKeyboardKey.shift || logical == LogicalKeyboardKey.shiftLeft || logical == LogicalKeyboardKey.shiftRight) {
|
||||
return KeyModifier.shift;
|
||||
}
|
||||
if (logical == LogicalKeyboardKey.meta || logical == LogicalKeyboardKey.metaLeft || logical == LogicalKeyboardKey.metaRight) {
|
||||
return KeyModifier.meta;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Build from a Flutter [KeyEvent]. Returns null for non-down events
|
||||
/// or events whose logical key has no meaningful id (e.g. a bare
|
||||
/// modifier press in isolation).
|
||||
@@ -235,9 +260,30 @@ const List<LogicalKeyboardKey> _digitKeys = [
|
||||
LogicalKeyboardKey.digit9,
|
||||
];
|
||||
|
||||
LogicalKeyboardKey? _keyByName(String name) => _byName[name.toLowerCase()];
|
||||
/// Canonical logical key for each bare modifier (left/right variants
|
||||
/// collapse to the side-agnostic key). Drives [KeyChord.bareModifier] and
|
||||
/// the `shift` / `ctrl` / `alt` / `meta` base-key names. (T-341)
|
||||
const Map<KeyModifier, LogicalKeyboardKey> _modifierKey = {
|
||||
KeyModifier.ctrl: LogicalKeyboardKey.control,
|
||||
KeyModifier.alt: LogicalKeyboardKey.alt,
|
||||
KeyModifier.shift: LogicalKeyboardKey.shift,
|
||||
KeyModifier.meta: LogicalKeyboardKey.meta,
|
||||
};
|
||||
|
||||
LogicalKeyboardKey? _keyByName(String name) {
|
||||
final n = name.toLowerCase();
|
||||
// A bare modifier name as the base key (`shift`, `ctrl`, `cmd`, …) — so
|
||||
// `parseSequence('shift shift')` yields a double-tap binding. (T-341)
|
||||
final mod = _modByName(n);
|
||||
if (mod != null) return _modifierKey[mod];
|
||||
return _byName[n];
|
||||
}
|
||||
|
||||
String _keyName(LogicalKeyboardKey key) {
|
||||
// Bare-modifier keys reverse to their canonical modifier name.
|
||||
for (final entry in _modifierKey.entries) {
|
||||
if (entry.value == key) return entry.key.yaml;
|
||||
}
|
||||
// Reverse lookup; prefer the canonical (first) name for each key.
|
||||
for (final entry in _byName.entries) {
|
||||
if (entry.value == key) return entry.key;
|
||||
|
||||
@@ -157,6 +157,16 @@ class KeymapService extends ChangeNotifier {
|
||||
return km.resolve(chord, _scope);
|
||||
}
|
||||
|
||||
/// Resolve a complete chord [sequence] (e.g. a double-tapped modifier,
|
||||
/// `[shift, shift]`) against the active keymap and current scope. Returns
|
||||
/// the bound intent only on an exact full-sequence match, else null.
|
||||
/// Used by the global handler's double-tap detector (T-341).
|
||||
Intent? resolveSequence(List<KeyChord> sequence) {
|
||||
final km = _active;
|
||||
if (km == null) return null;
|
||||
return km.match(sequence, _scope).exact;
|
||||
}
|
||||
|
||||
/// Set a named scope flag. Producers should call this when their
|
||||
/// state changes so when-clauses re-evaluate correctly. Notifies
|
||||
/// listeners when the value actually changes.
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/// 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`).
|
||||
library;
|
||||
|
||||
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.
|
||||
final Duration window;
|
||||
|
||||
KeyModifier? _last;
|
||||
DateTime? _lastAt;
|
||||
|
||||
/// 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);
|
||||
if (gap >= Duration.zero && gap <= window) {
|
||||
reset();
|
||||
return m;
|
||||
}
|
||||
}
|
||||
_last = m;
|
||||
_lastAt = now;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Break the gesture — any non-modifier key press resets the tracker.
|
||||
void reset() {
|
||||
_last = null;
|
||||
_lastAt = null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user