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
@@ -3525,3 +3525,6 @@ FOLLOW-UP SCOPE (folded in 2026-06-11):
3. TODO exact toolchain pin for reproducible builds. No FVM .fvmrc / .tool-versions / .flutter-version exists; a fresh clone builds with whatever Flutter the dev has (>= floor). Add an exact pin (FVM .fvmrc or asdf/mise .tool-versions) targeting the current toolchain (Dart 3.12.1 / Flutter 3.44.1).', NULL, '2026-06-11 07:05:54', '2026-06-11 07:05:54', '2026-06-11 07:05:54', NULL, 'f6d9c657c6987f7927bc3ba0bc02b3a4', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'status', 'backlog', 'ready', NULL, '2026-06-11 07:05:58', '2026-06-11 07:05:58', '2026-06-11 07:05:58', NULL, '1553f134361839180feffa625a88c06d', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'status', 'ready', 'done', NULL, '2026-06-11 10:12:25', '2026-06-11 10:12:25', '2026-06-11 10:12:25', NULL, '53cfd5cf779b9a8474afe7e74efd02a3', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB44SKPKTHFMV6WD28GZYPXM', 'status', 'ready', 'in_progress', NULL, '2026-06-11 10:47:15', '2026-06-11 10:47:15', '2026-06-11 10:47:15', NULL, '4ac3982fdaada5c776320c99cbf0763c', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB493JEW32CH0H3771TNHF7G', 'status', 'ready', 'in_progress', NULL, '2026-06-11 10:49:25', '2026-06-11 10:49:25', '2026-06-11 10:49:25', NULL, 'c4fca969ce9e6d31122706878554bbdb', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DWCJSGZH9WYDNFWZBAYYR', 'status', 'ready', 'in_progress', NULL, '2026-06-11 10:49:33', '2026-06-11 10:49:33', '2026-06-11 10:49:33', NULL, '6180a1491ff29428974ca84c0de4ebb0', 2) ON CONFLICT(hash) DO NOTHING;
+43
View File
@@ -3268,3 +3268,46 @@ FOLLOW-UP SCOPE (folded in 2026-06-11):
2. TODO tighten env floors to reality. pubspec.yaml `environment` currently declares Dart >=3.5.0 / Flutter >=3.19.0, but we actually require more (alchemist 0.12 needs Flutter 3.32; the held markdown 7.3.1 needs Dart 3.9). Raise floors to ~Dart >=3.9.0 / Flutter >=3.32.0 honest minimums. Raising Dart to 3.9 also unblocks the held markdown 7.2.2 -> 7.3.1 bump.
3. TODO exact toolchain pin for reproducible builds. No FVM .fvmrc / .tool-versions / .flutter-version exists; a fresh clone builds with whatever Flutter the dev has (>= floor). Add an exact pin (FVM .fvmrc or asdf/mise .tool-versions) targeting the current toolchain (Dart 3.12.1 / Flutter 3.44.1).', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 06:50:21', '2026-06-11 10:12:25', NULL, '4738f0609647b88e169e6e5788ded217', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB44SKPKTHFMV6WD28GZYPXM', 'task', NULL, 'Support double-tap / bare-modifier chords (JetBrains Search Everywhere)', 'The KeyChord matcher can''t represent a bare or double-tapped modifier: KeyChord.fromKeyEvent returns null for bare modifier presses (key_chord.dart _isBareModifier), and KeyChord.parse requires a base key after the last ''+'', so ''shift+shift'' / ''shift shift'' both fail. Consequence: JetBrains ''Search Everywhere'' (double-Shift) and any double-tap-modifier shortcut can''t be bound.
Decide (design): is search-everywhere its own intent + overlay, or an alias of quick-open? Then extend the chord/sequence matcher to represent a bare-modifier ''key'' (and a double-tap as a sequence) so a preset can bind it.
Surfaced by T-64/T-66 (VS Code / JetBrains keymap presets). Those tickets'' ''see Q-9'' reference is STALE Q-9 is ''Lua runtime vendoring'', unrelated. The JetBrains preset currently maps quick-open to Ctrl+Shift+N (Go to File) and the palette to Ctrl+Shift+A (Find Action) as the expressible IntelliJ equivalents.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 15:07:43', '2026-06-11 10:47:15', NULL, 'b277bfbe5ed8986df00e1e031ad34ad6', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB493JEW32CH0H3771TNHF7G', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Give each spawned subagent its own collapsing activity card (don''t merge Task spawns)', 'Filed 2026-06-10 from a user request: when the agent fans out multiple subagents (Task/Agent tool), each spawned subagent should get its OWN collapsing activity card. The space is worth it — a fan-out of N agents should read as N cards, not one lumped card.
CURRENT BEHAVIOUR (confirmed): multiple Task/Agent spawns are MERGED into a single shared ''Activity / N steps'' cluster. groupConversation() in lib/builtin/claude/src/activity_cluster.dart:120-147 walks items and coalesces every consecutive _isFoldable item into one FoldedCluster. _isFoldable (:149-172, AssistantToolUse case at :161-164) only distinguishes diff tools (Edit/Write/MultiEdit/NotebookEdit/Update -> stay first-class) from everything else (Task/Agent/Bash/Read/... -> all foldable). The Task tool is treated identically to a Bash/Read call; there is NO subagent-aware grouping key (not toolUseId, not parent_tool_use_id). So 4 spawned agents render as one ''Activity 4 steps'' card.
What recent work already does (do NOT redo): T-263 folds the subagent PROMPT into the Agent card; T-264 nests the subagent''s RUN items under the parent Agent card (the ''agent run'' collapser); T-338 routes sidechain items to their parent via parent_tool_use_id. All of that is about what shows INSIDE one agent''s card. This ticket is the complement: stop merging DISTINCT agent spawns into a shared cluster.
SCOPE / DESIGN:
- An AssistantToolUse where _isAgentTool(name) (Task/Agent) should break the current Activity cluster and render as its own first-class collapsing card (its own ClideCollapserCard with the prompt + nested ''agent run'' from T-263/T-264), rather than folding into the generic Activity cluster with sibling tool calls.
- Decide grouping precisely in groupConversation/_isFoldable: an Agent tool-use is a cluster boundary (like a sticky item) OR emits its own single-item card. Adjacent non-agent foldables (Bash/Read/Grep) keep clustering into the normal Activity card as today.
- Label each subagent card by its task/description (the Agent call''s label) so parallel fan-outs are distinguishable, not ''Activity N steps''.
- Keep collapsed-by-default behaviour and the FoldLevel semantics; this changes the grouping boundary, not the fold mechanics.
Refs: lib/builtin/claude/src/activity_cluster.dart (groupConversation, _isFoldable, isDiffTool), lib/builtin/claude/src/conversation_view.dart (_ActivityCard at ~833, _toolUseCollapser ~615-657, _isAgentTool ~378). Related: T-230 (clustering), T-263, T-264, T-338.
Tests: a groupConversation case asserting that two consecutive Agent tool-uses yield two separate cards (not one FoldedCluster), while two consecutive Bash calls still yield one Activity cluster.
SCOPE CLARIFICATION (2026-06-10, from user):
1. RIGHT card, not just A card. Each per-agent card must pull in ALL of that agent''s nested run the folded prompt (T-263) AND every nested response: prose, thinking, sidechain tool cards, and results (T-264) attributed to the CORRECT agent even under a parallel fan-out where multiple agents'' sidechain items interleave in the stream. Reuse the existing _sidechainFold machinery (conversation_view.dart:188-256): runByToolUseId / promptsByToolUseId are already keyed by the owning Agent''s toolUseId, and resolveOwner''s direct route (conversation_view.dart:210-217) uses parent_tool_use_id (T-338) which disambiguates concurrent agents correctly. THE HAZARD: resolveOwner falls back to ''nearest'' = lastAgent (the most-recently-emitted Agent in stream order; conversation_view.dart:228, applied at :242). In a parallel fan-out any item that lacks parent_tool_use_id and a rooted parentUuid chain would mis-route to whichever agent was emitted last landing in the WRONG card. Harden this: for the multi-agent case, drop or guard the nearest-lastAgent fallback so an unattributable item is rendered inline/orphaned (resolveOwner already returns null -> handled at :243) rather than mis-filed into a sibling agent''s card.
2. PRESERVE the existing grouping. This ticket only adds an Agent-spawn cluster boundary; it must NOT regress the rest:
- Non-agent foldables (Bash/Read/Grep/LS/etc.) keep coalescing into the generic ''Activity / N steps'' cluster exactly as today (activity_cluster.dart groupConversation/_isFoldable).
- The intra-agent folding stays: prompt-into-call (T-263), run-nested-under-card (T-264), sidechain routing by parent_tool_use_id (T-338). Reuse them; do not rebuild.
- Net behaviour: a fan-out of N agents -> N distinct collapsed cards, each containing its own complete run; surrounding non-agent tool calls still group into their normal Activity card.
Test additions: (a) two concurrent agents whose sidechain items interleave -> each agent''s run items land under its own card, none cross-attributed; (b) an unattributable sidechain item (no parent_tool_use_id, broken chain) is NOT swept into the nearest agent''s card; (c) regression: consecutive Bash/Read calls still form one Activity cluster.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 15:26:33', '2026-06-11 10:49:25', NULL, '95cc390bd8d38f7a3568cf367864d1ae', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DWCJSGZH9WYDNFWZBAYYR', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'ExitPlanMode does not exit plan mode in the conversation panel', 'Observed live: Claude used the ExitPlanMode tool (approved the plan / left plan mode), but the clide conversation panel did not reflect the exit — it still appears to be in plan mode afterward.
Expected: once ExitPlanMode is accepted, the pane leaves plan mode the permission-mode indicator (claude_status / permission_mode_control) flips off ''plan'', and the composer / status reflect the new mode.
Where to look:
- How the stream-json session handles the ExitPlanMode tool/control event (stream_json_session.dart) and whether it updates the tracked permission mode (claude_status.dart permissionMode).
- The permission-mode control + status badge wiring (permission_mode_control.dart, claude_pane.dart) does an ExitPlanMode resolution feed back into the mode the way Ctrl/Cmd+M and the picker do?
- Whether ExitPlanMode arrives as a permission prompt (ToolPromptCard) and, if approved, actually transitions the session out of plan mode rather than just resolving the tool.
Repro: enter plan mode, let Claude present a plan via ExitPlanMode, accept it the pane stays showing plan mode.
Acceptance: accepting ExitPlanMode leaves plan mode in the conversation panel (mode indicator + behavior update), matching what the underlying session does.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 13:27:36', '2026-06-11 10:49:33', NULL, '96c4174428163a4998490d15c8463149', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+8
View File
@@ -16,6 +16,14 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
## [Unreleased]
### Added
- **Double-tap-modifier shortcuts (e.g. double-Shift "Search Everywhere").**
The keymap can now bind a bare modifier and a double-tap sequence
(`shift shift`). All four presets (default, vim, vscode, jetbrains) map
double-Shift to the quick-open finder — JetBrains' "Search Everywhere"
gesture, aliased to clide's existing fuzzy file finder. (T-341)
## [2.3.3] — 2026-06-11
### Fixed
+3 -1
View File
@@ -58,8 +58,10 @@ bindings:
# only claims ctrl+p for selectPrevious `when: palette.open`, so this
# is conflict-free. Arrow/enter/escape inside the overlay are handled
# locally by the widget.
# `shift shift` (double-tap) is the JetBrains "Search Everywhere" gesture;
# clide aliases it to the quick-open finder across all presets (T-341).
- intent: quickOpen.open
keys: [ctrl+p, meta+p]
keys: [ctrl+p, meta+p, shift shift]
when: "!palette.open"
# Nav stays on arrows/ctrl+n (not ctrl+p — that's the open chord and
# would collide while the overlay is up).
+7 -4
View File
@@ -12,11 +12,12 @@
# IntelliJ's editor-scoped contexts have no producer yet, so global chords
# stay ungated (they're global in IntelliJ too).
#
# "Search Everywhere" (double-Shift) maps to clide's quick-open finder via
# the `shift shift` double-tap gesture (T-341) — see the quick-open binding.
#
# Not bound — no clide command analogue (kept out of scope per the ticket):
# Run (Shift+F10), Debug (Shift+F9), Rename/Refactor (Shift+F6), Settings
# (Ctrl+Alt+S). And "Search Everywhere" (double-Shift) is not expressible by
# the current chord matcher (bare/double modifiers unsupported) — tracked by
# T-341; Go to File / Find Action below are the practical stand-ins.
# (Ctrl+Alt+S).
name: jetbrains
@@ -52,8 +53,10 @@ bindings:
# -- Quick open: Go to File / Go to Class / Recent Files --------------
# win/linux: Ctrl+Shift+N, Ctrl+N, Ctrl+E. mac: Cmd+Shift+O, Cmd+O,
# Cmd+E. clide has one fuzzy file finder, so all land on quick-open.
# `shift shift` (double-tap) is IntelliJ's "Search Everywhere"; clide
# aliases it to the quick-open finder (T-341).
- intent: quickOpen.open
keys: [ctrl+shift+n, ctrl+n, ctrl+e, meta+shift+o, meta+o, meta+e]
keys: [ctrl+shift+n, ctrl+n, ctrl+e, meta+shift+o, meta+o, meta+e, shift shift]
when: "!palette.open"
- intent: quickOpen.selectNext
keys: down
+2 -1
View File
@@ -34,8 +34,9 @@ bindings:
- intent: dismiss
keys: escape
when: palette.open
# `shift shift` (double-tap) aliases to quick-open across presets (T-341).
- intent: quickOpen.open
keys: [ctrl+p, meta+p]
keys: [ctrl+p, meta+p, shift shift]
when: "!palette.open"
- intent: quickOpen.selectNext
keys: [down, ctrl+n]
+2 -1
View File
@@ -51,8 +51,9 @@ bindings:
when: palette.open
# -- Quick open / Go to File (Ctrl+P) ---------------------------------
# `shift shift` (double-tap) aliases to quick-open across presets (T-341).
- intent: quickOpen.open
keys: [ctrl+p, meta+p]
keys: [ctrl+p, meta+p, shift shift]
when: "!palette.open"
- intent: quickOpen.selectNext
keys: [down, ctrl+n]
+26 -4
View File
@@ -55,6 +55,10 @@ class _RootShell extends StatefulWidget {
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).
final ModifierTapTracker _modTap = ModifierTapTracker();
@override
void initState() {
@@ -183,12 +187,30 @@ 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;
// Dispatch the 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 Actions for global ones
// (text scale, generic command bridge).
_dispatchIntent(intent);
}
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
// Actions for global ones (text scale, generic command bridge).
final ctx = FocusManager.instance.primaryFocus?.context ?? context;
Actions.maybeInvoke(ctx, intent);
}
+1
View File
@@ -27,6 +27,7 @@ export 'src/keymap/intents.dart';
export 'src/keymap/key_chord.dart';
export 'src/keymap/keymap.dart';
export 'src/keymap/keymap_service.dart';
export 'src/keymap/modifier_tap.dart';
export 'src/keymap/sequence_matcher.dart';
export 'src/keymap/when_clause.dart';
export 'src/dialog.dart';
+47 -1
View File
@@ -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;
+10
View File
@@ -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.
+44
View File
@@ -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;
}
}
@@ -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');
});
}
}