From 1368ba84b8f41ced0ee7c44628dfd5fe6f309fae Mon Sep 17 00:00:00 2001 From: Devin345458 Date: Wed, 17 Jun 2026 21:25:27 -0400 Subject: [PATCH] feat(shortcuts): double-tap modifier keybindings (#5516) * docs: design for double-tap modifier keybindings * feat(keybindings): parse and normalize DoubleTap+ grammar * test(keybindings): cover DoubleTap+Ctrl positive case * feat(keybindings): match DoubleTap bindings against synthetic input * test(keybindings): cover Ctrl-on-darwin double-tap miss; document input invariant * feat(keybindings): format DoubleTap bindings as a doubled glyph * test(keybindings): cover DoubleTap+Ctrl glyph on mac; note title label semantics * feat(keybindings): add pure ModifierDoubleTapDetector state machine * test(keybindings): cover missed-keyup double-tap edge; clarify keyUp guard * feat(keybindings): capture double-tap gestures into DoubleTap+ * test(keybindings): cover Ctrl/linux and Cmd/linux double-tap canonicalization * feat(keybindings): resolve double-tap input in window shortcut policy * feat(shortcuts): detect double-tap modifiers in main before-input-event * docs(shortcuts): note dictation guards live in the renderer * feat(shortcuts): dispatch double-tap modifiers in the renderer window handler * fix(shortcuts): clear armed double-tap state on dangling modifier keyup * feat(settings): record double-tap modifier shortcuts in the editor * i18n(shortcuts): localize ShortcutKeyCombo double-tap tooltip * Fix double-tap shortcut review gaps Co-authored-by: Orca --------- Co-authored-by: Devin345458 Co-authored-by: Jinwoo-H Co-authored-by: Orca --- .../double-tap-modifier-keybindings-design.md | 247 +++++++++++++ src/main/browser/browser-guest-ui.test.ts | 89 +++++ src/main/browser/browser-guest-ui.ts | 129 ++++--- src/main/window/createMainWindow.test.ts | 250 ++++++++++++++ src/main/window/createMainWindow.ts | 325 ++++++++++-------- src/renderer/src/App.tsx | 144 ++++++-- src/renderer/src/components/Landing.tsx | 21 +- .../src/components/ShortcutKeyCombo.tsx | 18 +- .../src/components/editor/IpynbViewer.tsx | 14 +- .../CmdJPaletteFeatureTipVisual.test.tsx | 29 +- .../CmdJPaletteFeatureTipVisual.tsx | 12 +- .../FloatingTerminalPanel.test.tsx | 140 ++++++++ .../FloatingTerminalPanel.tsx | 306 ++++++++++------- .../settings/AppearancePane.test.tsx | 2 +- .../components/settings/AppearancePane.tsx | 15 +- .../settings/SettingsSidebar.test.tsx | 2 +- .../components/settings/SettingsSidebar.tsx | 11 +- .../settings/ShortcutBindingRow.tsx | 75 +++- .../sidebar/SidebarSettingsHelpMenu.test.tsx | 6 +- .../sidebar/SidebarSettingsHelpMenu.tsx | 9 +- src/renderer/src/hooks/useShortcutLabel.ts | 40 ++- src/renderer/src/i18n/locales/en.json | 6 +- src/renderer/src/i18n/locales/es.json | 6 +- src/renderer/src/i18n/locales/ja.json | 6 +- src/renderer/src/i18n/locales/ko.json | 6 +- src/renderer/src/i18n/locales/zh.json | 6 +- .../lib/floating-workspace-shortcut-policy.ts | 13 +- ...loating-workspace-terminal-actions.test.ts | 11 + src/shared/keybindings.test.ts | 167 +++++++++ src/shared/keybindings.ts | 240 ++++++++++--- .../modifier-double-tap-detector.test.ts | 135 ++++++++ src/shared/modifier-double-tap-detector.ts | 153 +++++++++ src/shared/window-shortcut-policy.test.ts | 17 + src/shared/window-shortcut-policy.ts | 6 +- 34 files changed, 2199 insertions(+), 457 deletions(-) create mode 100644 docs/double-tap-modifier-keybindings-design.md create mode 100644 src/shared/modifier-double-tap-detector.test.ts create mode 100644 src/shared/modifier-double-tap-detector.ts diff --git a/docs/double-tap-modifier-keybindings-design.md b/docs/double-tap-modifier-keybindings-design.md new file mode 100644 index 000000000..4cc868e2a --- /dev/null +++ b/docs/double-tap-modifier-keybindings-design.md @@ -0,0 +1,247 @@ +# Double-tap modifier keybindings — design + +## Goal + +Allow any keybinding action to be bound to a **double-tap of a bare modifier** +(Shift, Cmd/Ctrl, Alt) in Settings → Shortcuts. A double-tap binding is stored, +recorded, formatted, conflict-checked, and matched alongside normal bindings, +and fires everywhere a normal shortcut does — including when a browser guest or +terminal owns focus. + +Example: bind `DoubleTap+Shift` to `worktree.quickOpen` ("Go to File"), then +tapping Shift twice opens Go to File (IntelliJ "double-Shift" style). + +## Why this is not just another binding + +The existing keybinding system is **stateless and per-keydown**: every binding +has at least one modifier and exactly one key, and matching compares a single +`KeyboardEvent`'s modifier state + key against the stored binding string +(`keybindingMatchesInput` in `src/shared/keybindings.ts`). A double-tap is a +**timed sequence of a bare modifier with no key** — press M, release M, press M +again within a short window. It cannot be represented by the current grammar or +matched by the current stateless comparison. + +Dispatch is also split across two layers, and both must participate for a +double-tap to work for "any action": + +- **Main process** — `before-input-event` in + `src/main/window/createMainWindow.ts` matches an explicit allowlist of ~20 + actions via `resolveWindowShortcutAction` + (`src/shared/window-shortcut-policy.ts`), calls `preventDefault()`, and + forwards the action to the renderer over IPC. This layer exists so a subset of + shortcuts work even when focus lives in a browser guest `webContents` or a + contentEditable surface that bypasses the renderer's window-level listener. +- **Renderer** — the window `keydown` handler in + `src/renderer/src/App.tsx` matches most actions via `keybindingMatchesAction` + and runs their effects inline. + +## Approach (chosen): synthetic input through the existing matchers + +Detect the double-tap with a small shared state machine, then represent the +completed gesture as a **synthetic shortcut input** carrying a +`doubleTapModifier` marker and run that input through the *existing* dispatch +chains in both layers. The matcher is extended so a `DoubleTap+` binding +matches only that synthetic input (and never a normal keydown, and vice-versa). + +Because the existing dispatch chains already call +`keybindingMatchesAction(actionId, input, …)`, every action that is already +wired in those chains gains double-tap support automatically — no per-action +dispatch table to build or keep in sync. + +Rejected alternatives: + +- **Per-action dispatch registry** — detector resolves action ids and calls a + new `dispatchActionById()` implemented per action. Avoids refactoring the + renderer handler but duplicates action effects that already live there, drifts + over time, and only supports actions we explicitly wire — not "any action". +- **Re-dispatch a synthetic DOM `KeyboardEvent`** — a double-tap can't be + expressed as a standard key event without a key, and re-dispatching risks + event loops. + +## Components + +### 1. Binding grammar — `src/shared/keybindings.ts` + +- New canonical form `DoubleTap+` where `` is one of `Shift`, `Mod`, + `Cmd`, `Ctrl`, `Alt`. `Mod` resolves to Cmd on macOS and Ctrl on + Windows/Linux, identical to normal bindings. +- `ParsedKeybinding` gains `doubleTapModifier?: ModifierToken` and permits an + empty `key` (only when `doubleTapModifier` is set). +- `parseKeybinding` recognizes a leading `DoubleTap` token followed by exactly + one modifier token and **no** key token. Anything else with `DoubleTap` is + invalid. +- `canonicalizeParsedKeybinding` emits `DoubleTap+` (modifier in the same + canonical position rules as today). +- `normalizeKeybindingWithOptions` accepts a well-formed double-tap binding and + rejects malformed ones with clear errors: + - `DoubleTap` + a key (e.g. `DoubleTap+Shift+P`) → invalid. + - `DoubleTap` + two modifiers (e.g. `DoubleTap+Shift+Alt`) → invalid. + - `DoubleTap+Mod+Cmd` (both forms) → reuse the existing "Mod or + platform-specific, not both" error. + - bare `DoubleTap` with no modifier → invalid. +- `formatKeybinding` returns the modifier glyph **twice**: macOS `['⇧','⇧']`, + Windows/Linux `['Shift','Shift']`. +- `ShortcutKeyCombo` renders the two chips. Double-tap is special-cased so the + non-Mac separator reads "Shift Shift" (space), not "Shift+Shift". A + "Double-tap Shift" tooltip clarifies the gesture. + +### 2. Detector — new module `src/shared/modifier-double-tap-detector.ts` + +A pure, dependency-free state machine. Timestamps are **injected** by the caller +so it is deterministic and unit-testable. + +``` +class ModifierDoubleTapDetector { + // event: { type: 'keyDown' | 'keyUp', modifier: ModifierToken | null, + // isModifierOnly: boolean, isAutoRepeat: boolean } + process(event, timestampMs): DetectedDoubleTap | null + reset(): void +} +``` + +State machine: + +1. **idle** → on a modifier-only `keyDown` of M that is not autorepeat: remember + M, wait for its release. +2. **down1** → on `keyUp` of M (clean, no other key seen): record release time, + move to **armed(M)** with deadline `releaseTime + WINDOW_MS`. +3. **armed(M)** → on `keyDown` of the same M within the deadline, with no other + modifier held and no intervening non-modifier key: **emit** a double-tap of M + and reset. + +Any of these reset to idle: a non-modifier key event at any point, a different +or additional modifier, autorepeat-hold of the modifier, exceeding the window, +or an explicit `reset()` (e.g. on window blur / focus change). + +`WINDOW_MS = 300` (internal constant; not user-configurable). A helper derives +`(modifier, isModifierOnly)` from an event's `code`/`key`. + +### 3. Matcher extension — `src/shared/keybindings.ts` + +- `KeybindingInput` gains `doubleTapModifier?: ModifierToken`. +- `keybindingMatchesInput`: when the parsed binding is a double-tap binding, + match iff `input.doubleTapModifier` equals the binding's modifier, resolved per + platform (`Mod` → meta on macOS, control elsewhere). A double-tap binding never + matches a normal keydown (no `doubleTapModifier`), and a normal binding never + matches a synthetic double-tap input. +- No change to `keybindingMatchesAction` — it already delegates to + `keybindingMatchesInput`, so any action becomes double-tap-capable for free. + +### 4. Dispatch wiring — both layers + +- **Main** (`src/main/window/createMainWindow.ts`): instantiate a + `ModifierDoubleTapDetector` per window. In `before-input-event`, feed every + `keyDown`/`keyUp` to the detector (it only consumes bare-modifier events). On + emit, build the synthetic input `{ doubleTapModifier: M }`, run the existing + `resolveWindowShortcutAction(syntheticInput, platform, keybindings, + terminalShortcutContext)`, and if an allowlisted action resolves, dispatch via + the current IPC + `preventDefault()` path. `resolveWindowShortcutAction` needs + no per-action change; the implicit numeric-index shortcuts are guarded on + `input.key`, which is undefined for a double-tap input, so they cannot match. + Only the emitting second-keydown event is `preventDefault()`-ed — never the + first tap's down/up (those bare modifiers are harmless and the keyup is needed + by the detector). +- **Renderer** (`src/renderer/src/App.tsx`): extract the body of the window + `onKeyDown` handler into `dispatchShortcutInput(input: ShortcutDispatchInput)`, + where `ShortcutDispatchInput` exposes the modifier/key fields plus + `doubleTapModifier?`, a `preventDefault()` (no-op for synthetic input), + `defaultPrevented`, and the focus/target context. The real listener wraps the + `KeyboardEvent`; a renderer `ModifierDoubleTapDetector` (fed by both a keydown + and a **new** keyup window listener) produces a synthetic input on emit with + `context` derived from `document.activeElement`, and calls + `dispatchShortcutInput`. + +#### No double-fire between layers + +This reuses the exact disambiguation normal shortcuts already rely on: + +- For an **allowlisted** action, main detects the double-tap on the second + modifier keydown, resolves it, and calls `preventDefault()`. That suppresses + the corresponding renderer DOM keydown, so the renderer detector never + completes its second tap → it does not fire. (The renderer detector may have + observed the first tap's down/up. It has no timer: the second-press window is + enforced by comparing the next keydown's timestamp against a deadline. The + suppressed second keydown never arrives, but its keyup still does — a keyup of + the armed modifier with no intervening second keydown clears the armed state, + so a later lone press of the same modifier cannot phantom-complete the gesture.) +- For a **non-allowlisted** action, main's detector still emits but + `resolveWindowShortcutAction` returns `null`, so main does not call + `preventDefault()`. The second-keydown DOM event reaches the renderer, whose + detector completes and fires via `dispatchShortcutInput`. + +### 5. Recorder UX — `ShortcutBindingRow.tsx` + `ShortcutsPane.tsx` + +The recorder currently captures on the first keydown, which makes a bare +modifier error with "Press a key, not only a modifier." Change the row so that +while recording it runs a `ModifierDoubleTapDetector` fed by the row button's +keydown **and keyup** (the button holds focus during recording, so it receives +both): + +- A bare-modifier keydown no longer captures immediately — the detector observes + it. +- A non-modifier keydown (with or without modifiers) captures a normal binding, + exactly as today. +- A completed double-tap captures `DoubleTap+`: the row passes + `{ doubleTapModifier: M }` into the capture path, and + `keybindingFromInputWithOptions` short-circuits to build `DoubleTap+` + (mapping meta → `Mod` on macOS, etc.) and normalizes it. +- A single lone modifier tap that never completes is ignored — the recorder + keeps listening. + +Helper text while recording: *"Press a shortcut, or double-tap a modifier (e.g. +⇧⇧)."* Esc still cancels. The detector is reset when recording stops or the row +loses focus. + +### 6. Conflicts & terminal policy + +`DoubleTap+Shift` is a canonical binding string, so `findKeybindingConflicts` +compares it like any other binding — two actions sharing a double-tap surface a +conflict in the UI. Terminal-policy gating (`keybindingIsActiveInContext`, +orca-first / terminal-first) applies unchanged. Note that a bare modifier press +emits no terminal bytes, so detecting a double-tap never steals readline input; +policy is still honored for consistency. + +## Data flow + +- **Record:** row keydown/keyup → row detector → `{ doubleTapModifier: M }` → + `keybindingFromInputForAction` → `DoubleTap+` → stored as + `["DoubleTap+Shift"]` in `~/.orca/keybindings.json`. +- **Runtime:** physical modifier taps → main + renderer detectors → synthetic + `{ doubleTapModifier: M }` → existing matchers → action dispatched (main IPC + for allowlisted actions, renderer inline for the rest). + +## Behavioral decisions + +- **Trigger edge:** fire on the **second modifier keydown** (snappy), not the + second keyup. +- **Window:** `WINDOW_MS = 300`, internal constant, not user-configurable. +- **Modifiers supported:** Shift, Cmd/Ctrl (`Mod`), Alt — any modifier, recorded + as the platform-appropriate token following the existing capture convention. + +## Testing + +- New `src/shared/modifier-double-tap-detector.test.ts`: completion within + window; timeout past window; reset on intervening non-modifier key; reset on + different/extra modifier; autorepeat-hold is not a tap; wrong-modifier second + tap; `reset()` clears state. +- `src/shared/keybindings.test.ts` additions: parse / normalize / canonicalize / + format for `DoubleTap+*` (incl. malformed-input rejection); platform token + mapping (`DoubleTap+Mod` → Cmd on macOS, Ctrl elsewhere); + `keybindingMatchesInput` with a synthetic `doubleTapModifier` input (positive + and cross-type negatives); conflict detection across two double-tap bindings. +- Manual: record `DoubleTap+Shift` on "Go to File"; confirm it fires globally + including with a browser guest and a focused terminal; confirm normal Shift+key + typing is unaffected; confirm chips and tokens are correct on macOS and + Windows/Linux. + +## Risks & edge cases + +- **Accidental triggers during fast typing** — mitigated by requiring a clean + down→up→down of the same modifier with no other key, inside a 300ms window. +- **macOS Sticky Keys (press Shift 5×)** — unaffected; the gesture is two taps + within a tight window. +- **Double-fire main vs renderer** — resolved by the + `preventDefault`-on-emit mechanism described in §4. +- **Focus/window changes mid-sequence** — both detectors reset on blur / focus + change (hook into the existing recorder/terminal focus reset paths in the main + process and a window blur listener in the renderer). diff --git a/src/main/browser/browser-guest-ui.test.ts b/src/main/browser/browser-guest-ui.test.ts index a526aed35..9e7704538 100644 --- a/src/main/browser/browser-guest-ui.test.ts +++ b/src/main/browser/browser-guest-ui.test.ts @@ -414,6 +414,14 @@ describe('setupGuestShortcutForwarding', () => { return preventDefault } + function triggerGuestBlur(): void { + const handler = guestOnMock.mock.calls.find((call) => call[0] === 'blur')?.[1] as + | (() => void) + | undefined + expect(handler).toBeTypeOf('function') + handler!() + } + beforeEach(() => { rendererSendMock = vi.fn() guestOnMock = vi.fn() @@ -552,4 +560,85 @@ describe('setupGuestShortcutForwarding', () => { expect(customPreventDefault).toHaveBeenCalledTimes(1) expect(rendererSendMock).toHaveBeenCalledWith('ui:zoomBrowserPage', 'in') }) + + it('forwards double-tap window shortcuts from focused guest pages', () => { + setupGuestShortcutForwarding({ + browserTabId, + guest: makeGuest(), + resolveRenderer: () => makeRenderer(), + getKeybindings: () => ({ + 'worktree.quickOpen': ['DoubleTap+Shift'] + }) + }) + + const modifierInput = { + code: 'ShiftLeft', + key: 'Shift', + shift: true, + meta: false, + control: false, + alt: false + } + const firstDownPreventDefault = triggerBeforeInput(modifierInput) + const firstUpPreventDefault = triggerBeforeInput({ ...modifierInput, type: 'keyUp' }) + const secondDownPreventDefault = triggerBeforeInput(modifierInput) + + expect(firstDownPreventDefault).not.toHaveBeenCalled() + expect(firstUpPreventDefault).not.toHaveBeenCalled() + expect(secondDownPreventDefault).toHaveBeenCalledTimes(1) + expect(rendererSendMock).toHaveBeenCalledWith('ui:openQuickOpen') + }) + + it('forwards double-tap tab shortcuts from focused guest pages', () => { + setupGuestShortcutForwarding({ + browserTabId, + guest: makeGuest(), + resolveRenderer: () => makeRenderer(), + getKeybindings: () => ({ + 'tab.newBrowser': ['DoubleTap+Shift'] + }) + }) + + const modifierInput = { + code: 'ShiftLeft', + key: 'Shift', + shift: true, + meta: false, + control: false, + alt: false + } + triggerBeforeInput(modifierInput) + triggerBeforeInput({ ...modifierInput, type: 'keyUp' }) + const secondDownPreventDefault = triggerBeforeInput(modifierInput) + + expect(secondDownPreventDefault).toHaveBeenCalledTimes(1) + expect(rendererSendMock).toHaveBeenCalledWith('ui:newBrowserTab') + }) + + it('resets guest double-tap detection on blur', () => { + setupGuestShortcutForwarding({ + browserTabId, + guest: makeGuest(), + resolveRenderer: () => makeRenderer(), + getKeybindings: () => ({ + 'worktree.quickOpen': ['DoubleTap+Shift'] + }) + }) + + const modifierInput = { + code: 'ShiftLeft', + key: 'Shift', + shift: true, + meta: false, + control: false, + alt: false + } + triggerBeforeInput(modifierInput) + triggerBeforeInput({ ...modifierInput, type: 'keyUp' }) + triggerGuestBlur() + const nextDownPreventDefault = triggerBeforeInput(modifierInput) + + expect(nextDownPreventDefault).not.toHaveBeenCalled() + expect(rendererSendMock).not.toHaveBeenCalledWith('ui:openQuickOpen') + }) }) diff --git a/src/main/browser/browser-guest-ui.ts b/src/main/browser/browser-guest-ui.ts index 68142e52b..61a37b8b3 100644 --- a/src/main/browser/browser-guest-ui.ts +++ b/src/main/browser/browser-guest-ui.ts @@ -11,11 +11,16 @@ import { import { isRecentTabSwitcherCommitRelease, matchesRecentTabSwitcherChord, - resolveWindowShortcutAction + resolveWindowShortcutAction, + type WindowShortcutInput } from '../../shared/window-shortcut-policy' import { readGuestNavigationState } from './browser-guest-navigation-state' import { keybindingMatchesAction, type KeybindingOverrides } from '../../shared/keybindings' import type { BrowserPageZoomDirection } from '../../shared/browser-page-zoom' +import { + ModifierDoubleTapDetector, + toModifierDoubleTapEvent +} from '../../shared/modifier-double-tap-detector' type ResolveRenderer = (browserTabId: string) => Electron.WebContents | null type ShouldForwardDictationShortcut = () => boolean @@ -263,49 +268,30 @@ export function setupGuestShortcutForwarding(args: { getKeybindings } = args let ctrlTabSwitching = false - const handler = (event: Electron.Event, input: Electron.Input): void => { + const doubleTapDetector = new ModifierDoubleTapDetector() + const resetDoubleTapDetector = (): void => doubleTapDetector.reset() + type GuestShortcutInput = WindowShortcutInput & { isAutoRepeat?: boolean } + + const forwardShortcutInput = ( + event: Electron.Event, + input: GuestShortcutInput, + action = resolveWindowShortcutAction(input, process.platform, getKeybindings?.()) + ): boolean => { const keybindings = getKeybindings?.() - if ( - input.type === 'keyDown' && - matchesRecentTabSwitcherChord(input, process.platform, keybindings) - ) { - event.preventDefault() - ctrlTabSwitching = true - const renderer = resolveRenderer(browserTabId) - renderer?.send('ui:ctrlTabKeyDown', { shiftKey: input.shift === true }) - return - } - - if (ctrlTabSwitching && isRecentTabSwitcherCommitRelease(input)) { - event.preventDefault() - ctrlTabSwitching = false - const renderer = resolveRenderer(browserTabId) - renderer?.send('ui:ctrlTabKeyUp') - return - } - - if (input.type !== 'keyDown') { - return - } - // Why: resolve the policy action once per keystroke. The history-navigate - // chord (Cmd/Ctrl+Alt+Arrow) is the only allowlisted chord that carries - // Alt and must be handled before the generic modifier-chord gate below, - // which rejects Alt. Every other chord handled further down can reuse - // the same `action` rather than re-running the full predicate chain. - const action = resolveWindowShortcutAction(input, process.platform, keybindings) if (action?.type === 'zoom') { // Why: browser page zoom must consume repeats and teardown races before // Chromium or the guest page can apply its own shortcut behavior. event.preventDefault() const renderer = resolveRenderer(browserTabId) renderer?.send('ui:zoomBrowserPage', action.direction) - return + return true } if (input.isAutoRepeat) { if (action?.type === 'dictationKeyDown' && shouldForwardDictationShortcut?.()) { event.preventDefault() + return true } - return + return false } if (action?.type === 'worktreeHistoryNavigate') { // Why: preventDefault unconditionally — if we cannot resolve the @@ -317,14 +303,14 @@ export function setupGuestShortcutForwarding(args: { event.preventDefault() const renderer = resolveRenderer(browserTabId) renderer?.send('ui:worktreeHistoryNavigate', action.direction) - return + return true } if (action?.type === 'toggleFloatingTerminal') { event.preventDefault() const renderer = resolveRenderer(browserTabId) renderer?.send('ui:toggleFloatingTerminal') - return + return true } // Why: Cmd/Ctrl+Alt+[ / ] cycles across every tab type. Handled before @@ -344,14 +330,14 @@ export function setupGuestShortcutForwarding(args: { event.preventDefault() const renderer = resolveRenderer(browserTabId) renderer?.send('ui:switchTabAcrossAllTypes', switchAllTypesDirection) - return + return true } if (keybindingMatchesAction('tab.previousRecent', input, process.platform, keybindings)) { event.preventDefault() const renderer = resolveRenderer(browserTabId) renderer?.send('ui:switchRecentTab') - return + return true } // Why: terminal-only tab switching defaults to Ctrl+PageUp/PageDown on every @@ -370,12 +356,12 @@ export function setupGuestShortcutForwarding(args: { event.preventDefault() const renderer = resolveRenderer(browserTabId) renderer?.send('ui:switchTerminalTab', terminalTabDirection) - return + return true } const renderer = resolveRenderer(browserTabId) if (!renderer) { - return + return false } if (keybindingMatchesAction('tab.newBrowser', input, process.platform, keybindings)) { renderer.send('ui:newBrowserTab') @@ -456,21 +442,84 @@ export function setupGuestShortcutForwarding(args: { renderer.send('ui:jumpToTabIndex', action.index) } else if (action?.type === 'dictationKeyDown') { if (!shouldForwardDictationShortcut?.()) { - return + return false } renderer.send('ui:dictationKeyDown') } else { - return + return false } // Why: preventDefault stops the guest page from also processing the chord // (e.g. Cmd+T opening a browser-internal new-tab page). event.preventDefault() + return true + } + + const handler = (event: Electron.Event, input: Electron.Input): void => { + const keybindings = getKeybindings?.() + if ( + input.type === 'keyDown' && + matchesRecentTabSwitcherChord(input, process.platform, keybindings) + ) { + event.preventDefault() + ctrlTabSwitching = true + const renderer = resolveRenderer(browserTabId) + renderer?.send('ui:ctrlTabKeyDown', { shiftKey: input.shift === true }) + return + } + + if (ctrlTabSwitching && isRecentTabSwitcherCommitRelease(input)) { + event.preventDefault() + ctrlTabSwitching = false + const renderer = resolveRenderer(browserTabId) + renderer?.send('ui:ctrlTabKeyUp') + return + } + + if (input.type === 'keyDown' || input.type === 'keyUp') { + const detected = doubleTapDetector.process( + toModifierDoubleTapEvent({ + type: input.type, + code: input.code, + key: input.key, + shift: input.shift, + control: input.control, + alt: input.alt, + meta: input.meta, + isAutoRepeat: input.isAutoRepeat + }), + Date.now() + ) + if (detected) { + const doubleTapInput: GuestShortcutInput = { doubleTapModifier: detected.modifier } + forwardShortcutInput( + event, + doubleTapInput, + resolveWindowShortcutAction(doubleTapInput, process.platform, keybindings, { + context: 'app' + }) + ) + return + } + } + + if (input.type !== 'keyDown') { + return + } + // Why: resolve the policy action once per keystroke. The history-navigate + // chord (Cmd/Ctrl+Alt+Arrow) is the only allowlisted chord that carries + // Alt and must be handled before the generic modifier-chord gate below, + // which rejects Alt. Every other chord handled further down can reuse + // the same `action` rather than re-running the full predicate chain. + const action = resolveWindowShortcutAction(input, process.platform, keybindings) + forwardShortcutInput(event, input, action) } guest.on('before-input-event', handler) + guest.on('blur', resetDoubleTapDetector) return () => { try { guest.off('before-input-event', handler) + guest.off('blur', resetDoubleTapDetector) } catch { // Why: best-effort — guest may already be destroyed during teardown. } diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index 934787a0e..6dd1d33d8 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -665,6 +665,100 @@ describe('createMainWindow', () => { expect(webContents.send).not.toHaveBeenCalled() }) + it('only intercepts double-tap dictation when enabled toggle mode can handle it', () => { + const windowHandlers: Record void> = {} + const webContents = { + on: vi.fn((event, handler) => { + windowHandlers[event] = handler + }), + setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), + setWindowOpenHandler: vi.fn(), + send: vi.fn(), + isDevToolsOpened: vi.fn(), + openDevTools: vi.fn(), + closeDevTools: vi.fn() + } + const browserWindowInstance = { + webContents, + on: vi.fn(), + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => true), + isFullScreen: vi.fn(() => false), + getSize: vi.fn(() => [1200, 800]), + setSize: vi.fn(), + maximize: vi.fn(), + show: vi.fn(), + loadFile: vi.fn(), + loadURL: vi.fn() + } + browserWindowMock.mockImplementation(function () { + return browserWindowInstance + }) + + const voice: { enabled: boolean; sttModel: string; dictationMode: 'toggle' | 'hold' } = { + enabled: false, + sttModel: '', + dictationMode: 'toggle' + } + createMainWindow( + { + getUI: () => ({}), + getSettings: () => ({ windowBackgroundBlur: false, voice }) as never, + updateUI: vi.fn() + } as never, + { + getKeybindings: () => ({ 'voice.dictation': ['DoubleTap+Shift'] }) + } + ) + + const triggerDoubleTapShift = (): ReturnType => { + const modifierInput = { + code: 'ShiftLeft', + key: 'Shift', + shift: true, + meta: false, + control: false, + alt: false + } + windowHandlers['before-input-event']( + { preventDefault: vi.fn() } as never, + { ...modifierInput, type: 'keyDown' } as never + ) + windowHandlers['before-input-event']( + { preventDefault: vi.fn() } as never, + { ...modifierInput, type: 'keyUp' } as never + ) + const preventDefault = vi.fn() + windowHandlers['before-input-event']( + { preventDefault } as never, + { ...modifierInput, type: 'keyDown' } as never + ) + windowHandlers['before-input-event']( + { preventDefault: vi.fn() } as never, + { ...modifierInput, type: 'keyUp' } as never + ) + return preventDefault + } + + const disabledPreventDefault = triggerDoubleTapShift() + expect(disabledPreventDefault).not.toHaveBeenCalled() + expect(webContents.send).not.toHaveBeenCalledWith('ui:dictationKeyDown') + + voice.enabled = true + voice.sttModel = 'test-model' + voice.dictationMode = 'hold' + const holdPreventDefault = triggerDoubleTapShift() + expect(holdPreventDefault).not.toHaveBeenCalled() + expect(webContents.send).not.toHaveBeenCalledWith('ui:dictationKeyDown') + + voice.dictationMode = 'toggle' + const togglePreventDefault = triggerDoubleTapShift() + expect(togglePreventDefault).toHaveBeenCalledTimes(1) + expect(webContents.send).toHaveBeenCalledWith('ui:dictationKeyDown') + }) + it('forwards ctrl/cmd+j to the worktree palette toggle event', () => { const windowHandlers: Record void> = {} const webContents = { @@ -792,6 +886,85 @@ describe('createMainWindow', () => { expect(webContents.send).not.toHaveBeenCalled() }) + it('allows double-tap shortcuts while terminal input is focused with Terminal-first policy', () => { + const windowHandlers: Record void> = {} + const webContents = { + on: vi.fn((event, handler) => { + windowHandlers[event] = handler + }), + setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), + setWindowOpenHandler: vi.fn(), + send: vi.fn(), + isDevToolsOpened: vi.fn(), + openDevTools: vi.fn(), + closeDevTools: vi.fn() + } + const browserWindowInstance = { + webContents, + on: vi.fn(), + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => true), + isFullScreen: vi.fn(() => false), + getSize: vi.fn(() => [1200, 800]), + setSize: vi.fn(), + maximize: vi.fn(), + show: vi.fn(), + loadFile: vi.fn(), + loadURL: vi.fn() + } + browserWindowMock.mockImplementation(function () { + return browserWindowInstance + }) + + createMainWindow( + { + getUI: () => ({}), + getSettings: () => ({ terminalShortcutPolicy: 'terminal-first' }) + } as never, + { + getKeybindings: () => ({ 'worktree.quickOpen': ['DoubleTap+Shift'] }) + } + ) + + const setFocusedListener = vi + .mocked(ipcMain.on) + .mock.calls.find(([channel]) => channel === 'ui:setTerminalInputFocused')?.[1] + expect(setFocusedListener).toBeTypeOf('function') + setFocusedListener?.({ sender: webContents } as never, true) + + const modifierInput = { + code: 'ShiftLeft', + key: 'Shift', + shift: true, + meta: false, + control: false, + alt: false + } + const firstDownPreventDefault = vi.fn() + windowHandlers['before-input-event']( + { preventDefault: firstDownPreventDefault } as never, + { ...modifierInput, type: 'keyDown' } as never + ) + const firstUpPreventDefault = vi.fn() + windowHandlers['before-input-event']( + { preventDefault: firstUpPreventDefault } as never, + { ...modifierInput, type: 'keyUp' } as never + ) + const secondDownPreventDefault = vi.fn() + windowHandlers['before-input-event']( + { preventDefault: secondDownPreventDefault } as never, + { ...modifierInput, type: 'keyDown' } as never + ) + + expect(firstDownPreventDefault).not.toHaveBeenCalled() + expect(firstUpPreventDefault).not.toHaveBeenCalled() + expect(secondDownPreventDefault).toHaveBeenCalledTimes(1) + expect(webContents.send).toHaveBeenCalledTimes(1) + expect(webContents.send).toHaveBeenCalledWith('ui:openQuickOpen') + }) + it('notifies before Orca-first captures a risky terminal-focused shortcut', () => { const windowHandlers: Record void> = {} const webContents = { @@ -857,6 +1030,83 @@ describe('createMainWindow', () => { expect(webContents.send).toHaveBeenNthCalledWith(2, 'ui:toggleWorktreePalette') }) + it('notifies before Orca-first captures a terminal-focused double-tap shortcut', () => { + const windowHandlers: Record void> = {} + const webContents = { + on: vi.fn((event, handler) => { + windowHandlers[event] = handler + }), + setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), + setWindowOpenHandler: vi.fn(), + send: vi.fn(), + isDevToolsOpened: vi.fn(), + openDevTools: vi.fn(), + closeDevTools: vi.fn() + } + const browserWindowInstance = { + webContents, + on: vi.fn(), + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => true), + isFullScreen: vi.fn(() => false), + getSize: vi.fn(() => [1200, 800]), + setSize: vi.fn(), + maximize: vi.fn(), + show: vi.fn(), + loadFile: vi.fn(), + loadURL: vi.fn() + } + browserWindowMock.mockImplementation(function () { + return browserWindowInstance + }) + + createMainWindow( + { + getUI: () => ({}), + getSettings: () => ({ terminalShortcutPolicy: 'orca-first' }) + } as never, + { + getKeybindings: () => ({ 'worktree.quickOpen': ['DoubleTap+Shift'] }) + } + ) + + const setFocusedListener = vi + .mocked(ipcMain.on) + .mock.calls.find(([channel]) => channel === 'ui:setTerminalInputFocused')?.[1] + expect(setFocusedListener).toBeTypeOf('function') + setFocusedListener?.({ sender: webContents } as never, true) + + const modifierInput = { + code: 'ShiftLeft', + key: 'Shift', + shift: true, + meta: false, + control: false, + alt: false + } + windowHandlers['before-input-event']( + { preventDefault: vi.fn() } as never, + { ...modifierInput, type: 'keyDown' } as never + ) + windowHandlers['before-input-event']( + { preventDefault: vi.fn() } as never, + { ...modifierInput, type: 'keyUp' } as never + ) + const preventDefault = vi.fn() + windowHandlers['before-input-event']( + { preventDefault } as never, + { ...modifierInput, type: 'keyDown' } as never + ) + + expect(preventDefault).toHaveBeenCalledTimes(1) + expect(webContents.send).toHaveBeenNthCalledWith(1, 'ui:terminalShortcutCaptured', { + actionId: 'worktree.quickOpen' + }) + expect(webContents.send).toHaveBeenNthCalledWith(2, 'ui:openQuickOpen') + }) + it('forwards the configured workspace delete shortcut while terminal input is focused', () => { const windowHandlers: Record void> = {} const webContents = { diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index cf380b0e0..0ff617b1b 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -15,8 +15,13 @@ import { getWindowShortcutActionId, matchesRecentTabSwitcherChord, resolveWindowShortcutAction, - windowShortcutActionCapturesTerminal + windowShortcutActionCapturesTerminal, + type WindowShortcutAction } from '../../shared/window-shortcut-policy' +import { + ModifierDoubleTapDetector, + toModifierDoubleTapEvent +} from '../../shared/modifier-double-tap-detector' import { keybindingMatchesAction, normalizeTerminalShortcutPolicy, @@ -655,6 +660,127 @@ export function createMainWindow( clearRendererRecoveryTimer() }) + const doubleTapDetector = new ModifierDoubleTapDetector() + + // Why: one place maps a resolved window-shortcut action to its IPC/side effect, + // reused by the normal keydown path and the double-tap path so they cannot drift. + const sendResolvedWindowShortcutAction = (action: WindowShortcutAction): void => { + switch (action.type) { + // The renderer's DictationController re-checks enabled/sttModel and ignores + // hold mode, so this path needs no voice guards. + case 'dictationKeyDown': + mainWindow.webContents.send('ui:dictationKeyDown') + return + case 'zoom': + mainWindow.webContents.send('terminal:zoom', action.direction) + return + case 'openSettings': + mainWindow.webContents.send('ui:openSettings') + return + case 'forceReload': + opts?.onBeforeReload?.({ ignoreCache: true, webContentsId: mainWindow.webContents.id }) + mainWindow.webContents.reloadIgnoringCache() + return + case 'toggleLeftSidebar': + mainWindow.webContents.send('ui:toggleLeftSidebar') + return + case 'toggleRightSidebar': + mainWindow.webContents.send('ui:toggleRightSidebar') + return + case 'toggleWorktreePalette': + mainWindow.webContents.send('ui:toggleWorktreePalette') + return + case 'toggleFloatingTerminal': + mainWindow.webContents.send('ui:toggleFloatingTerminal') + return + case 'openQuickOpen': + mainWindow.webContents.send('ui:openQuickOpen') + return + case 'openNewWorkspace': + mainWindow.webContents.send('ui:openNewWorkspace') + return + case 'deleteCurrentWorkspace': + mainWindow.webContents.send('ui:deleteCurrentWorkspace') + return + case 'openWorkspaceBoard': + mainWindow.webContents.send('ui:openWorkspaceBoard') + return + case 'openTasks': + mainWindow.webContents.send('ui:openTasks') + return + case 'switchRecentTab': + mainWindow.webContents.send('ui:switchRecentTab') + return + case 'jumpToWorktreeIndex': + mainWindow.webContents.send('ui:jumpToWorktreeIndex', action.index) + return + case 'jumpToTabIndex': + mainWindow.webContents.send('ui:jumpToTabIndex', action.index) + return + case 'worktreeHistoryNavigate': + mainWindow.webContents.send('ui:worktreeHistoryNavigate', action.direction) + } + } + + const dispatchResolvedWindowShortcutAction = ( + event: Electron.Event, + action: WindowShortcutAction, + options: { + isAutoRepeat: boolean + focusedShortcutContext: KeybindingMatchOptions + } + ): boolean => { + const { focusedShortcutContext, isAutoRepeat } = options + if ( + floatingTerminalInputFocused && + (action.type === 'toggleLeftSidebar' || action.type === 'toggleRightSidebar') + ) { + return false + } + + const capturedTerminalActionId = + focusedShortcutContext.context === 'terminal' && + focusedShortcutContext.terminalShortcutPolicy === 'orca-first' && + windowShortcutActionCapturesTerminal(action) + ? getWindowShortcutActionId(action) + : null + + // Why: hold-mode dictation needs renderer keyup events, so the main process + // may only consume shortcuts that toggle dictation from a single keydown. + if (action.type === 'dictationKeyDown') { + const voiceSettings = store?.getSettings().voice + if (!voiceSettings?.enabled || !voiceSettings.sttModel) { + return false + } + const dictationMode = voiceSettings.dictationMode ?? 'toggle' + if (dictationMode === 'hold') { + return false + } + if (isAutoRepeat) { + event.preventDefault() + return true + } + event.preventDefault() + if (capturedTerminalActionId) { + mainWindow.webContents.send('ui:terminalShortcutCaptured', { + actionId: capturedTerminalActionId + }) + } + mainWindow.webContents.send('ui:dictationKeyDown') + return true + } + + event.preventDefault() + if (capturedTerminalActionId) { + mainWindow.webContents.send('ui:terminalShortcutCaptured', { + actionId: capturedTerminalActionId + }) + } + + sendResolvedWindowShortcutAction(action) + return true + } + mainWindow.webContents.on('before-input-event', (event, input) => { if (shortcutRecorderFocused) { return @@ -677,6 +803,51 @@ export function createMainWindow( store?.getSettings().terminalShortcutPolicy ) } + const appShortcutContext: KeybindingMatchOptions = { + context: 'app', + terminalShortcutPolicy: terminalShortcutContext.terminalShortcutPolicy + } + + // Why: detect double-tap-modifier gestures on the raw key stream. A bare + // modifier emits no terminal bytes, so this never steals readline input. + if (input.type === 'keyDown' || input.type === 'keyUp') { + const detected = doubleTapDetector.process( + toModifierDoubleTapEvent({ + type: input.type, + code: input.code, + key: input.key, + shift: input.shift, + control: input.control, + alt: input.alt, + meta: input.meta, + isAutoRepeat: input.isAutoRepeat + }), + Date.now() + ) + if (detected) { + const doubleTapAction = resolveWindowShortcutAction( + { type: 'keyDown', doubleTapModifier: detected.modifier }, + process.platform, + keybindings, + appShortcutContext + ) + if ( + doubleTapAction && + dispatchResolvedWindowShortcutAction(event, doubleTapAction, { + isAutoRepeat: false, + focusedShortcutContext: terminalShortcutContext + }) + ) { + // Only preventDefault the emitting keydown — never the first tap's + // down/up. This suppresses the renderer DOM keydown so the renderer + // detector cannot also fire for the same gesture. + return + } + // No allowlisted action: let the keydown reach the renderer, whose + // detector completes and dispatches inline. + } + } + if ( input.type === 'keyDown' && matchesRecentTabSwitcherChord(input, process.platform, keybindings, terminalShortcutContext) @@ -715,156 +886,20 @@ export function createMainWindow( return } - // Why: keep global app routing for non-terminal actions, but let floating - // xterm own shell control chars that overlap sidebar chrome shortcuts. - if ( - floatingTerminalInputFocused && - (action.type === 'toggleLeftSidebar' || action.type === 'toggleRightSidebar') - ) { - return - } - if (input.type !== 'keyDown') { return } - const capturedTerminalActionId = - terminalShortcutContext.context === 'terminal' && - terminalShortcutContext.terminalShortcutPolicy === 'orca-first' && - windowShortcutActionCapturesTerminal(action) - ? getWindowShortcutActionId(action) - : null - - // Why: in hold mode, Cmd+E must NOT be intercepted here. Calling - // preventDefault() in before-input-event suppresses ALL subsequent DOM - // events for the key combo — including the keyUp the renderer needs to - // detect release. By letting the event through, the renderer's - // capture-phase DOM listeners handle both keydown and keyup normally. - // Toggle mode still uses the IPC path since it doesn't need keyUp. - if (action.type === 'dictationKeyDown') { - const voiceSettings = store?.getSettings().voice - if (!voiceSettings?.enabled || !voiceSettings.sttModel) { - return - } - const dictationMode = voiceSettings.dictationMode ?? 'toggle' - if (dictationMode === 'hold') { - return - } - if (input.isAutoRepeat) { - event.preventDefault() - return - } - event.preventDefault() - if (capturedTerminalActionId) { - mainWindow.webContents.send('ui:terminalShortcutCaptured', { - actionId: capturedTerminalActionId - }) - } - mainWindow.webContents.send('ui:dictationKeyDown') - return - } - - event.preventDefault() - if (capturedTerminalActionId) { - mainWindow.webContents.send('ui:terminalShortcutCaptured', { - actionId: capturedTerminalActionId - }) - } - - if (action.type === 'zoom') { - mainWindow.webContents.send('terminal:zoom', action.direction) - return - } - - if (action.type === 'openSettings') { - mainWindow.webContents.send('ui:openSettings') - return - } - - if (action.type === 'forceReload') { - opts?.onBeforeReload?.({ - ignoreCache: true, - webContentsId: mainWindow.webContents.id - }) - mainWindow.webContents.reloadIgnoringCache() - return - } - - if (action.type === 'toggleLeftSidebar') { - mainWindow.webContents.send('ui:toggleLeftSidebar') - return - } - - if (action.type === 'toggleRightSidebar') { - mainWindow.webContents.send('ui:toggleRightSidebar') - return - } - - if (action.type === 'toggleWorktreePalette') { - // Why: embedded browser guests can keep keyboard focus inside Chromium's - // guest webContents, which bypasses the renderer's window-level keydown - // listener. Forward the worktree-switch shortcut through the main window - // so Cmd+J (macOS) or Ctrl+Shift+J (Win/Linux) works consistently from browser tabs too. - mainWindow.webContents.send('ui:toggleWorktreePalette') - return - } - - if (action.type === 'toggleFloatingTerminal') { - mainWindow.webContents.send('ui:toggleFloatingTerminal') - return - } - - if (action.type === 'openQuickOpen') { - mainWindow.webContents.send('ui:openQuickOpen') - return - } - - if (action.type === 'openNewWorkspace') { - // Why: routed through the main process so focus contexts that bypass - // the renderer's window-level keydown (contentEditable markdown editor, - // browser-guest webContents) still reach the new-workspace composer. - mainWindow.webContents.send('ui:openNewWorkspace') - return - } - - if (action.type === 'deleteCurrentWorkspace') { - mainWindow.webContents.send('ui:deleteCurrentWorkspace') - return - } - - if (action.type === 'openWorkspaceBoard') { - mainWindow.webContents.send('ui:openWorkspaceBoard') - return - } - - if (action.type === 'openTasks') { - mainWindow.webContents.send('ui:openTasks') - return - } - - if (action.type === 'switchRecentTab') { - mainWindow.webContents.send('ui:switchRecentTab') - return - } - - if (action.type === 'jumpToWorktreeIndex') { - mainWindow.webContents.send('ui:jumpToWorktreeIndex', action.index) - return - } - - if (action.type === 'jumpToTabIndex') { - mainWindow.webContents.send('ui:jumpToTabIndex', action.index) - return - } - - if (action.type === 'worktreeHistoryNavigate') { - // Why: routed through main so the chord reaches the renderer even when - // a terminal (xterm.js) or a browser guest has focus — both surfaces - // otherwise absorb Arrow keys before the renderer's window listener. - mainWindow.webContents.send('ui:worktreeHistoryNavigate', action.direction) - } + dispatchResolvedWindowShortcutAction(event, action, { + isAutoRepeat: Boolean(input.isAutoRepeat), + focusedShortcutContext: terminalShortcutContext + }) }) + // Why: a mid-gesture focus loss must not leave the detector armed so the next + // unrelated modifier press completes a phantom double-tap. + mainWindow.on('blur', () => doubleTapDetector.reset()) + mainWindow.webContents.on('zoom-changed', (event, zoomDirection) => { // Why: Some keyboard layouts/platforms consume Ctrl/Cmd+Minus before // before-input-event fires, but still emit Electron's zoom command. Keep diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index fb73d8b11..0e5cf3a97 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -136,8 +136,13 @@ import { import { keybindingMatchesAction, type KeybindingActionId, - type KeybindingContext + type KeybindingContext, + type PhysicalModifierToken } from '../../shared/keybindings' +import { + ModifierDoubleTapDetector, + toModifierDoubleTapEvent +} from '../../shared/modifier-double-tap-detector' import { isGitRepoKind } from '../../shared/repo-kind' import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification' import { resolveMountedLazyModalIds, type LazyModalId } from './lazy-modal-mount-state' @@ -153,6 +158,22 @@ function getKeybindingContext(target: EventTarget | null): KeybindingContext { : 'app' } +// Abstraction over a real KeyboardEvent and a synthetic double-tap gesture so a +// single dispatch path serves both. KeybindingInput-compatible (key/code + +// modifier flags) so it flows straight into keybindingMatchesAction. +type ShortcutDispatchInput = { + key?: string + code?: string + altKey?: boolean + metaKey?: boolean + ctrlKey?: boolean + shiftKey?: boolean + doubleTapModifier?: PhysicalModifierToken + target: EventTarget | null + defaultPrevented: boolean + preventDefault: () => void +} + // Why: 'hidden' titleBarStyle on Windows removes the native OS title bar, // so we render our own minimize/maximize/close buttons. These SVG icons match // the Fluent/Win11 style: thin 10×10 paths on a 40×30 hit area. @@ -1347,26 +1368,25 @@ function App(): React.JSX.Element { } useEffect(() => { - const onKeyDown = (e: KeyboardEvent): void => { - if (e.repeat) { - return - } + const doubleTapDetector = new ModifierDoubleTapDetector() + + const dispatchShortcutInput = (input: ShortcutDispatchInput): void => { // Why: child-component handlers (e.g. terminal search Cmd+G / Cmd+Shift+G) // register on the same window capture phase and fire first. If they already // called preventDefault, this handler must not also act on the event — // otherwise both actions execute (e.g. search navigation AND sidebar open). - if (e.defaultPrevented) { + if (input.defaultPrevented) { return } // Why: the Settings recorder intentionally captures existing app // shortcuts, so global handlers must not fire while its button has focus. if ( - e.target instanceof Element && - e.target.closest('[data-shortcut-recorder-active]') !== null + input.target instanceof Element && + input.target.closest('[data-shortcut-recorder-active]') !== null ) { return } - const context = getKeybindingContext(e.target) + const context = getKeybindingContext(input.target) // Note: some app-level shortcuts are also intercepted via // before-input-event in createMainWindow.ts so they still work when a @@ -1374,7 +1394,7 @@ function App(): React.JSX.Element { // local-focus cases and to preserve the same guards in one place. const matchShortcut = (actionId: KeybindingActionId): boolean => - keybindingMatchesAction(actionId, e, shortcutPlatform, keybindings, { + keybindingMatchesAction(actionId, input, shortcutPlatform, keybindings, { context, terminalShortcutPolicy: settings?.terminalShortcutPolicy }) @@ -1407,7 +1427,7 @@ function App(): React.JSX.Element { ? selectedExplorerFolderRelativePath(document.activeElement) : null if (selectedFolderRelativePath !== null && activeWorktreeId) { - e.preventDefault() + input.preventDefault() notifyTerminalCapture('sidebar.search.toggle') actions.showRightSidebarSearch({ includePattern: folderRelativePathToIncludeGlob(selectedFolderRelativePath) @@ -1417,7 +1437,7 @@ function App(): React.JSX.Element { const selectedText = getSelectedTextForFileSearch() if (selectedText) { - e.preventDefault() + input.preventDefault() notifyTerminalCapture('sidebar.search.toggle') openSearchSidebar(selectedText) return @@ -1427,7 +1447,7 @@ function App(): React.JSX.Element { // Why: an empty floating workspace has no tab to close; Cmd/Ctrl+W // should hide that transient overlay before underlying app surfaces act. if ( - keybindingMatchesAction('tab.close', e, shortcutPlatform, keybindings, { + keybindingMatchesAction('tab.close', input, shortcutPlatform, keybindings, { context: 'app' }) && shouldMinimizeFloatingWorkspacePanelOnCloseShortcut({ @@ -1435,7 +1455,7 @@ function App(): React.JSX.Element { floatingVisibleTabCount }) ) { - e.preventDefault() + input.preventDefault() setFloatingTerminalOpenWithFocus(false) return } @@ -1446,14 +1466,14 @@ function App(): React.JSX.Element { // Cmd+B for the markdown editor (see createMainWindow.ts + // docs/markdown-cmd-b-bold-design.md), but this renderer-side fallback // still covers the blur→press IPC race and any non-carved editable surface. - if (isEditableTarget(e.target)) { + if (isEditableTarget(input.target)) { return } // Why: xterm's helper textarea is intentionally not a generic editable // target, but floating-terminal SSH/tmux control chords must still reach // the terminal instead of app-level chrome shortcuts. - if (isFloatingWorkspaceTerminalInputTarget(e.target)) { + if (isFloatingWorkspaceTerminalInputTarget(input.target)) { return } @@ -1466,7 +1486,7 @@ function App(): React.JSX.Element { if (creationLayoutActive || !shouldShowWorktreeHistoryControls(activeView)) { return } - e.preventDefault() + input.preventDefault() const store = useAppStore.getState() if (matchShortcut('worktree.history.back')) { store.goBackWorktree() @@ -1484,7 +1504,7 @@ function App(): React.JSX.Element { const floatingWorkspaceFocused = isFloatingWorkspacePanelFocused() if (floatingWorkspaceFocused) { if ( - isFloatingWorkspacePanelShortcut(e, shortcutPlatform, null, keybindings, { + isFloatingWorkspacePanelShortcut(input, shortcutPlatform, null, keybindings, { context, terminalShortcutPolicy: settings?.terminalShortcutPolicy }) @@ -1495,7 +1515,7 @@ function App(): React.JSX.Element { // Cmd/Ctrl+B — toggle left sidebar if (matchShortcut('sidebar.left.toggle')) { - e.preventDefault() + input.preventDefault() notifyTerminalCapture('sidebar.left.toggle') actions.toggleSidebar() return @@ -1508,7 +1528,7 @@ function App(): React.JSX.Element { if (workspaceChromeActive && !floatingWorkspaceFocused && matchShortcut('tab.rename')) { const store = useAppStore.getState() if (store.activeTabType === 'terminal' && store.activeTabId) { - e.preventDefault() + input.preventDefault() notifyTerminalCapture('tab.rename') store.setRenamingTabId(store.activeTabId) return @@ -1524,7 +1544,7 @@ function App(): React.JSX.Element { matchShortcut('workspace.rename') && activeWorktreeId ) { - e.preventDefault() + input.preventDefault() notifyTerminalCapture('workspace.rename') const store = useAppStore.getState() store.setSidebarOpen(true) @@ -1533,7 +1553,7 @@ function App(): React.JSX.Element { } if (matchShortcut('workspace.openBoard') && activeView !== 'settings') { - e.preventDefault() + input.preventDefault() notifyTerminalCapture('workspace.openBoard') const store = useAppStore.getState() store.setSidebarOpen(true) @@ -1552,7 +1572,7 @@ function App(): React.JSX.Element { if (matchShortcut('view.tasks') && activeView !== 'settings') { const store = useAppStore.getState() if (store.repos.some((repo) => isGitRepoKind(repo))) { - e.preventDefault() + input.preventDefault() notifyTerminalCapture('view.tasks') store.openTaskPage() } @@ -1565,7 +1585,7 @@ function App(): React.JSX.Element { // Cmd/Ctrl+L — toggle right sidebar if (matchShortcut('sidebar.right.toggle')) { - e.preventDefault() + input.preventDefault() notifyTerminalCapture('sidebar.right.toggle') actions.toggleRightSidebar() return @@ -1573,7 +1593,7 @@ function App(): React.JSX.Element { // Cmd/Ctrl+Shift+E — toggle right sidebar / explorer tab if (matchShortcut('sidebar.explorer.toggle')) { - e.preventDefault() + input.preventDefault() notifyTerminalCapture('sidebar.explorer.toggle') actions.showRightSidebarFiles() return @@ -1581,7 +1601,7 @@ function App(): React.JSX.Element { // Cmd/Ctrl+Shift+F — toggle right sidebar / search tab if (matchShortcut('sidebar.search.toggle')) { - e.preventDefault() + input.preventDefault() notifyTerminalCapture('sidebar.search.toggle') openSearchSidebar(null) return @@ -1596,7 +1616,7 @@ function App(): React.JSX.Element { if (document.querySelector('[data-terminal-search-root]')) { return } - e.preventDefault() + input.preventDefault() notifyTerminalCapture('sidebar.sourceControl.toggle') actions.setRightSidebarTab('source-control') actions.setRightSidebarOpen(true) @@ -1604,7 +1624,7 @@ function App(): React.JSX.Element { } if (matchShortcut('sidebar.checks.toggle')) { - e.preventDefault() + input.preventDefault() notifyTerminalCapture('sidebar.checks.toggle') actions.setRightSidebarTab('checks') actions.setRightSidebarOpen(true) @@ -1615,15 +1635,79 @@ function App(): React.JSX.Element { // Why: Ctrl+Shift+I is the built-in DevTools accelerator on Windows/Linux; // intercepting it would break an essential developer tool. if (matchShortcut('sidebar.ports.toggle')) { - e.preventDefault() + input.preventDefault() notifyTerminalCapture('sidebar.ports.toggle') actions.setRightSidebarTab('ports') actions.setRightSidebarOpen(true) } } + const onKeyDown = (e: KeyboardEvent): void => { + const detected = doubleTapDetector.process( + toModifierDoubleTapEvent({ + type: 'keyDown', + code: e.code, + key: e.key, + shift: e.shiftKey, + control: e.ctrlKey, + alt: e.altKey, + meta: e.metaKey, + isAutoRepeat: e.repeat + }), + Date.now() + ) + if (e.repeat) { + return + } + if (detected) { + // Synthetic input: no key/modifier flags, so only DoubleTap bindings match. + dispatchShortcutInput({ + doubleTapModifier: detected.modifier, + target: e.target, + defaultPrevented: e.defaultPrevented, + preventDefault: () => e.preventDefault() + }) + return + } + dispatchShortcutInput({ + key: e.key, + code: e.code, + altKey: e.altKey, + metaKey: e.metaKey, + ctrlKey: e.ctrlKey, + shiftKey: e.shiftKey, + target: e.target, + defaultPrevented: e.defaultPrevented, + preventDefault: () => e.preventDefault() + }) + } + + const onKeyUp = (e: KeyboardEvent): void => { + doubleTapDetector.process( + toModifierDoubleTapEvent({ + type: 'keyUp', + code: e.code, + key: e.key, + shift: e.shiftKey, + control: e.ctrlKey, + alt: e.altKey, + meta: e.metaKey + }), + Date.now() + ) + } + + // Why: a window blur mid-gesture must not leave the detector armed. + const onBlur = (): void => doubleTapDetector.reset() + window.addEventListener('keydown', onKeyDown, { capture: true }) - return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) + window.addEventListener('keyup', onKeyUp, { capture: true }) + window.addEventListener('blur', onBlur) + return () => { + window.removeEventListener('keydown', onKeyDown, { capture: true }) + window.removeEventListener('keyup', onKeyUp, { capture: true }) + window.removeEventListener('blur', onBlur) + } }, [ activeView, activeWorktreeId, diff --git a/src/renderer/src/components/Landing.tsx b/src/renderer/src/components/Landing.tsx index 74fd63814..0bce8ca7d 100644 --- a/src/renderer/src/components/Landing.tsx +++ b/src/renderer/src/components/Landing.tsx @@ -4,14 +4,14 @@ import { cn } from '../lib/utils' import { useAppStore } from '../store' import { isGitRepoKind } from '../../../shared/repo-kind' import { ShortcutKeyCombo } from './ShortcutKeyCombo' -import { useShortcutKeys } from '@/hooks/useShortcutLabel' +import { useShortcutKeyDetails, type ShortcutKeyComboDetails } from '@/hooks/useShortcutLabel' import { useMountedRef } from '@/hooks/useMountedRef' import logo from '../../../../resources/logo.svg' import { translate } from '@/i18n/i18n' type ShortcutItem = { id: string - keys: string[] + shortcut: ShortcutKeyComboDetails action: string } @@ -285,20 +285,20 @@ export default function Landing(): React.JSX.Element { } }, [preflightIssues.length]) - const createWorktreeKeys = useShortcutKeys('workspace.create') - const previousWorktreeKeys = useShortcutKeys('worktree.navigateUp') - const nextWorktreeKeys = useShortcutKeys('worktree.navigateDown') + const createWorktreeShortcut = useShortcutKeyDetails('workspace.create') + const previousWorktreeShortcut = useShortcutKeyDetails('worktree.navigateUp') + const nextWorktreeShortcut = useShortcutKeyDetails('worktree.navigateDown') const shortcuts = useMemo(() => { return [ { id: 'create', - keys: createWorktreeKeys, + shortcut: createWorktreeShortcut, action: `Create ${createTargetLabel.toLowerCase()}` }, - { id: 'up', keys: previousWorktreeKeys, action: 'Move up workspace' }, - { id: 'down', keys: nextWorktreeKeys, action: 'Move down workspace' } + { id: 'up', shortcut: previousWorktreeShortcut, action: 'Move up workspace' }, + { id: 'down', shortcut: nextWorktreeShortcut, action: 'Move down workspace' } ] - }, [createTargetLabel, createWorktreeKeys, nextWorktreeKeys, previousWorktreeKeys]) + }, [createTargetLabel, createWorktreeShortcut, nextWorktreeShortcut, previousWorktreeShortcut]) return (
@@ -359,7 +359,8 @@ export default function Landing(): React.JSX.Element {
{shortcut.action}
diff --git a/src/renderer/src/components/ShortcutKeyCombo.tsx b/src/renderer/src/components/ShortcutKeyCombo.tsx index 8ec8c3a34..40c9d839c 100644 --- a/src/renderer/src/components/ShortcutKeyCombo.tsx +++ b/src/renderer/src/components/ShortcutKeyCombo.tsx @@ -1,5 +1,6 @@ import React from 'react' import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' function KeyCap({ label, className }: { label: string; className?: string }): React.JSX.Element { return ( @@ -20,24 +21,33 @@ type ShortcutKeyComboProps = { separatorClassName?: string // Override cap colors when chips sit on a non-default surface (e.g. a filled primary card). keyCapClassName?: string + // When true the chips render a double-tap gesture: no "+" separator (reads + // "Shift Shift"), with a title clarifying the gesture. Note: the title uses + // the displayed label, so on Mac it reads as the glyph (e.g. 'Double-tap ⇧'). + doubleTap?: boolean } export function ShortcutKeyCombo({ keys, className, separatorClassName, - keyCapClassName + keyCapClassName, + doubleTap = false }: ShortcutKeyComboProps): React.JSX.Element { const isMac = navigator.userAgent.includes('Mac') return ( - + 0 ? translate("auto.components.ShortcutKeyCombo.07eb4985a1", "Double-tap {{value0}}", { value0: keys[0] }) : undefined} + > {keys.map((key, index) => ( {/* Why: Orca renders Mac shortcuts as adjacent glyphs, but Windows/Linux - shortcuts read more naturally with explicit "+" separators. */} - {!isMac && index < keys.length - 1 ? ( + shortcuts read more naturally with explicit "+" separators. A + double-tap reads as the same key twice, so it gets a space, not "+". */} + {!isMac && !doubleTap && index < keys.length - 1 ? ( + ) : null} diff --git a/src/renderer/src/components/editor/IpynbViewer.tsx b/src/renderer/src/components/editor/IpynbViewer.tsx index 31abbc557..b780d1956 100644 --- a/src/renderer/src/components/editor/IpynbViewer.tsx +++ b/src/renderer/src/components/editor/IpynbViewer.tsx @@ -49,7 +49,7 @@ import { } from '@/components/ui/dialog' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' -import { useShortcutKeys } from '@/hooks/useShortcutLabel' +import { useShortcutKeyDetails, type ShortcutKeyComboDetails } from '@/hooks/useShortcutLabel' import { registerPendingEditorFlush } from './editor-pending-flush' import { editorShortcutMatches, installEditorSaveShortcut } from './editor-shortcuts' import MonacoCodeExcerpt from './MonacoCodeExcerpt' @@ -264,13 +264,13 @@ function NotebookCellHeader({ function NotebookHeaderButton({ label, disabled = false, - shortcutKeys, + shortcut, onClick, children }: { label: string disabled?: boolean - shortcutKeys?: string[] + shortcut?: ShortcutKeyComboDetails onClick: () => void children: React.ReactNode }): React.JSX.Element { @@ -292,7 +292,9 @@ function NotebookHeaderButton({ {label} - {shortcutKeys ? : null} + {shortcut && shortcut.keys.length > 0 ? ( + + ) : null} @@ -729,7 +731,7 @@ export default function IpynbViewer({ const latestContent = flushSourceDrafts() await onSave(latestContent) }, [flushSourceDrafts, onSave]) - const saveShortcutKeys = useShortcutKeys('editor.save') + const saveShortcut = useShortcutKeyDetails('editor.save') const handleNotebookKeyDownCapture = useCallback( (event: React.KeyboardEvent): void => { @@ -885,7 +887,7 @@ export default function IpynbViewer({
void saveNotebook()} > diff --git a/src/renderer/src/components/feature-tips/CmdJPaletteFeatureTipVisual.test.tsx b/src/renderer/src/components/feature-tips/CmdJPaletteFeatureTipVisual.test.tsx index eec4c82bb..d30835677 100644 --- a/src/renderer/src/components/feature-tips/CmdJPaletteFeatureTipVisual.test.tsx +++ b/src/renderer/src/components/feature-tips/CmdJPaletteFeatureTipVisual.test.tsx @@ -7,16 +7,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { CmdJPaletteFeatureTipVisual } from './CmdJPaletteFeatureTipVisual' const prefersReducedMotionMock = vi.hoisted(() => vi.fn(() => false)) -const shortcutKeysMock = vi.hoisted(() => vi.fn(() => ['⌘', 'J'])) -const formatShortcutKeysMock = vi.hoisted(() => vi.fn(() => ['⌘', 'J'])) +const shortcutMock = vi.hoisted(() => vi.fn(() => ({ keys: ['⌘', 'J'], doubleTap: false }))) +const formatShortcutMock = vi.hoisted(() => vi.fn(() => [{ keys: ['⌘', 'J'], doubleTap: false }])) vi.mock('@/components/feature-wall/feature-wall-modal-helpers', () => ({ usePrefersReducedMotion: prefersReducedMotionMock })) vi.mock('@/hooks/useShortcutLabel', () => ({ - useShortcutKeys: shortcutKeysMock, - formatShortcutKeys: formatShortcutKeysMock + useShortcutKeyDetails: shortcutMock, + formatShortcutKeyComboDetails: formatShortcutMock })) async function renderVisual(): Promise<{ container: HTMLDivElement; root: Root }> { @@ -34,8 +34,8 @@ async function renderVisual(): Promise<{ container: HTMLDivElement; root: Root } describe('CmdJPaletteFeatureTipVisual', () => { beforeEach(() => { prefersReducedMotionMock.mockReturnValue(false) - shortcutKeysMock.mockReturnValue(['⌘', 'J']) - formatShortcutKeysMock.mockReturnValue(['⌘', 'J']) + shortcutMock.mockReturnValue({ keys: ['⌘', 'J'], doubleTap: false }) + formatShortcutMock.mockReturnValue([{ keys: ['⌘', 'J'], doubleTap: false }]) }) afterEach(() => { @@ -110,12 +110,12 @@ describe('CmdJPaletteFeatureTipVisual', () => { }) it('falls back to default per-key chips when the live binding is unassigned', () => { - shortcutKeysMock.mockReturnValue([]) - formatShortcutKeysMock.mockReturnValue(['Ctrl', 'Shift', 'J']) + shortcutMock.mockReturnValue({ keys: [], doubleTap: false }) + formatShortcutMock.mockReturnValue([{ keys: ['Ctrl', 'Shift', 'J'], doubleTap: false }]) const html = renderToStaticMarkup() - expect(formatShortcutKeysMock).toHaveBeenCalledWith('worktree.palette') + expect(formatShortcutMock).toHaveBeenCalledWith('worktree.palette') expect(html).toContain('Ctrl') expect(html).toContain('Shift') expect(html).toContain('J') @@ -130,7 +130,7 @@ describe('CmdJPaletteFeatureTipVisual', () => { }) it('renders the live binding as separate shortcut key chips with plus separators', () => { - shortcutKeysMock.mockReturnValue(['⌘', 'J']) + shortcutMock.mockReturnValue({ keys: ['⌘', 'J'], doubleTap: false }) const html = renderToStaticMarkup() @@ -138,4 +138,13 @@ describe('CmdJPaletteFeatureTipVisual', () => { expect(html).toContain('J') expect(html).toContain('+') }) + + it('renders double-tap shortcut chips without plus separators', () => { + shortcutMock.mockReturnValue({ keys: ['⇧', '⇧'], doubleTap: true }) + + const html = renderToStaticMarkup() + + expect(html).toContain('⇧') + expect(html).not.toContain('+') + }) }) diff --git a/src/renderer/src/components/feature-tips/CmdJPaletteFeatureTipVisual.tsx b/src/renderer/src/components/feature-tips/CmdJPaletteFeatureTipVisual.tsx index 080e54b90..2aaa5402f 100644 --- a/src/renderer/src/components/feature-tips/CmdJPaletteFeatureTipVisual.tsx +++ b/src/renderer/src/components/feature-tips/CmdJPaletteFeatureTipVisual.tsx @@ -1,7 +1,7 @@ import { Fragment, useEffect, useState, type JSX } from 'react' import { Plus, Search } from 'lucide-react' import { usePrefersReducedMotion } from '@/components/feature-wall/feature-wall-modal-helpers' -import { formatShortcutKeys, useShortcutKeys } from '@/hooks/useShortcutLabel' +import { formatShortcutKeyComboDetails, useShortcutKeyDetails } from '@/hooks/useShortcutLabel' import { translate } from '@/i18n/i18n' const TYPED_QUERY = 'auth' @@ -49,11 +49,13 @@ export function CmdJPaletteFeatureTipVisual(): JSX.Element { const reducedMotion = usePrefersReducedMotion() // Why: render the live binding so the cue stays correct after a rebind and on // platforms where Cmd+J is not the default (Linux/Windows use Ctrl+Shift+J). - const shortcutKeys = useShortcutKeys('worktree.palette') + const shortcut = useShortcutKeyDetails('worktree.palette') // Why: the press animation staggers per-key chips (⌘ then J); fall back to the // platform default when the user disables the binding. - const displayShortcutKeys = - shortcutKeys.length > 0 ? shortcutKeys : formatShortcutKeys('worktree.palette') + const displayShortcut = + shortcut.keys.length > 0 ? shortcut : formatShortcutKeyComboDetails('worktree.palette')[0] + const displayShortcutKeys = displayShortcut?.keys ?? [] + const displayShortcutDoubleTap = displayShortcut?.doubleTap === true const [phase, setPhase] = useState('idle') const [typedLength, setTypedLength] = useState(0) @@ -135,7 +137,7 @@ export function CmdJPaletteFeatureTipVisual(): JSX.Element {
{displayShortcutKeys.map((key, index) => ( - {index > 0 ? ( + {index > 0 && !displayShortcutDoubleTap ? ( diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx index 86d591dc3..29ffe3ceb 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx @@ -3,6 +3,7 @@ * asserted without mounting the full Electron renderer. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import type { KeybindingOverrides } from '../../../../shared/keybindings' import type { BrowserTab, Tab, TabGroup, TerminalTab } from '../../../../shared/types' import type { OpenFile } from '@/store/slices/editor' import { createUntitledMarkdownFileWithTemplateSelection } from '@/lib/create-untitled-markdown' @@ -61,6 +62,7 @@ type FloatingPanelStoreState = { pinFile: (fileId: string, tabId?: string) => void openFile: (file: unknown, options?: unknown) => void browserDefaultUrl: string + keybindings?: KeybindingOverrides tabBarOrderByWorktree: Record settings: { activeRuntimeEnvironmentId?: string | null; floatingTerminalCwd?: string } } @@ -430,6 +432,7 @@ function resetStore(tabs: TerminalTab[] = []): void { setTabColor: mocks.setTabColor, setTabPaneExpanded: mocks.setTabPaneExpanded, browserDefaultUrl: 'about:blank', + keybindings: {}, tabBarOrderByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: tabs.map((tab) => tab.id) }, settings: { floatingTerminalCwd: '' } } satisfies FloatingPanelStoreState @@ -1162,6 +1165,143 @@ describe('FloatingTerminalPanel close behavior', () => { expect(mocks.pickFloatingMarkdownDocument).toHaveBeenCalledWith() }) + it('routes focused floating terminal double-tap shortcuts to the floating workspace', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' })]) + ;(storeBox.state as FloatingPanelStoreState).keybindings = { + 'tab.newTerminal': ['DoubleTap+Shift'] + } + const element = await renderPanel(true) + const panel = findByProp(element, 'data-floating-terminal-panel') + const panelElement = { contains: vi.fn().mockReturnValue(true), focus: vi.fn() } + const target = { + classList: { contains: vi.fn((token: string) => token === 'xterm-helper-textarea') }, + closest: vi.fn((selector: string) => + selector === '[data-floating-terminal-panel]' ? panelElement : null + ) + } + Object.setPrototypeOf(target, HTMLElement.prototype) + attachRef(panel.props.ref, panelElement) + vi.stubGlobal('document', { + activeElement: target, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + }) + runEffects() + const keydownListener = vi + .mocked(window.addEventListener) + .mock.calls.find(([type]) => type === 'keydown')?.[1] as + | ((event: unknown) => void) + | undefined + const keyupListener = vi + .mocked(window.addEventListener) + .mock.calls.find(([type]) => type === 'keyup')?.[1] as ((event: unknown) => void) | undefined + if (!keydownListener || !keyupListener) { + throw new Error('keyboard listeners not registered') + } + + const modifierEvent = { + altKey: false, + code: 'ShiftLeft', + ctrlKey: false, + defaultPrevented: false, + key: 'Shift', + metaKey: false, + repeat: false, + shiftKey: true, + target + } + const firstPreventDefault = vi.fn() + keydownListener({ ...modifierEvent, preventDefault: firstPreventDefault }) + keyupListener({ ...modifierEvent }) + const preventDefault = vi.fn() + const stopPropagation = vi.fn() + const stopImmediatePropagation = vi.fn() + keydownListener({ + ...modifierEvent, + preventDefault, + stopImmediatePropagation, + stopPropagation + }) + await flushAsyncWork() + + expect(firstPreventDefault).not.toHaveBeenCalled() + expect(preventDefault).toHaveBeenCalledWith() + expect(stopPropagation).toHaveBeenCalledWith() + expect(stopImmediatePropagation).toHaveBeenCalledWith() + expect(mocks.createTab).toHaveBeenCalledTimes(1) + expect(mocks.createTab).toHaveBeenCalledWith( + FLOATING_TERMINAL_WORKTREE_ID, + 'floating-group', + undefined, + { activate: false } + ) + expect(mocks.activateTab).toHaveBeenCalledWith('created-tab') + }) + + it('resets focused floating terminal double-tap detection on window blur', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' })]) + ;(storeBox.state as FloatingPanelStoreState).keybindings = { + 'tab.newTerminal': ['DoubleTap+Shift'] + } + const element = await renderPanel(true) + const panel = findByProp(element, 'data-floating-terminal-panel') + const panelElement = { contains: vi.fn().mockReturnValue(true), focus: vi.fn() } + const target = { + classList: { contains: vi.fn((token: string) => token === 'xterm-helper-textarea') }, + closest: vi.fn((selector: string) => + selector === '[data-floating-terminal-panel]' ? panelElement : null + ) + } + Object.setPrototypeOf(target, HTMLElement.prototype) + attachRef(panel.props.ref, panelElement) + vi.stubGlobal('document', { + activeElement: target, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + }) + runEffects() + const keydownListener = vi + .mocked(window.addEventListener) + .mock.calls.find(([type]) => type === 'keydown')?.[1] as + | ((event: unknown) => void) + | undefined + const keyupListener = vi + .mocked(window.addEventListener) + .mock.calls.find(([type]) => type === 'keyup')?.[1] as ((event: unknown) => void) | undefined + const blurListener = vi + .mocked(window.addEventListener) + .mock.calls.find(([type]) => type === 'blur')?.[1] as (() => void) | undefined + if (!keydownListener || !keyupListener || !blurListener) { + throw new Error('keyboard listeners not registered') + } + + const modifierEvent = { + altKey: false, + code: 'ShiftLeft', + ctrlKey: false, + defaultPrevented: false, + key: 'Shift', + metaKey: false, + repeat: false, + shiftKey: true, + target + } + keydownListener({ ...modifierEvent, preventDefault: vi.fn() }) + keyupListener({ ...modifierEvent }) + blurListener() + const preventDefault = vi.fn() + keydownListener({ + ...modifierEvent, + preventDefault, + stopImmediatePropagation: vi.fn(), + stopPropagation: vi.fn() + }) + await flushAsyncWork() + + expect(preventDefault).not.toHaveBeenCalled() + expect(mocks.createTab).not.toHaveBeenCalled() + }) + it('routes focused floating tab switch shortcuts to the floating workspace', async () => { setFloatingTabs([makeTab({ id: 'tab-1' }), makeTab({ id: 'tab-2' })]) const element = await renderPanel(true) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx index 329a931ba..88197dbce 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx @@ -22,7 +22,7 @@ import { resolveGroupTabFromVisibleId } from '@/components/tab-group/tab-group-v import TerminalPane from '@/components/terminal-pane/TerminalPane' import { Button } from '@/components/ui/button' import { useMountedRef } from '@/hooks/useMountedRef' -import { useShortcutKeys } from '@/hooks/useShortcutLabel' +import { useShortcutKeyDetails, type ShortcutKeyComboDetails } from '@/hooks/useShortcutLabel' import { Dialog, DialogContent, @@ -67,8 +67,13 @@ import { keybindingMatchesAction, type KeybindingActionId, type KeybindingContext, - type KeybindingMatchOptions + type KeybindingMatchOptions, + type PhysicalModifierToken } from '../../../../shared/keybindings' +import { + ModifierDoubleTapDetector, + toModifierDoubleTapEvent +} from '../../../../shared/modifier-double-tap-detector' import type { BrowserTab as BrowserTabState, Tab, @@ -116,6 +121,11 @@ type FloatingWorkspaceTourInteractionSnapshot = { recordFeatureInteractionForTour: boolean } +type FloatingPanelShortcutInput = Partial< + Pick +> & + Pick & { doubleTapModifier?: PhysicalModifierToken } + const FLOATING_TERMINAL_NO_DRAG_SELECTOR = 'button,input,textarea,select,[role="menuitem"],[data-testid="sortable-tab"],[data-floating-terminal-no-drag]' const FLOATING_TERMINAL_SHORTCUT_SURFACE_SELECTOR = '[data-floating-terminal-shortcut-surface]' @@ -194,11 +204,11 @@ export function FloatingTerminalPanel({ const browserDefaultUrl = useAppStore((s) => s.browserDefaultUrl) const floatingTerminalCwd = useAppStore((s) => s.settings?.floatingTerminalCwd ?? '') const generatedTabTitlesEnabled = useAppStore((s) => s.settings?.tabAutoGenerateTitle === true) - const newTerminalShortcutKeys = useShortcutKeys('tab.newTerminal') - const newBrowserShortcutKeys = useShortcutKeys('tab.newBrowser') - const newMarkdownShortcutKeys = useShortcutKeys('tab.newMarkdown') - const openMarkdownShortcutKeys = useShortcutKeys('tab.openMarkdown') - const closeShortcutKeys = useShortcutKeys('tab.close') + const newTerminalShortcut = useShortcutKeyDetails('tab.newTerminal') + const newBrowserShortcut = useShortcutKeyDetails('tab.newBrowser') + const newMarkdownShortcut = useShortcutKeyDetails('tab.newMarkdown') + const openMarkdownShortcut = useShortcutKeyDetails('tab.openMarkdown') + const closeShortcut = useShortcutKeyDetails('tab.close') const [cwd, setCwd] = useState(null) const [markdownCwd, setMarkdownCwd] = useState(null) @@ -228,6 +238,10 @@ export function FloatingTerminalPanel({ const pendingEditorCloseQueueRef = useRef([]) const saveDialogFileIdRef = useRef(null) const panelRef = useRef(null) + const doubleTapDetectorRef = useRef(null) + if (!doubleTapDetectorRef.current) { + doubleTapDetectorRef.current = new ModifierDoubleTapDetector() + } const shortcutFocusFrameRef = useRef(null) const shortcutFocusTimeoutRef = useRef(null) const mountedRef = useMountedRef() @@ -926,6 +940,71 @@ export function FloatingTerminalPanel({ setFloatingTerminalInputFocusedInMain(isFloatingWorkspaceTerminalInputTarget(target)) }, []) + const handleFloatingPanelShortcutAction = useCallback( + (input: FloatingPanelShortcutInput, consume: () => void): boolean => { + const state = useAppStore.getState() + const platform = getShortcutPlatform() + const context: KeybindingContext = input.doubleTapModifier + ? 'app' + : isFloatingWorkspaceTerminalInputTarget(input.target) + ? 'terminal' + : 'app' + const matchOptions: KeybindingMatchOptions = { + context, + terminalShortcutPolicy: state.settings?.terminalShortcutPolicy + } + const matches = (actionId: KeybindingActionId): boolean => + keybindingMatchesAction(actionId, input, platform, state.keybindings, matchOptions) + + if (matches('tab.newTerminal')) { + consume() + createFloatingTerminalTab() + return true + } + if (matches('tab.newBrowser')) { + consume() + createFloatingBrowserTab() + return true + } + if (matches('tab.newMarkdown')) { + consume() + createFloatingMarkdownTab() + return true + } + if (matches('tab.openMarkdown')) { + consume() + openFloatingMarkdownTab() + return true + } + if (matches('tab.close')) { + consume() + if (activeClosableTab) { + closeFloatingItem(activeClosableTab.id) + if (visibleFloatingItemCount <= 1) { + // Why: closing the final xterm removes the focused textarea; keep + // the empty floating workspace as the owner for the next Cmd/Ctrl+T. + focusPanelForShortcutsAfterClose() + } + } else { + onOpenChange(false) + } + return true + } + return false + }, + [ + activeClosableTab, + closeFloatingItem, + createFloatingBrowserTab, + createFloatingMarkdownTab, + createFloatingTerminalTab, + focusPanelForShortcutsAfterClose, + onOpenChange, + openFloatingMarkdownTab, + visibleFloatingItemCount + ] + ) + const handleShortcutSurfaceKeyDown = useCallback( (event: React.KeyboardEvent) => { if (!open || event.defaultPrevented || event.repeat) { @@ -950,8 +1029,6 @@ export function FloatingTerminalPanel({ terminalShortcutPolicy: state.settings?.terminalShortcutPolicy } const nativeEvent = event.nativeEvent - const matches = (actionId: KeybindingActionId): boolean => - keybindingMatchesAction(actionId, nativeEvent, platform, state.keybindings, matchOptions) if ( !isFloatingWorkspacePanelShortcut( @@ -965,52 +1042,9 @@ export function FloatingTerminalPanel({ return } - if (matches('tab.newTerminal')) { - event.preventDefault() - createFloatingTerminalTab() - return - } - if (matches('tab.newBrowser')) { - event.preventDefault() - createFloatingBrowserTab() - return - } - if (matches('tab.newMarkdown')) { - event.preventDefault() - createFloatingMarkdownTab() - return - } - if (matches('tab.openMarkdown')) { - event.preventDefault() - openFloatingMarkdownTab() - return - } - if (matches('tab.close')) { - event.preventDefault() - if (activeClosableTab) { - closeFloatingItem(activeClosableTab.id) - if (visibleFloatingItemCount <= 1) { - // Why: closing the final xterm removes the focused textarea; keep - // the empty floating workspace as the owner for the next Cmd/Ctrl+T. - focusPanelForShortcutsAfterClose() - } - } else { - onOpenChange(false) - } - } + handleFloatingPanelShortcutAction(nativeEvent, () => event.preventDefault()) }, - [ - activeClosableTab, - closeFloatingItem, - createFloatingBrowserTab, - createFloatingMarkdownTab, - createFloatingTerminalTab, - focusPanelForShortcutsAfterClose, - onOpenChange, - openFloatingMarkdownTab, - open, - visibleFloatingItemCount - ] + [handleFloatingPanelShortcutAction, open] ) useEffect(() => { @@ -1018,13 +1052,35 @@ export function FloatingTerminalPanel({ return } - const handleFloatingPanelKeyDown = (event: KeyboardEvent): void => { - if (event.defaultPrevented || event.repeat) { - return - } + const isPanelFocused = (): boolean => { const panel = panelRef.current const active = document.activeElement - if (!panel || !(active instanceof HTMLElement) || !panel.contains(active)) { + return Boolean(panel && active instanceof HTMLElement && panel.contains(active)) + } + + const handleFloatingPanelKeyDown = (event: KeyboardEvent): void => { + if (event.defaultPrevented) { + return + } + if (!isPanelFocused()) { + doubleTapDetectorRef.current?.reset() + return + } + + const detected = doubleTapDetectorRef.current?.process( + toModifierDoubleTapEvent({ + type: 'keyDown', + code: event.code, + key: event.key, + shift: event.shiftKey, + control: event.ctrlKey, + alt: event.altKey, + meta: event.metaKey, + isAutoRepeat: event.repeat + }), + Date.now() + ) + if (event.repeat) { return } @@ -1043,38 +1099,17 @@ export function FloatingTerminalPanel({ event.stopImmediatePropagation() } - if (matches('tab.newTerminal')) { - consume() - createFloatingTerminalTab() + if ( + detected && + handleFloatingPanelShortcutAction( + { doubleTapModifier: detected.modifier, target: event.target }, + consume + ) + ) { return } - if (matches('tab.newBrowser')) { - consume() - createFloatingBrowserTab() - return - } - if (matches('tab.newMarkdown')) { - consume() - createFloatingMarkdownTab() - return - } - if (matches('tab.openMarkdown')) { - consume() - openFloatingMarkdownTab() - return - } - if (matches('tab.close')) { - consume() - if (activeClosableTab) { - closeFloatingItem(activeClosableTab.id) - if (visibleFloatingItemCount <= 1) { - // Why: closing the final xterm removes the focused textarea; keep - // the empty floating workspace as the owner for the next Cmd/Ctrl+T. - focusPanelForShortcutsAfterClose() - } - } else { - onOpenChange(false) - } + + if (handleFloatingPanelShortcutAction(event, consume)) { return } @@ -1109,23 +1144,39 @@ export function FloatingTerminalPanel({ } } + const handleFloatingPanelKeyUp = (event: KeyboardEvent): void => { + if (!isPanelFocused()) { + doubleTapDetectorRef.current?.reset() + return + } + doubleTapDetectorRef.current?.process( + toModifierDoubleTapEvent({ + type: 'keyUp', + code: event.code, + key: event.key, + shift: event.shiftKey, + control: event.ctrlKey, + alt: event.altKey, + meta: event.metaKey + }), + Date.now() + ) + } + + const handleFloatingPanelBlur = (): void => doubleTapDetectorRef.current?.reset() + // Why: the main Terminal view is not mounted on Landing/Settings, but the // floating workspace must still own its tab shortcuts while it has focus. window.addEventListener('keydown', handleFloatingPanelKeyDown, { capture: true }) - return () => + window.addEventListener('keyup', handleFloatingPanelKeyUp, { capture: true }) + window.addEventListener('blur', handleFloatingPanelBlur) + return () => { window.removeEventListener('keydown', handleFloatingPanelKeyDown, { capture: true }) - }, [ - activeClosableTab, - closeFloatingItem, - createFloatingBrowserTab, - createFloatingMarkdownTab, - createFloatingTerminalTab, - focusPanelForShortcutsAfterClose, - onOpenChange, - openFloatingMarkdownTab, - open, - visibleFloatingItemCount - ]) + window.removeEventListener('keyup', handleFloatingPanelKeyUp, { capture: true }) + window.removeEventListener('blur', handleFloatingPanelBlur) + doubleTapDetectorRef.current?.reset() + } + }, [handleFloatingPanelShortcutAction, open]) useEffect(() => { if (!open) { @@ -1451,11 +1502,11 @@ export function FloatingTerminalPanel({ onNewBrowser={createFloatingBrowserTab} onClose={() => onOpenChange(false)} onFocusPanel={focusPanelForShortcuts} - newTerminalShortcutKeys={newTerminalShortcutKeys} - newBrowserShortcutKeys={newBrowserShortcutKeys} - newMarkdownShortcutKeys={newMarkdownShortcutKeys} - openMarkdownShortcutKeys={openMarkdownShortcutKeys} - closeShortcutKeys={closeShortcutKeys} + newTerminalShortcut={newTerminalShortcut} + newBrowserShortcut={newBrowserShortcut} + newMarkdownShortcut={newMarkdownShortcut} + openMarkdownShortcut={openMarkdownShortcut} + closeShortcut={closeShortcut} /> ) : null}
@@ -1593,11 +1644,11 @@ function FloatingTerminalEmptyState({ onNewBrowser, onClose, onFocusPanel, - newTerminalShortcutKeys, - newBrowserShortcutKeys, - newMarkdownShortcutKeys, - openMarkdownShortcutKeys, - closeShortcutKeys + newTerminalShortcut, + newBrowserShortcut, + newMarkdownShortcut, + openMarkdownShortcut, + closeShortcut }: { onNewTerminal: () => void onNewMarkdown: () => void @@ -1605,11 +1656,11 @@ function FloatingTerminalEmptyState({ onNewBrowser: () => void onClose: () => void onFocusPanel: () => void - newTerminalShortcutKeys: string[] - newBrowserShortcutKeys: string[] - newMarkdownShortcutKeys: string[] - openMarkdownShortcutKeys: string[] - closeShortcutKeys: string[] + newTerminalShortcut: ShortcutKeyComboDetails + newBrowserShortcut: ShortcutKeyComboDetails + newMarkdownShortcut: ShortcutKeyComboDetails + openMarkdownShortcut: ShortcutKeyComboDetails + closeShortcut: ShortcutKeyComboDetails }): React.JSX.Element { return (
- +
) } -function FloatingEmptyStateShortcut({ keys }: { keys: string[] }): React.JSX.Element { - if (keys.length === 0) { +function FloatingEmptyStateShortcut({ + shortcut +}: { + shortcut: ShortcutKeyComboDetails +}): React.JSX.Element { + if (shortcut.keys.length === 0) { return } return ( diff --git a/src/renderer/src/components/settings/AppearancePane.test.tsx b/src/renderer/src/components/settings/AppearancePane.test.tsx index 391050539..a5d0c0cde 100644 --- a/src/renderer/src/components/settings/AppearancePane.test.tsx +++ b/src/renderer/src/components/settings/AppearancePane.test.tsx @@ -22,7 +22,7 @@ vi.mock('../../store', () => ({ })) vi.mock('@/hooks/useShortcutLabel', () => ({ - useShortcutKeyCombos: () => [] + useShortcutKeyComboDetails: () => [] })) vi.mock('../status-bar/use-available-status-bar-toggles', () => ({ diff --git a/src/renderer/src/components/settings/AppearancePane.tsx b/src/renderer/src/components/settings/AppearancePane.tsx index 18c358201..aed005c68 100644 --- a/src/renderer/src/components/settings/AppearancePane.tsx +++ b/src/renderer/src/components/settings/AppearancePane.tsx @@ -10,7 +10,7 @@ import { UIZoomControl } from './UIZoomControl' import { SearchableSetting } from './SearchableSetting' import { matchesSettingsSearch } from './settings-search' import { useAppStore } from '../../store' -import { useShortcutKeyCombos } from '@/hooks/useShortcutLabel' +import { useShortcutKeyComboDetails, type ShortcutKeyComboDetails } from '@/hooks/useShortcutLabel' import { ShortcutKeyCombo } from '../ShortcutKeyCombo' import { FontAutocomplete, @@ -64,7 +64,7 @@ type AppearancePaneProps = { warpThemes: UseWarpThemeImportReturn } -function ShortcutHintList({ combos }: { combos: string[][] }): React.JSX.Element { +function ShortcutHintList({ combos }: { combos: ShortcutKeyComboDetails[] }): React.JSX.Element { if (combos.length === 0) { return ( @@ -75,10 +75,11 @@ function ShortcutHintList({ combos }: { combos: string[][] }): React.JSX.Element return ( - {combos.map((keys) => ( + {combos.map((combo) => ( @@ -98,8 +99,8 @@ export function AppearancePane({ warpThemes }: AppearancePaneProps): React.JSX.Element { const searchQuery = useAppStore((state) => state.settingsSearchQuery) - const zoomInKeyCombos = useShortcutKeyCombos('zoom.in') - const zoomOutKeyCombos = useShortcutKeyCombos('zoom.out') + const zoomInKeyCombos = useShortcutKeyComboDetails('zoom.in') + const zoomOutKeyCombos = useShortcutKeyComboDetails('zoom.out') const statusBarItems = useAppStore((state) => state.statusBarItems) const toggleStatusBarItem = useAppStore((state) => state.toggleStatusBarItem) const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction) diff --git a/src/renderer/src/components/settings/SettingsSidebar.test.tsx b/src/renderer/src/components/settings/SettingsSidebar.test.tsx index 340d99ca5..11eb49163 100644 --- a/src/renderer/src/components/settings/SettingsSidebar.test.tsx +++ b/src/renderer/src/components/settings/SettingsSidebar.test.tsx @@ -13,7 +13,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/hooks/useShortcutLabel', () => ({ useShortcutLabel: () => '⌘F', - useShortcutKeyCombos: () => [['⌘', 'F']] + useShortcutKeyComboDetails: () => [{ keys: ['⌘', 'F'], doubleTap: false }] })) vi.mock('./settings-setup-guide-progress', () => ({ diff --git a/src/renderer/src/components/settings/SettingsSidebar.tsx b/src/renderer/src/components/settings/SettingsSidebar.tsx index c969757f4..7bbcea653 100644 --- a/src/renderer/src/components/settings/SettingsSidebar.tsx +++ b/src/renderer/src/components/settings/SettingsSidebar.tsx @@ -4,7 +4,7 @@ import { ArrowLeft, Search, Server } from 'lucide-react' import type { RepoIcon } from '../../../../shared/repo-icon' import type { SettingsNavIcon, SettingsNavInstallStatus } from '@/lib/settings-navigation-types' import type { GitHubRepositoryIdentity, GlobalSettings } from '../../../../shared/types' -import { useShortcutKeyCombos } from '@/hooks/useShortcutLabel' +import { useShortcutKeyComboDetails } from '@/hooks/useShortcutLabel' import { ShortcutKeyCombo } from '../ShortcutKeyCombo' import { cn } from '@/lib/utils' import { RepoIconGlyph } from '../repo/repo-icon' @@ -138,7 +138,7 @@ export function SettingsSidebar({ // Settings should remain a stable place to reopen the checklist. const showSetupGuideTopRow = setupGuideProgress.ready && setupGuideProgress.doneCount < setupGuideProgress.total - const searchShortcutCombos = useShortcutKeyCombos('settings.search') + const searchShortcutCombos = useShortcutKeyComboDetails('settings.search') const navItemClassName = (isActive: boolean): string => cn( 'flex w-full items-center gap-2 rounded-lg px-3 py-1.5 text-left text-[13px] outline-none transition-colors duration-150 focus-visible:ring-[3px] focus-visible:ring-worktree-sidebar-ring/50', @@ -201,10 +201,11 @@ export function SettingsSidebar({ /> {searchQuery === '' ? ( - {searchShortcutCombos.map((keys) => ( + {searchShortcutCombos.map((combo) => ( diff --git a/src/renderer/src/components/settings/ShortcutBindingRow.tsx b/src/renderer/src/components/settings/ShortcutBindingRow.tsx index b2abc4137..c63e7cdbc 100644 --- a/src/renderer/src/components/settings/ShortcutBindingRow.tsx +++ b/src/renderer/src/components/settings/ShortcutBindingRow.tsx @@ -2,10 +2,16 @@ import React, { useEffect, useRef } from 'react' import { Ban, Plus, RotateCcw, Terminal } from 'lucide-react' import { formatKeybinding, + isDoubleTapBinding, type KeybindingActionId, type KeybindingDefinition, type KeybindingInput } from '../../../../shared/keybindings' +import { + ModifierDoubleTapDetector, + modifierFromKeyEvent, + toModifierDoubleTapEvent +} from '../../../../shared/modifier-double-tap-detector' import { cn } from '../../lib/utils' import { ShortcutKeyCombo } from '../ShortcutKeyCombo' import { Badge } from '../ui/badge' @@ -55,17 +61,31 @@ export function ShortcutBindingRow({ onReset }: ShortcutBindingRowProps): React.JSX.Element { const recordButtonRef = useRef(null) + const doubleTapDetectorRef = useRef(null) + if (!doubleTapDetectorRef.current) { + doubleTapDetectorRef.current = new ModifierDoubleTapDetector() + } useEffect(() => { if (recording) { recordButtonRef.current?.focus() + } else { + // Stale taps mustn't survive into the next recording session. + doubleTapDetectorRef.current?.reset() } window.api.ui.setShortcutRecorderFocused(recording) return () => window.api.ui.setShortcutRecorderFocused(false) }, [recording]) const statusMessage = error ?? (warnings.length > 0 ? warnings.join(' ') : '') - const recordingMessage = recording ? 'Listening for shortcut. Esc cancels recording.' : '' + const doubleTapHint = platform === 'darwin' ? '⇧⇧' : 'Shift Shift' + const recordingMessage = recording + ? translate( + 'auto.components.settings.ShortcutBindingRow.a98d551407', + 'Press a shortcut, or double-tap a modifier (e.g. {{value0}}). Esc cancels.', + { value0: doubleTapHint } + ) + : '' const helperMessage = statusMessage || recordingMessage const hasBinding = effective.length > 0 @@ -82,11 +102,37 @@ export function ShortcutBindingRow({ event.stopPropagation() if (event.key === 'Escape') { + doubleTapDetectorRef.current?.reset() onClearError(item.id) onCancelRecording() return } + // A modifier press never captures on its own — the detector decides whether + // it completes a double-tap, leaving normal chords to capture on their key. + if (modifierFromKeyEvent(event.code, event.key) !== null) { + const detected = doubleTapDetectorRef.current?.process( + toModifierDoubleTapEvent({ + type: 'keyDown', + code: event.code, + key: event.key, + shift: event.shiftKey, + control: event.ctrlKey, + alt: event.altKey, + meta: event.metaKey, + isAutoRepeat: event.repeat + }), + Date.now() + ) + if (detected) { + onClearError(item.id) + onCapture(item.id, { doubleTapModifier: detected.modifier }) + doubleTapDetectorRef.current?.reset() + } + return + } + + doubleTapDetectorRef.current?.reset() onClearError(item.id) onCapture(item.id, { key: event.key, @@ -98,6 +144,26 @@ export function ShortcutBindingRow({ }) } + const handleRecordKeyUp = (event: React.KeyboardEvent): void => { + if (!recording) { + return + } + event.preventDefault() + event.stopPropagation() + doubleTapDetectorRef.current?.process( + toModifierDoubleTapEvent({ + type: 'keyUp', + code: event.code, + key: event.key, + shift: event.shiftKey, + control: event.ctrlKey, + alt: event.altKey, + meta: event.metaKey + }), + Date.now() + ) + } + // Why: the recorder is the row's primary control — clicking the keys (or the // "Add shortcut" placeholder) records a new binding in place, so the whole // affordance lives inline rather than in a detached popover. @@ -230,6 +296,7 @@ export function ShortcutBindingRow({ } }} onKeyDown={handleRecordKeyDown} + onKeyUp={handleRecordKeyUp} className={cn( 'flex min-h-7 min-w-[5.5rem] items-center justify-center gap-1.5 rounded-md border px-2 py-1 text-xs outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50', recording @@ -249,7 +316,11 @@ export function ShortcutBindingRow({ ) : hasBinding ? ( {effective.map((binding) => ( - + ))} ) : ( diff --git a/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.test.tsx b/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.test.tsx index d63a15553..8fba33ed0 100644 --- a/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.test.tsx +++ b/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.test.tsx @@ -10,7 +10,7 @@ const mocks = vi.hoisted(() => ({ appRestart: vi.fn(), updaterCheck: vi.fn(), shellOpenUrl: vi.fn(), - useShortcutKeys: vi.fn(), + useShortcutKeyDetails: vi.fn(), setupProgress: { ready: true, coreDoneCount: 2, @@ -32,7 +32,7 @@ vi.mock('@/store', () => ({ })) vi.mock('@/hooks/useShortcutLabel', () => ({ - useShortcutKeys: mocks.useShortcutKeys + useShortcutKeyDetails: mocks.useShortcutKeyDetails })) vi.mock('@/hooks/useMountedRef', () => ({ @@ -99,7 +99,7 @@ vi.mock('./SidebarFeedbackDialog', () => ({ describe('SidebarSettingsHelpMenu', () => { beforeEach(() => { vi.clearAllMocks() - mocks.useShortcutKeys.mockReturnValue(['⌘', ',']) + mocks.useShortcutKeyDetails.mockReturnValue({ keys: ['⌘', ','], doubleTap: false }) updateStatus = { state: 'idle' } mocks.setupProgress = { ready: true, diff --git a/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx b/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx index 0da82554d..f5bf3250c 100644 --- a/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx +++ b/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx @@ -26,7 +26,7 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { useMountedRef } from '@/hooks/useMountedRef' -import { useShortcutKeys } from '@/hooks/useShortcutLabel' +import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel' import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' import { showOnboardingFromRenderer } from '../onboarding/show-onboarding-event' import { SetupGuideProgressRing } from '../setup-guide/SetupGuideProgressRing' @@ -85,7 +85,7 @@ export function SidebarSettingsHelpMenu(): React.JSX.Element { const updateStatus = useAppStore((s) => s.updateStatus) const setupProgress = useSetupGuideProgress(true, false, false) - const settingsShortcutKeys = useShortcutKeys('app.settings') + const settingsShortcut = useShortcutKeyDetails('app.settings') const [menuOpen, setMenuOpen] = useState(false) const [feedbackOpen, setFeedbackOpen] = useState(false) const [showAdminOptions, setShowAdminOptions] = useState(false) @@ -177,9 +177,10 @@ export function SidebarSettingsHelpMenu(): React.JSX.Element { {translate('auto.components.sidebar.SidebarSettingsHelpMenu.a428c25998', 'Settings')} - {settingsShortcutKeys.length > 0 ? ( + {settingsShortcut.keys.length > 0 ? ( ({ + keys: formatKeybinding(binding, platform), + doubleTap: isDoubleTapBinding(binding) + })) +} + +export function useShortcutKeyComboDetails( + actionId: KeybindingActionId +): ShortcutKeyComboDetails[] { + const keybindings = useAppStore((state) => state.keybindings) + return formatShortcutKeyComboDetails(actionId, keybindings) +} + +export function useShortcutKeyDetails(actionId: KeybindingActionId): ShortcutKeyComboDetails { + return useShortcutKeyComboDetails(actionId)[0] ?? { keys: [], doubleTap: false } +} + export function formatShortcutKeyCombos( actionId: KeybindingActionId, overrides?: KeybindingOverrides ): string[][] { - const platform = getShortcutPlatform() - return getEffectiveKeybindingsForAction(actionId, platform, overrides).map((binding) => - formatKeybinding(binding, platform) - ) + return formatShortcutKeyComboDetails(actionId, overrides).map((combo) => combo.keys) } export function useShortcutKeyCombos(actionId: KeybindingActionId): string[][] { - const keybindings = useAppStore((state) => state.keybindings) - return formatShortcutKeyCombos(actionId, keybindings) + return useShortcutKeyComboDetails(actionId).map((combo) => combo.keys) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 12034398d..3ad404d28 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -5791,7 +5791,8 @@ "97dccee14e": "Modified", "3b11ef3a43": "{{value0}} shortcut", "f6579be67b": "Change shortcut", - "6a7848fdac": "Listening for shortcut" + "6a7848fdac": "Listening for shortcut", + "a98d551407": "Press a shortcut, or double-tap a modifier (e.g. {{value0}}). Esc cancels." }, "ShortcutFilterRail": { "28b63545bf": "Status", @@ -11451,6 +11452,9 @@ "sourceUnavailable": "{{value0}} source unavailable: {{value1}}", "someSourceHostsUnavailable": "Some {{value0}} source hosts unavailable: {{value1}}", "reconnectOrUpdateTitle": "Reconnect or update {{value0}} to load this source." + }, + "ShortcutKeyCombo": { + "07eb4985a1": "Double-tap {{value0}}" } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ac50ba075..218df43e1 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -5754,7 +5754,8 @@ "97dccee14e": "Modificado", "3b11ef3a43": "{{value0}} atajo", "f6579be67b": "Cambiar acceso directo", - "6a7848fdac": "Escuchando atajos" + "6a7848fdac": "Escuchando atajos", + "a98d551407": "Pulsa un atajo o toca dos veces una tecla modificadora (p. ej., {{value0}}). Esc cancela." }, "ShortcutFilterRail": { "28b63545bf": "Estado", @@ -11451,6 +11452,9 @@ "sourceUnavailable": "{{value0}} source unavailable: {{value1}}", "someSourceHostsUnavailable": "Some {{value0}} source hosts unavailable: {{value1}}", "reconnectOrUpdateTitle": "Reconnect or update {{value0}} to load this source." + }, + "ShortcutKeyCombo": { + "07eb4985a1": "Double-tap {{value0}}" } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 33ab60176..9e9e7447a 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -5776,7 +5776,8 @@ "97dccee14e": "修正済み", "3b11ef3a43": "{{value0}} ショートカット", "f6579be67b": "ショートカットを変更する", - "6a7848fdac": "ショートカットを記録中" + "6a7848fdac": "ショートカットを記録中", + "a98d551407": "ショートカットを押すか、修飾キーをダブルタップしてください(例: {{value0}})。Esc でキャンセルします。" }, "ShortcutFilterRail": { "28b63545bf": "状態", @@ -11451,6 +11452,9 @@ "sourceUnavailable": "{{value0}} source unavailable: {{value1}}", "someSourceHostsUnavailable": "Some {{value0}} source hosts unavailable: {{value1}}", "reconnectOrUpdateTitle": "Reconnect or update {{value0}} to load this source." + }, + "ShortcutKeyCombo": { + "07eb4985a1": "Double-tap {{value0}}" } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 17e942621..137e57f97 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -5739,7 +5739,8 @@ "97dccee14e": "수정됨", "3b11ef3a43": "{{value0}} 바로가기", "f6579be67b": "바로가기 변경", - "6a7848fdac": "단축키 입력 대기 중" + "6a7848fdac": "단축키 입력 대기 중", + "a98d551407": "단축키를 누르거나 보조 키를 두 번 탭하세요(예: {{value0}}). Esc로 취소합니다." }, "ShortcutFilterRail": { "28b63545bf": "상태", @@ -11451,6 +11452,9 @@ "sourceUnavailable": "{{value0}} source unavailable: {{value1}}", "someSourceHostsUnavailable": "Some {{value0}} source hosts unavailable: {{value1}}", "reconnectOrUpdateTitle": "Reconnect or update {{value0}} to load this source." + }, + "ShortcutKeyCombo": { + "07eb4985a1": "Double-tap {{value0}}" } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index ddd16b53a..49078d06d 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -5739,7 +5739,8 @@ "97dccee14e": "修改的", "3b11ef3a43": "{{value0}} 快捷方式", "f6579be67b": "更改快捷方式", - "6a7848fdac": "正在录制快捷键" + "6a7848fdac": "正在录制快捷键", + "a98d551407": "按下快捷键,或双击一个修饰键(例如 {{value0}})。按 Esc 取消。" }, "ShortcutFilterRail": { "28b63545bf": "状态", @@ -11451,6 +11452,9 @@ "sourceUnavailable": "{{value0}} source unavailable: {{value1}}", "someSourceHostsUnavailable": "Some {{value0}} source hosts unavailable: {{value1}}", "reconnectOrUpdateTitle": "Reconnect or update {{value0}} to load this source." + }, + "ShortcutKeyCombo": { + "07eb4985a1": "Double-tap {{value0}}" } }, "i18n": { diff --git a/src/renderer/src/lib/floating-workspace-shortcut-policy.ts b/src/renderer/src/lib/floating-workspace-shortcut-policy.ts index 6e52b6c62..96c3a0c42 100644 --- a/src/renderer/src/lib/floating-workspace-shortcut-policy.ts +++ b/src/renderer/src/lib/floating-workspace-shortcut-policy.ts @@ -2,13 +2,16 @@ import { keybindingMatchesAction, type KeybindingActionId, type KeybindingMatchOptions, - type KeybindingOverrides + type KeybindingOverrides, + type PhysicalModifierToken } from '../../../shared/keybindings' -type FloatingWorkspaceShortcutEvent = Pick< - KeyboardEvent, - 'altKey' | 'code' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey' | 'target' -> +// Partial<> on the key/modifier fields so a synthetic double-tap input (which +// carries no key/modifier flags) satisfies this shape; target stays required. +type FloatingWorkspaceShortcutEvent = Partial< + Pick +> & + Pick & { doubleTapModifier?: PhysicalModifierToken } const FLOATING_WORKSPACE_SHORTCUT_SURFACE_SELECTOR = '[data-floating-terminal-shortcut-surface]' const FLOATING_WORKSPACE_PANEL_SHORTCUT_ACTIONS: readonly KeybindingActionId[] = [ diff --git a/src/renderer/src/lib/floating-workspace-terminal-actions.test.ts b/src/renderer/src/lib/floating-workspace-terminal-actions.test.ts index 7ad601b31..f9a7cedfc 100644 --- a/src/renderer/src/lib/floating-workspace-terminal-actions.test.ts +++ b/src/renderer/src/lib/floating-workspace-terminal-actions.test.ts @@ -282,6 +282,17 @@ describe('isFloatingWorkspacePanelShortcut', () => { ).toBe(false) }) + it('claims customized double-tap shortcuts for the floating panel surface', () => { + const event = shortcutSurfaceEvent({}) as KeyboardEvent & { doubleTapModifier: 'Shift' } + event.doubleTapModifier = 'Shift' + + expect( + isFloatingWorkspacePanelShortcut(event, 'linux', null, { + 'tab.newTerminal': ['DoubleTap+Shift'] + }) + ).toBe(true) + }) + it('does not claim shortcuts with Alt or the wrong platform modifier', () => { expect( isFloatingWorkspacePanelShortcut(shortcutSurfaceEvent({ key: 't', metaKey: true }), false) diff --git a/src/shared/keybindings.test.ts b/src/shared/keybindings.test.ts index 74e7543b9..cdb0c404c 100644 --- a/src/shared/keybindings.test.ts +++ b/src/shared/keybindings.test.ts @@ -6,8 +6,10 @@ import { agentTabActionId, getKeybindingDefinition, findKeybindingConflicts, + formatKeybinding, formatKeybindingList, getEffectiveKeybindingsForAction, + isDoubleTapBinding, keybindingFromInput, keybindingFromInputForAction, keybindingMatchesAction, @@ -36,6 +38,34 @@ describe('keybindings', () => { expect(normalizeKeybinding('Ctrl+Nope')).toMatchObject({ ok: false }) }) + it('parses, normalizes, and rejects double-tap modifier bindings', () => { + expect(normalizeKeybinding('DoubleTap+Shift')).toEqual({ ok: true, value: 'DoubleTap+Shift' }) + expect(normalizeKeybinding(' doubletap + shift ')).toEqual({ + ok: true, + value: 'DoubleTap+Shift' + }) + expect(normalizeKeybinding('DoubleTap+Mod')).toEqual({ ok: true, value: 'DoubleTap+Mod' }) + expect(normalizeKeybinding('DoubleTap+Cmd')).toEqual({ ok: true, value: 'DoubleTap+Cmd' }) + expect(normalizeKeybinding('DoubleTap+Alt')).toEqual({ ok: true, value: 'DoubleTap+Alt' }) + expect(normalizeKeybinding('DoubleTap+Ctrl')).toEqual({ ok: true, value: 'DoubleTap+Ctrl' }) + + // A key after DoubleTap is invalid. + expect(normalizeKeybinding('DoubleTap+Shift+P')).toMatchObject({ ok: false }) + // Two modifiers is invalid. + expect(normalizeKeybinding('DoubleTap+Shift+Alt')).toMatchObject({ ok: false }) + // Mod + platform-specific reuses the shared error. + expect(normalizeKeybinding('DoubleTap+Mod+Cmd')).toEqual({ + ok: false, + error: 'Use either Mod or a platform-specific modifier, not both.' + }) + // Bare DoubleTap is invalid. + expect(normalizeKeybinding('DoubleTap')).toMatchObject({ ok: false }) + + expect(isDoubleTapBinding('DoubleTap+Shift')).toBe(true) + expect(isDoubleTapBinding('Mod+P')).toBe(false) + expect(isDoubleTapBinding('not-a-binding')).toBe(false) + }) + it('allows safe bare keys only for scoped actions that opt in', () => { expect(normalizeKeybinding('Delete')).toMatchObject({ ok: false }) expect(normalizeKeybindingListForAction('fileExplorer.delete', 'Delete')).toEqual(['Delete']) @@ -813,6 +843,95 @@ describe('keybindings', () => { ).toBe(true) }) + it('matches double-tap bindings only against synthetic double-tap input', () => { + expect( + keybindingMatchesInput('DoubleTap+Shift', { doubleTapModifier: 'Shift' }, 'darwin') + ).toBe(true) + // Mod resolves per platform: meta on macOS, control elsewhere. + expect(keybindingMatchesInput('DoubleTap+Mod', { doubleTapModifier: 'Cmd' }, 'darwin')).toBe( + true + ) + expect(keybindingMatchesInput('DoubleTap+Mod', { doubleTapModifier: 'Ctrl' }, 'win32')).toBe( + true + ) + expect(keybindingMatchesInput('DoubleTap+Mod', { doubleTapModifier: 'Cmd' }, 'win32')).toBe( + false + ) + expect(keybindingMatchesInput('DoubleTap+Mod', { doubleTapModifier: 'Ctrl' }, 'darwin')).toBe( + false + ) + expect(keybindingMatchesInput('DoubleTap+Shift', { doubleTapModifier: 'Alt' }, 'darwin')).toBe( + false + ) + + // Cross-type negatives: a double-tap binding never matches a normal keydown, + // and a normal binding never matches a synthetic double-tap input. + expect( + keybindingMatchesInput('DoubleTap+Shift', { key: 'A', code: 'KeyA', shift: true }, 'darwin') + ).toBe(false) + expect(keybindingMatchesInput('Mod+P', { doubleTapModifier: 'Cmd' }, 'darwin')).toBe(false) + + // Action-level matching works through user overrides, for free. + expect( + keybindingMatchesAction('worktree.quickOpen', { doubleTapModifier: 'Shift' }, 'darwin', { + 'worktree.quickOpen': ['DoubleTap+Shift'] + }) + ).toBe(true) + expect( + keybindingMatchesAction('worktree.quickOpen', { doubleTapModifier: 'Alt' }, 'darwin', { + 'worktree.quickOpen': ['DoubleTap+Shift'] + }) + ).toBe(false) + }) + + it('captures double-tap gestures into canonical bindings', () => { + expect(keybindingFromInput({ doubleTapModifier: 'Shift' }, 'darwin')).toEqual({ + ok: true, + value: 'DoubleTap+Shift' + }) + // The platform primary modifier canonicalizes to Mod, matching normal capture. + expect(keybindingFromInput({ doubleTapModifier: 'Cmd' }, 'darwin')).toEqual({ + ok: true, + value: 'DoubleTap+Mod' + }) + expect(keybindingFromInput({ doubleTapModifier: 'Ctrl' }, 'win32')).toEqual({ + ok: true, + value: 'DoubleTap+Mod' + }) + // A non-primary modifier keeps its explicit token. + expect(keybindingFromInput({ doubleTapModifier: 'Ctrl' }, 'darwin')).toEqual({ + ok: true, + value: 'DoubleTap+Ctrl' + }) + expect(keybindingFromInput({ doubleTapModifier: 'Alt' }, 'linux')).toEqual({ + ok: true, + value: 'DoubleTap+Alt' + }) + // Ctrl is the primary modifier on Linux too, so it canonicalizes to Mod. + expect(keybindingFromInput({ doubleTapModifier: 'Ctrl' }, 'linux')).toEqual({ + ok: true, + value: 'DoubleTap+Mod' + }) + // Cmd is not the primary modifier off-mac, so it stays explicit. + expect(keybindingFromInput({ doubleTapModifier: 'Cmd' }, 'linux')).toEqual({ + ok: true, + value: 'DoubleTap+Cmd' + }) + }) + + it('formats double-tap bindings as the modifier glyph twice', () => { + expect(formatKeybinding('DoubleTap+Shift', 'darwin')).toEqual(['⇧', '⇧']) + expect(formatKeybinding('DoubleTap+Shift', 'linux')).toEqual(['Shift', 'Shift']) + expect(formatKeybinding('DoubleTap+Mod', 'darwin')).toEqual(['⌘', '⌘']) + expect(formatKeybinding('DoubleTap+Mod', 'win32')).toEqual(['Ctrl', 'Ctrl']) + expect(formatKeybinding('DoubleTap+Cmd', 'win32')).toEqual(['Cmd', 'Cmd']) + expect(formatKeybinding('DoubleTap+Alt', 'darwin')).toEqual(['⌥', '⌥']) + // Ctrl's glyph ⌃ diverges from Mod's ⌘ on Mac, so cover it explicitly. + expect(formatKeybinding('DoubleTap+Ctrl', 'darwin')).toEqual(['⌃', '⌃']) + expect(formatKeybindingList(['DoubleTap+Shift'], 'darwin')).toBe('⇧ ⇧') + expect(formatKeybindingList(['DoubleTap+Shift'], 'linux')).toBe('Shift Shift') + }) + it('matches macOS Option-composed bracket shortcuts for all-type tab switching', () => { const macOptionLeftBracket = { key: '\u201c', @@ -840,4 +959,52 @@ describe('keybindings', () => { false ) }) + + it('reports conflicts across two double-tap bindings', () => { + // Both actions share the same DoubleTap+Shift binding via overrides, so both + // are in customizedActions and the conflict detector must flag them. + const conflicts = findKeybindingConflicts('darwin', { + 'worktree.quickOpen': ['DoubleTap+Shift'], + 'view.tasks': ['DoubleTap+Shift'] + }) + expect(conflicts).toContainEqual({ + binding: 'DoubleTap+Shift', + actionIds: expect.arrayContaining(['worktree.quickOpen', 'view.tasks']) + }) + }) + + it('reports conflicts across platform-primary double-tap aliases', () => { + expect( + findKeybindingConflicts('darwin', { + 'worktree.quickOpen': ['DoubleTap+Mod'], + 'view.tasks': ['DoubleTap+Cmd'] + }) + ).toContainEqual({ + binding: 'DoubleTap+Mod', + actionIds: expect.arrayContaining(['worktree.quickOpen', 'view.tasks']) + }) + + expect( + findKeybindingConflicts('linux', { + 'worktree.quickOpen': ['DoubleTap+Mod'], + 'view.tasks': ['DoubleTap+Ctrl'] + }) + ).toContainEqual({ + binding: 'DoubleTap+Mod', + actionIds: expect.arrayContaining(['worktree.quickOpen', 'view.tasks']) + }) + }) + + it('does not report a conflict when one action lists double-tap aliases for itself', () => { + expect( + findKeybindingConflicts('darwin', { + 'worktree.quickOpen': ['DoubleTap+Mod', 'DoubleTap+Cmd'] + }) + ).toEqual([]) + expect( + findKeybindingConflicts('linux', { + 'worktree.quickOpen': ['DoubleTap+Mod', 'DoubleTap+Ctrl'] + }) + ).toEqual([]) + }) }) diff --git a/src/shared/keybindings.ts b/src/shared/keybindings.ts index 35be144a8..8f69c11ee 100644 --- a/src/shared/keybindings.ts +++ b/src/shared/keybindings.ts @@ -138,6 +138,9 @@ export type KeybindingDefinition = { conflictGroup?: string } +export type ModifierToken = 'Mod' | 'Cmd' | 'Ctrl' | 'Alt' | 'Shift' +export type PhysicalModifierToken = Exclude + export type KeybindingInput = { key?: string code?: string @@ -149,6 +152,8 @@ export type KeybindingInput = { metaKey?: boolean ctrlKey?: boolean shiftKey?: boolean + // Set only by the double-tap detector; always a physical token (never 'Mod'). + doubleTapModifier?: PhysicalModifierToken } type ParsedKeybinding = { @@ -158,6 +163,7 @@ type ParsedKeybinding = { alt: boolean shift: boolean key: string + doubleTapModifier?: ModifierToken } type NormalizeKeybindingOptions = { @@ -982,6 +988,84 @@ function normalizeKeyToken(token: string): string | null { return simple[upper] ?? null } +function parseModifierToken(rawPart: string): ModifierToken | null { + const part = rawPart.toLowerCase() + if (part === 'mod' || part === 'cmdorctrl' || part === 'commandorcontrol') { + return 'Mod' + } + if (part === 'cmd' || part === 'command' || part === 'meta' || rawPart === '⌘') { + return 'Cmd' + } + if (part === 'ctrl' || part === 'control' || rawPart === '⌃') { + return 'Ctrl' + } + if (part === 'alt' || part === 'option' || part === 'opt' || rawPart === '⌥') { + return 'Alt' + } + if (part === 'shift' || rawPart === '⇧') { + return 'Shift' + } + return null +} + +function applyModifierToken(parsed: ParsedKeybinding, modifier: ModifierToken): void { + if (modifier === 'Mod') { + parsed.mod = true + } else if (modifier === 'Cmd') { + parsed.meta = true + } else if (modifier === 'Ctrl') { + parsed.control = true + } else if (modifier === 'Alt') { + parsed.alt = true + } else { + parsed.shift = true + } +} + +function emptyParsedKeybinding(): ParsedKeybinding { + return { mod: false, meta: false, control: false, alt: false, shift: false, key: '' } +} + +// Why: a double-tap is a bare modifier with no key, so it cannot reuse the +// normal "one key required" parse path; validation of conflicting/extra +// modifiers is deferred to normalizeKeybindingWithOptions for shared errors. +function parseDoubleTapKeybinding(rawParts: string[]): ParsedKeybinding | null { + const modifiers: ModifierToken[] = [] + let sawDoubleTap = false + for (const rawPart of rawParts) { + if (rawPart.toLowerCase() === 'doubletap') { + if (sawDoubleTap) { + return null + } + sawDoubleTap = true + continue + } + const modifier = parseModifierToken(rawPart) + if (!modifier) { + return null + } + modifiers.push(modifier) + } + if (modifiers.length === 0) { + return null + } + const parsed = emptyParsedKeybinding() + for (const modifier of modifiers) { + applyModifierToken(parsed, modifier) + } + // Mod combined with a platform-specific modifier: keep both flags so normalize + // emits the shared "Mod or platform-specific, not both" error. + if (parsed.mod && (parsed.meta || parsed.control)) { + parsed.doubleTapModifier = 'Mod' + return parsed + } + if (modifiers.length > 1) { + return null + } + parsed.doubleTapModifier = modifiers[0] + return parsed +} + function parseKeybinding(binding: string): ParsedKeybinding | null { const rawParts = binding .split('+') @@ -991,35 +1075,15 @@ function parseKeybinding(binding: string): ParsedKeybinding | null { return null } - const parsed: ParsedKeybinding = { - mod: false, - meta: false, - control: false, - alt: false, - shift: false, - key: '' + if (rawParts.some((part) => part.toLowerCase() === 'doubletap')) { + return parseDoubleTapKeybinding(rawParts) } + const parsed = emptyParsedKeybinding() for (const rawPart of rawParts) { - const part = rawPart.toLowerCase() - if (part === 'mod' || part === 'cmdorctrl' || part === 'commandorcontrol') { - parsed.mod = true - continue - } - if (part === 'cmd' || part === 'command' || part === 'meta' || rawPart === '⌘') { - parsed.meta = true - continue - } - if (part === 'ctrl' || part === 'control' || rawPart === '⌃') { - parsed.control = true - continue - } - if (part === 'alt' || part === 'option' || part === 'opt' || rawPart === '⌥') { - parsed.alt = true - continue - } - if (part === 'shift' || rawPart === '⇧') { - parsed.shift = true + const modifier = parseModifierToken(rawPart) + if (modifier) { + applyModifierToken(parsed, modifier) continue } if (parsed.key) { @@ -1036,6 +1100,9 @@ function parseKeybinding(binding: string): ParsedKeybinding | null { } function canonicalizeParsedKeybinding(parsed: ParsedKeybinding): string { + if (parsed.doubleTapModifier) { + return `DoubleTap+${parsed.doubleTapModifier}` + } const parts: string[] = [] if (parsed.mod) { parts.push('Mod') @@ -1086,6 +1153,9 @@ function normalizeKeybindingWithOptions( if (parsed.mod && (parsed.meta || parsed.control)) { return { ok: false, error: 'Use either Mod or a platform-specific modifier, not both.' } } + if (parsed.doubleTapModifier) { + return { ok: true, value: canonicalizeParsedKeybinding(parsed) } + } const isShiftInsert = parsed.shift && parsed.key === 'Insert' const isBareAllowed = options.allowBareKeybindings === true && isSafeBareKey(parsed) if ( @@ -1105,6 +1175,10 @@ export function normalizeKeybinding(binding: string): KeybindingValidationResult return normalizeKeybindingWithOptions(binding) } +export function isDoubleTapBinding(binding: string): boolean { + return Boolean(parseKeybinding(binding)?.doubleTapModifier) +} + function normalizeKeybindingListWithOptions( input: string, options: NormalizeKeybindingOptions = {} @@ -1291,11 +1365,33 @@ function keyTokenFromInput(input: KeybindingInput, platform: NodeJS.Platform): s return physicalCodeKeyTokenFromInput(input) } +// Why: the platform primary modifier canonicalizes to Mod, mirroring normal +// capture where Cmd on macOS / Ctrl elsewhere both become Mod. +function canonicalDoubleTapToken( + modifier: PhysicalModifierToken, + platform: NodeJS.Platform +): ModifierToken { + const isMac = platform === 'darwin' + if (modifier === 'Cmd' && isMac) { + return 'Mod' + } + if (modifier === 'Ctrl' && !isMac) { + return 'Mod' + } + return modifier +} + function keybindingFromInputWithOptions( input: KeybindingInput, platform: NodeJS.Platform, options: NormalizeKeybindingOptions = {} ): KeybindingValidationResult { + if (input.doubleTapModifier) { + return normalizeKeybindingWithOptions( + `DoubleTap+${canonicalDoubleTapToken(input.doubleTapModifier, platform)}`, + options + ) + } const key = keyTokenFromInput(input, platform) if (!key) { return { ok: false, error: 'Press a key, not only a modifier.' } @@ -1564,6 +1660,24 @@ function keyMatches( return canUsePhysicalCodeFallback(input) && physicalCodeKeyTokenFromInput(input) === parsedKey } +function resolveModifierToken( + modifier: ModifierToken, + platform: NodeJS.Platform +): 'meta' | 'control' | 'alt' | 'shift' { + switch (modifier) { + case 'Mod': + return platform === 'darwin' ? 'meta' : 'control' + case 'Cmd': + return 'meta' + case 'Ctrl': + return 'control' + case 'Alt': + return 'alt' + case 'Shift': + return 'shift' + } +} + export function keybindingMatchesInput( binding: string, input: KeybindingInput, @@ -1573,11 +1687,31 @@ export function keybindingMatchesInput( if (!parsed) { return false } + // A double-tap binding matches only a synthetic double-tap input, resolved per + // platform; a normal binding never matches a synthetic input, and vice-versa. + if (parsed.doubleTapModifier) { + return ( + input.doubleTapModifier !== undefined && + resolveModifierToken(parsed.doubleTapModifier, platform) === + resolveModifierToken(input.doubleTapModifier, platform) + ) + } + if (input.doubleTapModifier !== undefined) { + return false + } return ( modifierStateMatches(parsed, input, platform) && keyMatches(parsed.key, input, parsed, platform) ) } +function keybindingConflictIdentity(binding: string, platform: NodeJS.Platform): string { + const parsed = parseKeybinding(binding) + if (!parsed?.doubleTapModifier) { + return binding + } + return `DoubleTap:${resolveModifierToken(parsed.doubleTapModifier, platform)}` +} + export function keybindingMatchesAction( actionId: KeybindingActionId, input: KeybindingInput, @@ -1597,12 +1731,31 @@ export function keybindingMatchesAction( ) } +function formatModifierGlyph(modifier: ModifierToken, isMac: boolean): string { + switch (modifier) { + case 'Mod': + return isMac ? '⌘' : 'Ctrl' + case 'Cmd': + return isMac ? '⌘' : 'Cmd' + case 'Ctrl': + return isMac ? '⌃' : 'Ctrl' + case 'Alt': + return isMac ? '⌥' : 'Alt' + case 'Shift': + return isMac ? '⇧' : 'Shift' + } +} + export function formatKeybinding(binding: string, platform: NodeJS.Platform): string[] { const parsed = parseKeybinding(binding) if (!parsed) { return [binding] } const isMac = platform === 'darwin' + if (parsed.doubleTapModifier) { + const glyph = formatModifierGlyph(parsed.doubleTapModifier, isMac) + return [glyph, glyph] + } const parts: string[] = [] if (parsed.mod) { parts.push(isMac ? '⌘' : 'Ctrl') @@ -1631,7 +1784,10 @@ export function formatKeybindingList( return 'Unassigned' } return bindings - .map((binding) => formatKeybinding(binding, platform).join(platform === 'darwin' ? '' : '+')) + .map((binding) => { + const separator = isDoubleTapBinding(binding) ? ' ' : platform === 'darwin' ? '' : '+' + return formatKeybinding(binding, platform).join(separator) + }) .join(', ') } @@ -1674,7 +1830,7 @@ export function findKeybindingConflicts( overrides?: KeybindingOverrides, options: FindKeybindingConflictOptions = {} ): KeybindingConflict[] { - const owners = new Map() + const owners = new Map }>() const ignoredActionIds = new Set(options.ignoredActionIds ?? []) const customizedActions = new Set( Object.keys(overrides ?? {}).filter( @@ -1694,21 +1850,27 @@ export function findKeybindingConflicts( groups.add(definition.scope) } for (const group of groups) { - const conflictKey = `${group}\u0000${binding}` - const current = owners.get(conflictKey) ?? [] - current.push(definition.id) + const conflictKey = `${group}\u0000${keybindingConflictIdentity(binding, platform)}` + const current = owners.get(conflictKey) ?? { binding, actionIds: new Set() } + current.actionIds.add(definition.id) owners.set(conflictKey, current) } } } - return Array.from(owners.entries()) - .filter( - ([, actionIds]) => - actionIds.length > 1 && actionIds.some((actionId) => customizedActions.has(actionId)) - ) - .map(([conflictKey, actionIds]) => ({ - binding: conflictKey.slice(conflictKey.indexOf('\u0000') + 1), - actionIds + return Array.from(owners.values()) + .filter(({ actionIds }) => actionIds.size > 1 && setIntersects(actionIds, customizedActions)) + .map(({ binding, actionIds }) => ({ + binding, + actionIds: Array.from(actionIds) })) } + +function setIntersects(left: ReadonlySet, right: ReadonlySet): boolean { + for (const value of left) { + if (right.has(value)) { + return true + } + } + return false +} diff --git a/src/shared/modifier-double-tap-detector.test.ts b/src/shared/modifier-double-tap-detector.test.ts new file mode 100644 index 000000000..6bafb6d4a --- /dev/null +++ b/src/shared/modifier-double-tap-detector.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest' +import { + ModifierDoubleTapDetector, + modifierFromKeyEvent, + toModifierDoubleTapEvent, + type ModifierDoubleTapEvent +} from './modifier-double-tap-detector' + +function down( + modifier: ModifierDoubleTapEvent['modifier'], + overrides: Partial = {} +): ModifierDoubleTapEvent { + return { type: 'keyDown', modifier, isModifierOnly: true, isAutoRepeat: false, ...overrides } +} + +function up( + modifier: ModifierDoubleTapEvent['modifier'], + overrides: Partial = {} +): ModifierDoubleTapEvent { + return { type: 'keyUp', modifier, isModifierOnly: true, isAutoRepeat: false, ...overrides } +} + +const otherKey: ModifierDoubleTapEvent = { + type: 'keyDown', + modifier: null, + isModifierOnly: false, + isAutoRepeat: false +} + +describe('ModifierDoubleTapDetector', () => { + it('emits when the second press lands inside the window', () => { + const d = new ModifierDoubleTapDetector() + expect(d.process(down('Shift'), 0)).toBeNull() + expect(d.process(up('Shift'), 10)).toBeNull() + expect(d.process(down('Shift'), 200)).toEqual({ modifier: 'Shift' }) + }) + + it('does not emit when the second press is past the window', () => { + const d = new ModifierDoubleTapDetector() + d.process(down('Shift'), 0) + d.process(up('Shift'), 10) + expect(d.process(down('Shift'), 400)).toBeNull() + }) + + it('resets on an intervening non-modifier key', () => { + const d = new ModifierDoubleTapDetector() + d.process(down('Shift'), 0) + d.process(up('Shift'), 10) + expect(d.process(otherKey, 20)).toBeNull() + expect(d.process(down('Shift'), 100)).toBeNull() + }) + + it('treats a different modifier as a fresh gesture, not a completion', () => { + const d = new ModifierDoubleTapDetector() + d.process(down('Shift'), 0) + d.process(up('Shift'), 10) + // Wrong modifier: no emit, but it begins a new first tap. + expect(d.process(down('Alt'), 100)).toBeNull() + expect(d.process(up('Alt'), 110)).toBeNull() + expect(d.process(down('Alt'), 150)).toEqual({ modifier: 'Alt' }) + }) + + it('does not treat an auto-repeat hold as a tap', () => { + const d = new ModifierDoubleTapDetector() + d.process(down('Shift'), 0) + // Holding the key emits auto-repeat keyDowns — this must cancel the gesture. + expect(d.process(down('Shift', { isAutoRepeat: true }), 30)).toBeNull() + d.process(up('Shift'), 500) + expect(d.process(down('Shift'), 520)).toBeNull() + }) + + it('does not emit when another modifier is held (isModifierOnly false)', () => { + const d = new ModifierDoubleTapDetector() + expect(d.process(down('Shift', { isModifierOnly: false }), 0)).toBeNull() + d.process(up('Shift'), 10) + expect(d.process(down('Shift'), 100)).toBeNull() + }) + + it('handles a second keyDown of the same modifier without an intervening keyUp', () => { + const d = new ModifierDoubleTapDetector() + d.process(down('Shift'), 0) + // Missed keyUp — a fresh (non-repeat) keyDown for the same modifier just + // restarts the first tap rather than emitting. + d.process(down('Shift'), 50) + d.process(up('Shift'), 60) + // The next press within the window still completes the gesture. + expect(d.process(down('Shift'), 200)).toEqual({ modifier: 'Shift' }) + }) + + it('clears armed state when the second keydown was suppressed (allowlisted path)', () => { + const d = new ModifierDoubleTapDetector() + d.process(down('Shift'), 0) // first tap down → down1 + d.process(up('Shift'), 10) // first tap up → armed + // The main process suppressed the second keydown (an allowlisted action fired + // there), but the second tap's keyup still reaches this detector. + d.process(up('Shift'), 20) + // A later lone Shift press (e.g. typing a capital) must NOT phantom-complete + // a double-tap from the stale armed state. + expect(d.process(down('Shift'), 200)).toBeNull() + }) + + it('clears state on reset()', () => { + const d = new ModifierDoubleTapDetector() + d.process(down('Shift'), 0) + d.process(up('Shift'), 10) + d.reset() + expect(d.process(down('Shift'), 100)).toBeNull() + }) + + it('normalizes platform key events', () => { + expect(modifierFromKeyEvent('ShiftLeft', 'Shift')).toBe('Shift') + expect(modifierFromKeyEvent('MetaRight', 'Meta')).toBe('Cmd') + expect(modifierFromKeyEvent('ControlLeft', 'Control')).toBe('Ctrl') + expect(modifierFromKeyEvent('KeyA', 'a')).toBeNull() + + expect( + toModifierDoubleTapEvent({ type: 'keyDown', code: 'ShiftLeft', key: 'Shift', shift: true }) + ).toEqual({ type: 'keyDown', modifier: 'Shift', isModifierOnly: true, isAutoRepeat: false }) + + // Another modifier held → not a bare modifier event. + expect( + toModifierDoubleTapEvent({ + type: 'keyDown', + code: 'ShiftLeft', + key: 'Shift', + shift: true, + meta: true + }) + ).toMatchObject({ modifier: 'Shift', isModifierOnly: false }) + + expect( + toModifierDoubleTapEvent({ type: 'keyDown', code: 'KeyA', key: 'a' }) + ).toMatchObject({ modifier: null, isModifierOnly: false }) + }) +}) diff --git a/src/shared/modifier-double-tap-detector.ts b/src/shared/modifier-double-tap-detector.ts new file mode 100644 index 000000000..aea6b6527 --- /dev/null +++ b/src/shared/modifier-double-tap-detector.ts @@ -0,0 +1,153 @@ +import type { PhysicalModifierToken } from './keybindings' + +// Why: max gap between the first release and the second press. Internal — not +// user-configurable — and tight enough that normal fast typing never triggers. +const DOUBLE_TAP_WINDOW_MS = 300 + +export type ModifierDoubleTapEventType = 'keyDown' | 'keyUp' + +// A keyboard event normalized to just what the detector needs. +export type ModifierDoubleTapEvent = { + type: ModifierDoubleTapEventType + // Which physical modifier this event is about, or null for any other key. + modifier: PhysicalModifierToken | null + // True only for a bare modifier press/release with no OTHER modifier held. + isModifierOnly: boolean + isAutoRepeat: boolean +} + +export type DetectedDoubleTap = { modifier: PhysicalModifierToken } + +export type ModifierKeyEventLike = { + type: ModifierDoubleTapEventType + code?: string + key?: string + shift?: boolean + control?: boolean + alt?: boolean + meta?: boolean + isAutoRepeat?: boolean +} + +const MODIFIER_BY_CODE: Record = { + ShiftLeft: 'Shift', + ShiftRight: 'Shift', + ControlLeft: 'Ctrl', + ControlRight: 'Ctrl', + AltLeft: 'Alt', + AltRight: 'Alt', + MetaLeft: 'Cmd', + MetaRight: 'Cmd' +} + +const MODIFIER_BY_KEY: Record = { + Shift: 'Shift', + Control: 'Ctrl', + Alt: 'Alt', + Meta: 'Cmd' +} + +// Maps a physical key event to the modifier it represents, or null for any +// non-modifier key. Detector output is always a physical token (never 'Mod'). +export function modifierFromKeyEvent( + code: string | undefined, + key: string | undefined +): PhysicalModifierToken | null { + if (code && MODIFIER_BY_CODE[code]) { + return MODIFIER_BY_CODE[code] + } + return key ? (MODIFIER_BY_KEY[key] ?? null) : null +} + +function otherModifierHeld(event: ModifierKeyEventLike, modifier: PhysicalModifierToken): boolean { + if (modifier !== 'Shift' && event.shift) { + return true + } + if (modifier !== 'Ctrl' && event.control) { + return true + } + if (modifier !== 'Alt' && event.alt) { + return true + } + if (modifier !== 'Cmd' && event.meta) { + return true + } + return false +} + +// Normalizes a platform key event (DOM or Electron) into the detector input. +export function toModifierDoubleTapEvent(event: ModifierKeyEventLike): ModifierDoubleTapEvent { + const modifier = modifierFromKeyEvent(event.code, event.key) + return { + type: event.type, + modifier, + isModifierOnly: modifier !== null && !otherModifierHeld(event, modifier), + isAutoRepeat: Boolean(event.isAutoRepeat) + } +} + +type DetectorState = + | { phase: 'idle' } + | { phase: 'down1'; modifier: PhysicalModifierToken } + | { phase: 'armed'; modifier: PhysicalModifierToken; deadlineMs: number } + +export class ModifierDoubleTapDetector { + private state: DetectorState = { phase: 'idle' } + + process(event: ModifierDoubleTapEvent, timestampMs: number): DetectedDoubleTap | null { + // A non-modifier key, or a modifier chorded with another, breaks the gesture. + // (On keyUp, isModifierOnly:false means another modifier is still held — the + // gesture was already reset at that modifier's keyDown.) + if (event.modifier === null || !event.isModifierOnly) { + this.state = { phase: 'idle' } + return null + } + if (event.type === 'keyUp') { + this.onModifierUp(event.modifier, timestampMs) + return null + } + return this.onModifierDown(event.modifier, event.isAutoRepeat, timestampMs) + } + + reset(): void { + this.state = { phase: 'idle' } + } + + private onModifierDown( + modifier: PhysicalModifierToken, + isAutoRepeat: boolean, + timestampMs: number + ): DetectedDoubleTap | null { + if ( + this.state.phase === 'armed' && + this.state.modifier === modifier && + !isAutoRepeat && + timestampMs <= this.state.deadlineMs + ) { + this.state = { phase: 'idle' } + return { modifier } + } + // Auto-repeat means the key is being held, not tapped. + if (isAutoRepeat) { + this.state = { phase: 'idle' } + return null + } + // Any other fresh bare-modifier press (re)starts from the first tap. + this.state = { phase: 'down1', modifier } + return null + } + + private onModifierUp(modifier: PhysicalModifierToken, timestampMs: number): void { + if (this.state.phase === 'down1' && this.state.modifier === modifier) { + this.state = { phase: 'armed', modifier, deadlineMs: timestampMs + DOUBLE_TAP_WINDOW_MS } + return + } + // Why: a keyup of the armed modifier with no intervening second keydown means + // the second press was consumed elsewhere (the main process suppresses it for + // an allowlisted action). Clear armed so a later lone press of the same + // modifier can't phantom-complete a double-tap. + if (this.state.phase === 'armed' && this.state.modifier === modifier) { + this.state = { phase: 'idle' } + } + } +} diff --git a/src/shared/window-shortcut-policy.test.ts b/src/shared/window-shortcut-policy.test.ts index d6f00352f..a0c202bf6 100644 --- a/src/shared/window-shortcut-policy.test.ts +++ b/src/shared/window-shortcut-policy.test.ts @@ -763,4 +763,21 @@ describe('resolveWindowShortcutAction', () => { false ) }) + + it('resolves an allowlisted action from a synthetic double-tap input', () => { + // (a) A synthetic DoubleTap+Shift input resolves the overridden action. + const overrides: KeybindingOverrides = { 'worktree.quickOpen': ['DoubleTap+Shift'] } + expect( + resolveWindowShortcutAction({ doubleTapModifier: 'Shift' }, 'darwin', overrides) + ).toEqual({ type: 'openQuickOpen' }) + + // (b) A different modifier does not resolve it. + expect( + resolveWindowShortcutAction({ doubleTapModifier: 'Alt' }, 'darwin', overrides) + ).toBeNull() + + // (c) Implicit numeric shortcuts are guarded on input.key, which a double-tap + // input never has, so they cannot accidentally match a double-tap event. + expect(resolveWindowShortcutAction({ doubleTapModifier: 'Cmd' }, 'darwin')).toBeNull() + }) }) diff --git a/src/shared/window-shortcut-policy.ts b/src/shared/window-shortcut-policy.ts index 7aa288e14..234960b18 100644 --- a/src/shared/window-shortcut-policy.ts +++ b/src/shared/window-shortcut-policy.ts @@ -6,7 +6,8 @@ import { normalizeTerminalShortcutPolicy, type KeybindingActionId, type KeybindingMatchOptions, - type KeybindingOverrides + type KeybindingOverrides, + type PhysicalModifierToken } from './keybindings' export type WindowShortcutInput = { @@ -21,6 +22,9 @@ export type WindowShortcutInput = { metaKey?: boolean ctrlKey?: boolean shiftKey?: boolean + // Set only by the double-tap detector; threads the synthetic input through + // the main-process resolver so allowlisted actions can fire on double-tap. + doubleTapModifier?: PhysicalModifierToken } export type WindowShortcutAction =