From 430189d7143292a5f1c304e140e5767b1b17736f Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 11 Jun 2026 13:26:54 +0200 Subject: [PATCH] =?UTF-8?q?keymap:=20support=20bare-modifier=20double-tap?= =?UTF-8?q?=20chords;=20double-Shift=20=E2=86=92=20quick-open=20(T-341)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .pql/changelog/ticket_history/2026-06.sql | 3 + .pql/changelog/tickets/2026-06.sql | 43 ++++++++++++++ CHANGELOG.md | 8 +++ assets/keymaps/default.yaml | 4 +- assets/keymaps/jetbrains.yaml | 11 ++-- assets/keymaps/vim.yaml | 3 +- assets/keymaps/vscode.yaml | 3 +- lib/app.dart | 30 ++++++++-- lib/kernel/kernel.dart | 1 + lib/kernel/src/keymap/key_chord.dart | 48 +++++++++++++++- lib/kernel/src/keymap/keymap_service.dart | 10 ++++ lib/kernel/src/keymap/modifier_tap.dart | 44 +++++++++++++++ test/kernel/src/keymap/key_chord_test.dart | 47 ++++++++++++++++ test/kernel/src/keymap/modifier_tap_test.dart | 56 +++++++++++++++++++ .../src/keymap/shipped_presets_test.dart | 13 +++++ 15 files changed, 312 insertions(+), 12 deletions(-) create mode 100644 lib/kernel/src/keymap/modifier_tap.dart create mode 100644 test/kernel/src/keymap/modifier_tap_test.dart diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index d38c28c8..2528b71c 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -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; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 690fabc0..5ee67c23 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -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); diff --git a/CHANGELOG.md b/CHANGELOG.md index ed9a801f..c611b4c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/assets/keymaps/default.yaml b/assets/keymaps/default.yaml index e9d53230..462bf818 100644 --- a/assets/keymaps/default.yaml +++ b/assets/keymaps/default.yaml @@ -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). diff --git a/assets/keymaps/jetbrains.yaml b/assets/keymaps/jetbrains.yaml index 0ddcd7e7..d2bb3087 100644 --- a/assets/keymaps/jetbrains.yaml +++ b/assets/keymaps/jetbrains.yaml @@ -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 diff --git a/assets/keymaps/vim.yaml b/assets/keymaps/vim.yaml index 17a254f5..13c9b842 100644 --- a/assets/keymaps/vim.yaml +++ b/assets/keymaps/vim.yaml @@ -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] diff --git a/assets/keymaps/vscode.yaml b/assets/keymaps/vscode.yaml index 3db9af43..25c11c32 100644 --- a/assets/keymaps/vscode.yaml +++ b/assets/keymaps/vscode.yaml @@ -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] diff --git a/lib/app.dart b/lib/app.dart index a192b20b..0a467320 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -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); } diff --git a/lib/kernel/kernel.dart b/lib/kernel/kernel.dart index 27adcf5f..ba2a0a90 100644 --- a/lib/kernel/kernel.dart +++ b/lib/kernel/kernel.dart @@ -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'; diff --git a/lib/kernel/src/keymap/key_chord.dart b/lib/kernel/src/keymap/key_chord.dart index d48a423a..def5a623 100644 --- a/lib/kernel/src/keymap/key_chord.dart +++ b/lib/kernel/src/keymap/key_chord.dart @@ -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 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 _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 _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; diff --git a/lib/kernel/src/keymap/keymap_service.dart b/lib/kernel/src/keymap/keymap_service.dart index 66d1e9e4..c8279236 100644 --- a/lib/kernel/src/keymap/keymap_service.dart +++ b/lib/kernel/src/keymap/keymap_service.dart @@ -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 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. diff --git a/lib/kernel/src/keymap/modifier_tap.dart b/lib/kernel/src/keymap/modifier_tap.dart new file mode 100644 index 00000000..76233696 --- /dev/null +++ b/lib/kernel/src/keymap/modifier_tap.dart @@ -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; + } +} diff --git a/test/kernel/src/keymap/key_chord_test.dart b/test/kernel/src/keymap/key_chord_test.dart index 75224f47..a7ce0c41 100644 --- a/test/kernel/src/keymap/key_chord_test.dart +++ b/test/kernel/src/keymap/key_chord_test.dart @@ -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); + }); + }); } diff --git a/test/kernel/src/keymap/modifier_tap_test.dart b/test/kernel/src/keymap/modifier_tap_test.dart new file mode 100644 index 00000000..c19c26d7 --- /dev/null +++ b/test/kernel/src/keymap/modifier_tap_test.dart @@ -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); + }); + }); +} diff --git a/test/kernel/src/keymap/shipped_presets_test.dart b/test/kernel/src/keymap/shipped_presets_test.dart index 30fcc970..dc3cc526 100644 --- a/test/kernel/src/keymap/shipped_presets_test.dart +++ b/test/kernel/src/keymap/shipped_presets_test.dart @@ -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(), reason: '$name should bind double-Shift to quickOpen.open'); + }); + } }