Files
orca/src/shared/modifier-double-tap-detector.ts
T
1368ba84b8 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>
2026-06-17 18:25:27 -07:00

154 lines
4.8 KiB
TypeScript

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<string, PhysicalModifierToken> = {
ShiftLeft: 'Shift',
ShiftRight: 'Shift',
ControlLeft: 'Ctrl',
ControlRight: 'Ctrl',
AltLeft: 'Alt',
AltRight: 'Alt',
MetaLeft: 'Cmd',
MetaRight: 'Cmd'
}
const MODIFIER_BY_KEY: Record<string, PhysicalModifierToken> = {
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' }
}
}
}