orca/src/shared/modifier-double-tap-detecto...

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' }
}
}
}