mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
feat(shortcuts): double-tap modifier keybindings (#5516)
* docs: design for double-tap modifier keybindings * feat(keybindings): parse and normalize DoubleTap+<Mod> 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+<Mod> * 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 <help@stably.ai> --------- Co-authored-by: Devin345458 <devin@appleidimagination.com> Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com> Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
co-authored by
Orca
Devin345458
Jinwoo-H
parent
0eec0ad74a
commit
1368ba84b8
+180
-145
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user