diff --git a/apps/desktop/src/main/services/KeyBindingService.ts b/apps/desktop/src/main/services/KeyBindingService.ts index ae78fb2..92a9ff5 100644 --- a/apps/desktop/src/main/services/KeyBindingService.ts +++ b/apps/desktop/src/main/services/KeyBindingService.ts @@ -1,13 +1,12 @@ // src/main/services/KeyBindingService.ts -// uiohook-napi 기반 글로벌 키바인딩 후킹 서비스. +// uiohook-napi 기반 글로벌 키바인딩 후킹 어댑터. // -// 바인딩 계약(키 목록·기본값·정규화·검증·충돌 판정)의 정본은 `packages/core/src/keybinding.ts` 다. -// 이 파일은 두 가지만 책임진다: +// 바인딩 계약(키 목록·기본값·정규화·검증·충돌 판정)의 정본은 `packages/core/src/keybinding.ts`, +// press / release · 더블프레스 · auto-repeat · AltGr 보류 상태 머신의 정본은 +// `packages/core/src/keybinding-runtime.ts` 다. 이 파일은 입출력 어댑터만 책임진다: // 1) 정본 좌표계(Windows VK / MouseButton) ↔ uiohook 이벤트 좌표계 변환 -// 2) press / release · 더블프레스 · auto-repeat 런타임 상태 머신 -// -// 런타임 상태는 액션 id 가 아니라 bindingKey() 로 키잉한다 — -// 한 액션에 여러 바인딩이 붙고, 여러 액션이 한 바인딩을 공유하기 때문이다. +// 2) 설정 로딩 → 상태 머신 바인딩 등록 +// 3) macOS beep 차단용 globalShortcut accelerator 관리 import { EventEmitter } from 'events' import { globalShortcut } from 'electron' @@ -17,7 +16,6 @@ import { getLogger } from './LoggerService' import { acquireGlobalInputHook } from './global-input-hook' import { configGet } from './ConfigService' import { D3ROError, ErrorCode } from '@d3ro/core/errors' -import { TIMING } from '@d3ro/core/constants' import { KEYBINDING_ACTIONS, MouseButton, @@ -25,82 +23,42 @@ import { bindingKey, normalizeBinding } from '@d3ro/core/keybinding' +import { ChordStateMachine } from '@d3ro/core/keybinding-runtime' import type { - KeyBinding, - KeyBindingActionId, - KeyBindingActionSpec, - MouseButtonCode -} from '@d3ro/core/types' + ChordBindingEntry, + ChordClock, + ChordTriggerEvent +} from '@d3ro/core/keybinding-runtime' +import type { KeyBinding, KeyBindingActionSpec, MouseButtonCode } from '@d3ro/core/types' const logger = getLogger('KeyBindingService') -/** - * AltGr(=Ctrl+Alt) 함정 가드. - * - * 오른쪽 Alt 는 Windows 가 Ctrl+Alt 로 보내므로, Ctrl+Alt+화살표 같은 조합을 누르면 - * Alt 를 누르는 순간 Ctrl+AltRight 바인딩이 먼저 발동한다(실측 신고: 제안 탐색 대신 - * 음성 파이프라인이 돌았다). 수정자만으로 끝나면서 Ctrl+Alt 가 함께 눌린 트리거는 - * 잠깐 보류하고, 그 사이에 다른 키가 눌리면(=조합을 만들려던 것) 취소한다. - * - * 받아쓰기(수정자 단독, Ctrl 없음)와 Alt+Shift 계열은 모양이 달라 영향받지 않는다. - */ -const ALTGR_CHORD_GRACE_MS = 280 - -/** 수정자 키의 VK 코드 (수정자만으로 끝나는 조합 판별). */ -const MODIFIER_VK_CODES: ReadonlySet = new Set([ - 0x10, // Shift - 0x11, // Ctrl - 0x12, // Alt - 0x5b, // LWin - 0x5c, // RWin - 0xa0, - 0xa1, // L/R Shift - 0xa2, - 0xa3, // L/R Ctrl - 0xa4, - 0xa5 // L/R Alt -]) - -let pendingAltGrEmit: NodeJS.Timeout | null = null -/** 보류가 취소된 바인딩 — 대응하는 released 를 내보내지 않기 위해 기억한다. */ -const cancelledAltGrBindings = new Set() - // ============================================================ // 이벤트 페이로드 // ============================================================ -export interface KeyBindingTriggerPayload { - actionId: KeyBindingActionId - type: 'pressed' | 'released' - /** 더블프레스로 트리거된 누름인지 */ - isDoublePress: boolean - /** KEYBINDING_ACTIONS 의 holdMode — 소비자가 hold-to-talk / 토글을 분기하는 데 쓴다 */ - holdMode: boolean - timestamp: number - /** type === 'released' 일 때의 누름 유지 시간 (pressed 는 0) */ - durationMs: number - /** - * 매칭된 바인딩의 주 키 코드와 수정자 상태. - * - * 소비자가 "수정자만으로 끝나는 조합인지" 를 판단해 AltGr 함정을 피하는 데 쓴다. - * (오른쪽 Alt 는 Windows 가 Ctrl+Alt 로 보내므로, Ctrl+Alt+화살표를 누르면 - * Alt 를 누르는 순간 Ctrl+AltRight 바인딩이 먼저 발동한다) - */ - binding: { - device: 'keyboard' | 'mouse' - code: number - ctrl: boolean - alt: boolean - shift: boolean - meta: boolean - } -} +/** + * `triggered` 이벤트 페이로드. 정본은 core 의 ChordTriggerEvent 다. + * + * binding 은 매칭된 바인딩(정규화 완료)의 주 키 코드와 수정자 상태다. + */ +export type KeyBindingTriggerPayload = ChordTriggerEvent interface KeyBindingServiceEvents { triggered: (payload: KeyBindingTriggerPayload) => void error: (payload: { error: D3ROError }) => void } +/** 실제 시계. 보류 타이머가 프로세스 종료를 붙잡지 않도록 unref 한다. */ +const systemClock: ChordClock = { + now: () => Date.now(), + schedule: (callback, delayMs) => { + const handle = setTimeout(callback, delayMs) + handle.unref?.() + return () => clearTimeout(handle) + } +} + // ============================================================ // Windows VK ↔ uiohook 키코드 // ============================================================ @@ -258,22 +216,6 @@ export function uiohookCodeToVk(code: number): number | null { return UIOHOOK_TO_VK.get(code) ?? null } -function isCtrlCode(code: number): boolean { - return code === UiohookKey.Ctrl || code === UiohookKey.CtrlRight -} - -function isAltCode(code: number): boolean { - return code === UiohookKey.Alt || code === UiohookKey.AltRight -} - -function isShiftCode(code: number): boolean { - return code === UiohookKey.Shift || code === UiohookKey.ShiftRight -} - -function isMetaCode(code: number): boolean { - return code === UiohookKey.Meta || code === UiohookKey.MetaRight -} - // ============================================================ // 마우스 버튼 좁히기 // ============================================================ @@ -365,66 +307,38 @@ function bindingToAccelerator(binding: KeyBinding): string | null { // 내부 타입 // ============================================================ -interface RegisteredBinding { - actionId: KeyBindingActionId +interface RegisteredAction { spec: KeyBindingActionSpec /** 정본 좌표계 바인딩 (정규화 완료) */ binding: KeyBinding - /** keyboard → uiohook 키코드 / mouse → MouseButton 코드 */ - eventCode: number - /** macOS beep 차단용 accelerator. 마우스 바인딩과 변환 불가 키는 null */ - accelerator: string | null -} - -interface ActiveTrigger { - actionId: KeyBindingActionId - isDoublePress: boolean - holdMode: boolean - /** 눌림을 만든 바인딩 (AltGr 형태 판정에 필요) */ - entry: RegisteredBinding } // ============================================================ // KeyBindingService // ============================================================ -/** AltGr(=Ctrl+Alt) 형태의 수정자 전용 조합인가. */ -function isAltGrShaped(binding: KeyBinding): boolean { - if (binding.device !== 'keyboard') return false - if (!binding.ctrl || !binding.alt) return false - return MODIFIER_VK_CODES.has(binding.code) -} - class KeyBindingService extends EventEmitter { private _isRunning = false - /** bindingKey → 그 바인딩을 쓰는 액션들. 매칭은 이 역인덱스 조회로 끝난다. */ - private _byBindingKey: Map = new Map() + /** press / release · 더블프레스 · AltGr 보류 판정 (순수 정책, core) */ + private readonly _machine = new ChordStateMachine({ + clock: systemClock, + onTrigger: (event) => this.emit('triggered', event), + debug: (message) => logger.debug(message) + }) /** 이 서비스가 직접 등록한 accelerator만 추적한다 (다른 곳의 등록을 해제하지 않기 위해) */ private _ownedAccelerators: Set = new Set() - /** 키 반복(auto-repeat) 방지: 현재 눌려있는 바인딩 */ - private _isKeyDown: Map = new Map() - - /** hold duration 계산용: press 시작 시각 */ - private _pressStartTime: Map = new Map() - - /** 더블프레스 감지용: 마지막 press 시각 */ - private _lastPressTime: Map = new Map() - - /** 현재 누름에서 실제로 트리거된 액션 — release 를 같은 대상에게만 보낸다 */ - private _activeTriggers: Map = new Map() - private _onKeyDown: ((e: UiohookKeyboardEvent) => void) | null = null private _onKeyUp: ((e: UiohookKeyboardEvent) => void) | null = null private _onMouseDown: ((e: UiohookMouseEvent) => void) | null = null private _onMouseUp: ((e: UiohookMouseEvent) => void) | null = null /** - * 글로벌 후 해제 함수. + * 글로벌 후킹 해제 함수. * - * 후킹은 프로세스 전역 글턴이고 InputTelemetryService 도 같은 후킹을 쓴다. + * 후킹은 프로세스 전역 싱글턴이고 InputTelemetryService 도 같은 후킹을 쓴다. * 직접 stop 하면 상대방 수신을 죽이므로 참조 카운트 해제자를 보관한다. */ private _releaseHook: (() => void) | null = null @@ -435,7 +349,7 @@ class KeyBindingService extends EventEmitter { /** 등록된 바인딩 수 (bindingKey 기준) */ get registeredBindingCount(): number { - return this._byBindingKey.size + return this._machine.registeredBindingCount } // ── 수명 주기 ────────────────────────────────────────── @@ -515,75 +429,59 @@ class KeyBindingService extends EventEmitter { dispose(): void { this.stop() - this._byBindingKey.clear() + // setBindings 는 런타임 상태(보류 타이머 포함)도 함께 비운다. + this._machine.setBindings([]) this._unregisterOwnedAccelerators() - this._clearRuntimeState() this.removeAllListeners() logger.info('KeyBindingService disposed') } // ── 등록 ─────────────────────────────────────────────── - /** ConfigService 의 keyBindings 를 읽어 역인덱스를 다시 만든다. */ + /** ConfigService 의 keyBindings 를 읽어 상태 머신 바인딩을 다시 만든다. */ loadFromConfig(): void { - this._byBindingKey.clear() - this._clearRuntimeState() - if (!configGet('hotkeyEnabled')) { + // setBindings 는 런타임 상태(보류 타이머 포함)도 함께 비운다. + this._machine.setBindings([]) this._unregisterOwnedAccelerators() logger.info('Key bindings disabled in config') return } const map = configGet('keyBindings') + const entries: ChordBindingEntry[] = [] for (const spec of KEYBINDING_ACTIONS) { for (const raw of map[spec.id] ?? []) { - this._register(spec, raw) + const registered = this._toObservable(spec, raw) + if (registered === null) continue + entries.push({ + actionId: registered.spec.id, + holdMode: registered.spec.holdMode, + doublePress: registered.spec.doublePress, + binding: registered.binding + }) } } + this._machine.setBindings(entries) this._syncAccelerators() logger.info( - `Loaded ${this._byBindingKey.size} key binding(s) from config ` + + `Loaded ${this._machine.registeredBindingCount} key binding(s) from config ` + `for ${KEYBINDING_ACTIONS.length} action(s)` ) } - private _register(spec: KeyBindingActionSpec, raw: KeyBinding): void { + /** uiohook 이벤트로 관측할 수 있는 바인딩만 통과시킨다. */ + private _toObservable(spec: KeyBindingActionSpec, raw: KeyBinding): RegisteredAction | null { const binding = normalizeBinding(raw) - - let eventCode: number | null - if (binding.device === 'mouse') { - eventCode = binding.code - } else { - eventCode = VK_TO_UIOHOOK.get(binding.code) ?? null - } - - if (eventCode === null) { + if (binding.device === 'keyboard' && !VK_TO_UIOHOOK.has(binding.code)) { logger.warn( `Key code 0x${binding.code.toString(16)} has no uiohook equivalent ` + `— binding for action "${spec.id}" is not registered` ) - return + return null } - - const key = bindingKey(binding) - const entry: RegisteredBinding = { - actionId: spec.id, - spec, - binding, - eventCode, - accelerator: binding.device === 'keyboard' ? bindingToAccelerator(binding) : null - } - - const existing = this._byBindingKey.get(key) - if (existing === undefined) { - this._byBindingKey.set(key, [entry]) - return - } - // 같은 액션이 같은 바인딩을 두 번 들고 있으면 한 번 누를 때 두 번 트리거된다. - if (existing.some((registered) => registered.actionId === spec.id)) return - existing.push(entry) + return { spec, binding } } // ── accelerator (macOS beep 차단) ────────────────────── @@ -592,10 +490,10 @@ class KeyBindingService extends EventEmitter { this._unregisterOwnedAccelerators() if (!this._isRunning) return - for (const entries of this._byBindingKey.values()) { - // 같은 bindingKey 를 공유하는 엔트리는 바인딩이 동일하므로 accelerator 도 같다. - const accelerator = entries[0]?.accelerator - if (accelerator === undefined || accelerator === null) continue + for (const binding of this._machine.registeredBindings()) { + if (binding.device !== 'keyboard') continue + const accelerator = bindingToAccelerator(binding) + if (accelerator === null) continue if (this._ownedAccelerators.has(accelerator)) continue try { @@ -628,23 +526,20 @@ class KeyBindingService extends EventEmitter { this._ownedAccelerators.clear() } + /** 눌림 · 더블프레스 · AltGr 보류 타이머를 모두 비운다. */ private _clearRuntimeState(): void { - this._isKeyDown.clear() - this._pressStartTime.clear() - this._lastPressTime.clear() - this._activeTriggers.clear() + this._machine.reset() } - // ── 이벤트 → 바인딩 매칭 ─────────────────────────────── + // ── uiohook 이벤트 → 정본 좌표계 ────────────────────── /** - * uiohook 이벤트를 정본 좌표계 bindingKey 로 옮긴다. + * uiohook 키보드 이벤트를 정본 좌표계 bindingKey 로 옮긴다. * normalizeBinding 이 "주 키가 수정자 자신"인 경우를 정리하므로 * Right Alt 단독 바인딩도 그대로 매칭된다. */ - private _keyboardEventKey(e: UiohookKeyboardEvent): string | null { - const vk = UIOHOOK_TO_VK.get(e.keycode) - if (vk === undefined) return null + private _keyboardEventKey(e: UiohookKeyboardEvent, vk: number | null): string | null { + if (vk === null) return null return bindingKey({ device: 'keyboard', code: vk, @@ -669,202 +564,31 @@ class KeyBindingService extends EventEmitter { } private _handleKeyDown(e: UiohookKeyboardEvent): void { - const key = this._keyboardEventKey(e) + const key = this._keyboardEventKey(e, uiohookCodeToVk(e.keycode)) if (key === null) return - this._handlePress(key) + this._machine.keyDown(key) } private _handleMouseDown(e: UiohookMouseEvent): void { const key = this._mouseEventKey(e) if (key === null) return - this._handlePress(key) + this._machine.keyDown(key) } /** - * 키 업. - * Alt+1 같은 조합에서 수정자를 먼저 놓아도 release 를 놓치지 않아야 하므로, - * 정확 매칭이 실패하면 눌림 상태인 바인딩의 구성 키가 놓였는지도 확인한다. + * 키 업. Alt+1 같은 조합에서 수정자를 먼저 놓아도 release 를 놓치지 않도록 + * 놓인 키의 VK 를 함께 넘긴다 — 정확 매칭이 실패하면 상태 머신이 구성 키 여부를 본다. */ private _handleKeyUp(e: UiohookKeyboardEvent): void { - const key = this._keyboardEventKey(e) - if (key !== null && this._isKeyDown.get(key) === true) { - this._fireRelease(key) - return - } - this._releaseByComponentKey(e.keycode) + const vk = uiohookCodeToVk(e.keycode) + this._machine.keyUp(this._keyboardEventKey(e, vk), vk) } + /** 마우스 업은 정확 매칭만 한다. */ private _handleMouseUp(e: UiohookMouseEvent): void { const key = this._mouseEventKey(e) - if (key !== null && this._isKeyDown.get(key) === true) { - this._fireRelease(key) - } - } - - /** 눌림 상태인 바인딩 중 놓인 키가 주 키/수정자인 것을 release 시킨다. */ - private _releaseByComponentKey(uiohookCode: number): void { - for (const [key, entries] of this._byBindingKey) { - if (this._isKeyDown.get(key) !== true) continue - const entry = entries[0] - if (entry === undefined) continue - - const isMainKey = - entry.binding.device === 'keyboard' && entry.eventCode === uiohookCode - if (isMainKey || this._isModifierComponent(uiohookCode, entry.binding)) { - this._fireRelease(key) - return - } - } - } - - private _isModifierComponent(uiohookCode: number, binding: KeyBinding): boolean { - if (binding.ctrl && isCtrlCode(uiohookCode)) return true - if (binding.alt && isAltCode(uiohookCode)) return true - if (binding.shift && isShiftCode(uiohookCode)) return true - if (binding.meta && isMetaCode(uiohookCode)) return true - return false - } - - // ── press / release ──────────────────────────────────── - - private _handlePress(key: string): void { - const entries = this._byBindingKey.get(key) - if (entries === undefined || entries.length === 0) return - - // 키 반복(auto-repeat) 무시 - if (this._isKeyDown.get(key) === true) return - this._isKeyDown.set(key, true) - - const now = Date.now() - this._pressStartTime.set(key, now) - - // 더블프레스 감지는 이 바인딩을 쓰는 액션 중 하나라도 doublePress 일 때만 한다. - // 그렇지 않으면 단일프레스 액션을 빠르게 두 번 누를 때 두 번째가 삼켜진다. - let isDoublePress = false - if (entries.some((entry) => entry.spec.doublePress)) { - const lastPress = this._lastPressTime.get(key) - isDoublePress = - lastPress !== undefined && now - lastPress < TIMING.DOUBLE_PRESS_DURATION - if (isDoublePress) { - this._lastPressTime.delete(key) - } else { - this._lastPressTime.set(key, now) - } - } - - // dictation(단일) 과 hands-free(더블) 는 같은 바인딩을 공유한다 — - // 첫 매치만 반환하지 않고 doublePress 여부로 갈라 해당하는 액션을 모두 트리거한다. - const triggers: ActiveTrigger[] = [] - for (const entry of entries) { - if (entry.spec.doublePress !== isDoublePress) continue - triggers.push({ - actionId: entry.actionId, - isDoublePress, - holdMode: entry.spec.holdMode, - entry - }) - } - this._activeTriggers.set(key, triggers) - - for (const trigger of triggers) { - logger.debug(`Key binding pressed: "${trigger.actionId}" (double=${isDoublePress})`) - this._emitPressed(trigger.entry, trigger, now, key) - } - } - - - /** - * 눌림 이벤트를 내보낸다. - * - * AltGr 형태(수정자 전용 + Ctrl+Alt)는 곧 다른 키가 이어질 수 있으므로 잠깐 - * 보류한다. 그 사이 다른 바인딩의 눌림이 오면 취소한다 — 오른쪽 Alt 는 Windows 가 - * Ctrl+Alt 로 보내므로, Ctrl+Alt+화살표를 누르면 Alt 순간에 Ctrl+AltRight(음성 명령) - * 가 먼저 발동하던 문제를 이 지점에서 막는다. - */ - private _emitPressed( - entry: RegisteredBinding, - trigger: ActiveTrigger, - now: number, - bindingKeyValue: string - ): void { - const payload: KeyBindingTriggerPayload = { - actionId: trigger.actionId, - type: 'pressed', - isDoublePress: trigger.isDoublePress, - holdMode: trigger.holdMode, - timestamp: now, - durationMs: 0, - binding: { - device: entry.binding.device, - code: entry.binding.code, - ctrl: entry.binding.ctrl, - alt: entry.binding.alt, - shift: entry.binding.shift, - meta: entry.binding.meta - } - } - - if (isAltGrShaped(entry.binding)) { - if (pendingAltGrEmit) clearTimeout(pendingAltGrEmit) - const actionId = trigger.actionId - // 취소 표시는 _fireRelease 가 쓰는 것과 같은 키여야 한다 (device:code 조합이 아니다). - cancelledAltGrBindings.add(actionId + ':' + bindingKeyValue) - pendingAltGrEmit = setTimeout(() => { - pendingAltGrEmit = null - logger.debug(`AltGr 형태 트리거 유예 후 발동: "${actionId}"`) - this.emit('triggered', payload) - }, ALTGR_CHORD_GRACE_MS) - pendingAltGrEmit.unref?.() - return - } - - // 다른 키가 이어졌다 = 조합을 만들려던 것 → 보류 중인 AltGr 트리거는 취소. - if (pendingAltGrEmit) { - clearTimeout(pendingAltGrEmit) - pendingAltGrEmit = null - logger.debug('AltGr 형태 보류 트리거 취소 (다른 키가 이어짐)') - } - - this.emit('triggered', payload) - } - - private _fireRelease(key: string): void { - this._isKeyDown.set(key, false) - - const now = Date.now() - const pressStart = this._pressStartTime.get(key) - const durationMs = pressStart !== undefined ? now - pressStart : 0 - this._pressStartTime.delete(key) - - const triggers = this._activeTriggers.get(key) ?? [] - this._activeTriggers.delete(key) - - for (const trigger of triggers) { - logger.debug( - `Key binding released: "${trigger.actionId}" (duration=${durationMs}ms)` - ) - if (cancelledAltGrBindings.delete(trigger.actionId + ':' + key)) { - logger.debug(`AltGr 보류가 취소된 바인딩의 released 는 내보내지 않는다: "${trigger.actionId}"`) - continue - } - - this.emit('triggered', { - actionId: trigger.actionId, - type: 'released', - isDoublePress: trigger.isDoublePress, - holdMode: trigger.holdMode, - timestamp: now, - durationMs, - binding: { - device: 'keyboard', - code: 0, - ctrl: false, - alt: false, - shift: false, - meta: false - } - }) - } + if (key === null) return + this._machine.keyUp(key, null) } // ── EventEmitter 타입 오버라이드 ─────────────────────── diff --git a/packages/core/__tests__/keybinding-runtime.test.ts b/packages/core/__tests__/keybinding-runtime.test.ts new file mode 100644 index 0000000..2fe0af5 --- /dev/null +++ b/packages/core/__tests__/keybinding-runtime.test.ts @@ -0,0 +1,479 @@ +// packages/core/__tests__/keybinding-runtime.test.ts +// 키바인딩 런타임 상태 머신(src/keybinding-runtime.ts) 계약 검증. +// press / release · auto-repeat · 더블프레스 · 구성 키 release · AltGr 보류를 +// 가짜 시계로 결정적으로 돌린다 (uiohook · electron 없이). + +import { describe, it, expect, beforeEach } from 'vitest' +import { + ALTGR_CHORD_GRACE_MS, + ChordStateMachine, + isAltGrShapedBinding, + modifierFlagOfKeyCode +} from '../src/keybinding-runtime' +import type { + ChordBindingEntry, + ChordClock, + ChordTriggerEvent +} from '../src/keybinding-runtime' +import { + KEYBINDING_ACTIONS, + MouseButton, + VK, + bindingKey, + kb, + normalizeBinding +} from '../src/keybinding' +import type { KeyBinding, KeyBindingActionId } from '../src/keybinding' + +// ============================================================ +// 가짜 시계 +// ============================================================ + +interface FakeTimer { + at: number + callback: () => void + active: boolean +} + +class FakeClock implements ChordClock { + private _now = 1_000 + private readonly _timers: FakeTimer[] = [] + + now(): number { + return this._now + } + + schedule(callback: () => void, delayMs: number): () => void { + const timer: FakeTimer = { at: this._now + delayMs, callback, active: true } + this._timers.push(timer) + return () => { + timer.active = false + } + } + + get activeTimerCount(): number { + return this._timers.filter((t) => t.active).length + } + + advance(ms: number): void { + const target = this._now + ms + for (;;) { + const due = this._timers + .filter((t) => t.active && t.at <= target) + .sort((a, b) => a.at - b.at)[0] + if (due === undefined) break + due.active = false + this._now = due.at + due.callback() + } + this._now = target + } +} + +// ============================================================ +// 하네스 +// ============================================================ + +function entry( + actionId: KeyBindingActionId, + binding: KeyBinding, + opts: { holdMode?: boolean; doublePress?: boolean } = {} +): ChordBindingEntry { + return { + actionId, + binding, + holdMode: opts.holdMode ?? false, + doublePress: opts.doublePress ?? false + } +} + +function defaultEntries(): ChordBindingEntry[] { + return KEYBINDING_ACTIONS.flatMap((spec) => + spec.defaultBindings.map((binding) => ({ + actionId: spec.id, + holdMode: spec.holdMode, + doublePress: spec.doublePress, + binding + })) + ) +} + +/** uiohook 어댑터가 만드는 것과 같은 이벤트 키 (이벤트 수정자 플래그 그대로) */ +function keyOf( + code: number, + mods: Partial> = {} +): string { + return bindingKey(kb(code, mods)) +} + +function label(e: ChordTriggerEvent): string { + return `${e.actionId}:${e.type}` +} + +let clock: FakeClock +let events: ChordTriggerEvent[] +let machine: ChordStateMachine + +beforeEach(() => { + clock = new FakeClock() + events = [] + machine = new ChordStateMachine({ clock, onTrigger: (e) => events.push(e) }) +}) + +// ============================================================ +// 순수 판정 +// ============================================================ + +describe('modifierFlagOfKeyCode', () => { + it('maps modifier VKs to their flag and others to null', () => { + expect(modifierFlagOfKeyCode(VK.AltRight)).toBe('alt') + expect(modifierFlagOfKeyCode(VK.AltLeft)).toBe('alt') + expect(modifierFlagOfKeyCode(VK.CtrlLeft)).toBe('ctrl') + expect(modifierFlagOfKeyCode(VK.ShiftRight)).toBe('shift') + expect(modifierFlagOfKeyCode(VK.MetaLeft)).toBe('meta') + expect(modifierFlagOfKeyCode(VK.ArrowDown)).toBeNull() + expect(modifierFlagOfKeyCode(0x41)).toBeNull() + }) +}) + +describe('isAltGrShapedBinding', () => { + it('recognises the default Ctrl+RightAlt binding after normalization', () => { + const command = normalizeBinding(kb(VK.AltRight, { ctrl: true })) + expect(command.alt).toBe(false) // 정규화가 자기 플래그를 끈다 — 과거 가드가 죽은 이유 + expect(isAltGrShapedBinding(command)).toBe(true) + }) + + it('recognises Ctrl+Alt with a modifier main key in every spelling', () => { + expect(isAltGrShapedBinding(normalizeBinding(kb(VK.CtrlLeft, { alt: true })))).toBe(true) + expect(isAltGrShapedBinding(normalizeBinding(kb(VK.CtrlRight, { alt: true })))).toBe(true) + expect( + isAltGrShapedBinding(normalizeBinding(kb(VK.ShiftRight, { ctrl: true, alt: true }))) + ).toBe(true) + expect( + isAltGrShapedBinding(normalizeBinding(kb(VK.AltRight, { ctrl: true, shift: true }))) + ).toBe(true) + }) + + it('rejects bindings that are not modifier-only Ctrl+Alt chords', () => { + expect(isAltGrShapedBinding(normalizeBinding(kb(VK.AltRight)))).toBe(false) + expect(isAltGrShapedBinding(normalizeBinding(kb(VK.AltRight, { shift: true })))).toBe(false) + expect(isAltGrShapedBinding(kb(VK.ArrowDown, { ctrl: true, alt: true }))).toBe(false) + expect( + isAltGrShapedBinding({ ...kb(MouseButton.Back, { ctrl: true, alt: true }), device: 'mouse' }) + ).toBe(false) + }) +}) + +// ============================================================ +// press / release +// ============================================================ + +describe('ChordStateMachine — press / release', () => { + it('emits pressed then released with the hold duration', () => { + machine.setBindings([entry('history-popup', kb(0x56, { ctrl: true, shift: true }))]) + const key = keyOf(0x56, { ctrl: true, shift: true }) + + machine.keyDown(key) + clock.advance(450) + machine.keyUp(key, 0x56) + + expect(events.map(label)).toEqual(['history-popup:pressed', 'history-popup:released']) + expect(events[0]?.durationMs).toBe(0) + expect(events[1]?.durationMs).toBe(450) + expect(events[1]?.binding).toEqual(normalizeBinding(kb(0x56, { ctrl: true, shift: true }))) + }) + + it('ignores auto-repeat keydowns while the key is held', () => { + machine.setBindings([entry('history-popup', kb(0x56, { ctrl: true, shift: true }))]) + const key = keyOf(0x56, { ctrl: true, shift: true }) + + machine.keyDown(key) + machine.keyDown(key) + machine.keyDown(key) + machine.keyUp(key, 0x56) + + expect(events.map(label)).toEqual(['history-popup:pressed', 'history-popup:released']) + }) + + it('ignores keys that match no binding and releases nothing that was not pressed', () => { + machine.setBindings([entry('history-popup', kb(0x56, { ctrl: true, shift: true }))]) + machine.keyDown(keyOf(0x41)) + machine.keyUp(keyOf(0x41), 0x41) + machine.keyUp(keyOf(0x56, { ctrl: true, shift: true }), 0x56) + expect(events).toEqual([]) + }) + + it('deduplicates the same action holding the same binding twice', () => { + const b = kb(0x56, { ctrl: true, shift: true }) + machine.setBindings([entry('history-popup', b), entry('history-popup', b)]) + expect(machine.registeredBindingCount).toBe(1) + machine.keyDown(bindingKey(b)) + expect(events.map(label)).toEqual(['history-popup:pressed']) + }) +}) + +describe('ChordStateMachine — double press', () => { + beforeEach(() => { + machine.setBindings([ + entry('dictation', kb(VK.AltRight), { holdMode: true }), + entry('hands-free', kb(VK.AltRight), { doublePress: true }) + ]) + }) + + it('splits a shared key into single (dictation) and double (hands-free) presses', () => { + const key = keyOf(VK.AltRight, { alt: true }) + + machine.keyDown(key) + clock.advance(80) + machine.keyUp(key, VK.AltRight) + clock.advance(100) + machine.keyDown(key) + clock.advance(50) + machine.keyUp(key, VK.AltRight) + + expect(events.map(label)).toEqual([ + 'dictation:pressed', + 'dictation:released', + 'hands-free:pressed', + 'hands-free:released' + ]) + expect(events[2]?.isDoublePress).toBe(true) + }) + + it('treats a second press outside the window as a new single press', () => { + const key = keyOf(VK.AltRight) + machine.keyDown(key) + machine.keyUp(key, VK.AltRight) + clock.advance(1_000) + machine.keyDown(key) + expect(events.map(label)).toEqual([ + 'dictation:pressed', + 'dictation:released', + 'dictation:pressed' + ]) + }) + + it('does not swallow fast repeated presses of a single-press-only binding', () => { + machine.setBindings([entry('history-popup', kb(0x56, { ctrl: true, shift: true }))]) + const key = keyOf(0x56, { ctrl: true, shift: true }) + machine.keyDown(key) + machine.keyUp(key, 0x56) + clock.advance(50) + machine.keyDown(key) + expect(events.map(label)).toEqual([ + 'history-popup:pressed', + 'history-popup:released', + 'history-popup:pressed' + ]) + }) +}) + +describe('ChordStateMachine — component release', () => { + it('releases Alt+1 when the modifier is let go first', () => { + machine.setBindings([entry('dictation', kb(0x31, { alt: true }), { holdMode: true })]) + machine.keyDown(keyOf(0x31, { alt: true })) + // Alt 를 먼저 놓는다: keyup 이벤트는 Alt 자신(수정자 플래그 없음)으로 온다. + machine.keyUp(keyOf(VK.AltLeft), VK.AltLeft) + expect(events.map(label)).toEqual(['dictation:pressed', 'dictation:released']) + + // 뒤이은 1 키 업은 이미 놓인 조합이므로 아무것도 내보내지 않는다. + machine.keyUp(keyOf(0x31), 0x31) + expect(events).toHaveLength(2) + }) + + it('releases Alt+1 when the main key comes up without its modifier flag', () => { + machine.setBindings([entry('dictation', kb(0x31, { alt: true }), { holdMode: true })]) + machine.keyDown(keyOf(0x31, { alt: true })) + machine.keyUp(keyOf(0x31), 0x31) + expect(events.map(label)).toEqual(['dictation:pressed', 'dictation:released']) + }) + + it('releases a Ctrl+mouse binding when Ctrl is let go first', () => { + const mouse: KeyBinding = { ...kb(MouseButton.Back, { ctrl: true }), device: 'mouse' } + machine.setBindings([entry('dictation', mouse, { holdMode: true })]) + machine.keyDown(bindingKey(mouse)) + machine.keyUp(keyOf(VK.CtrlLeft), VK.CtrlLeft) + expect(events.map(label)).toEqual(['dictation:pressed', 'dictation:released']) + }) + + it('matches mouse-up exactly and never by component', () => { + const mouse: KeyBinding = { ...kb(MouseButton.Back, { ctrl: true }), device: 'mouse' } + machine.setBindings([entry('dictation', mouse, { holdMode: true })]) + machine.keyDown(bindingKey(mouse)) + // 수정자 없는 mouseup — 정확 매칭 실패, 구성 키 검사도 하지 않는다. + machine.keyUp(bindingKey({ ...mouse, ctrl: false }), null) + expect(events.map(label)).toEqual(['dictation:pressed']) + machine.keyUp(bindingKey(mouse), null) + expect(events.map(label)).toEqual(['dictation:pressed', 'dictation:released']) + }) + + it('does not release on an unrelated key-up', () => { + machine.setBindings([entry('dictation', kb(0x31, { alt: true }), { holdMode: true })]) + machine.keyDown(keyOf(0x31, { alt: true })) + machine.keyUp(keyOf(VK.ShiftLeft, { alt: true }), VK.ShiftLeft) + machine.keyUp(null, null) + expect(events.map(label)).toEqual(['dictation:pressed']) + }) +}) + +// ============================================================ +// AltGr 가드 (회귀) +// ============================================================ + +describe('ChordStateMachine — AltGr chord guard', () => { + const ctrlRightAlt = keyOf(VK.AltRight, { ctrl: true, alt: true }) + const ctrlAltDown = keyOf(VK.ArrowDown, { ctrl: true, alt: true }) + + it('with default bindings, Ctrl+RightAlt then ArrowDown fires only suggestion-next', () => { + machine.setBindings(defaultEntries()) + + machine.keyDown(ctrlRightAlt) + expect(events).toEqual([]) // 보류 — 곧 화살표가 이어질 수 있다 + + clock.advance(60) + machine.keyDown(ctrlAltDown) + clock.advance(1_000) + machine.keyUp(ctrlAltDown, VK.ArrowDown) + machine.keyUp(ctrlRightAlt, VK.AltRight) + clock.advance(1_000) + + expect(events.map(label)).toEqual(['suggestion-next:pressed', 'suggestion-next:released']) + expect(clock.activeTimerCount).toBe(0) + }) + + it('cycling suggestions while Ctrl+Alt stays held never starts the command action', () => { + machine.setBindings(defaultEntries()) + machine.keyDown(ctrlRightAlt) + for (let i = 0; i < 3; i += 1) { + clock.advance(40) + machine.keyDown(ctrlAltDown) + clock.advance(40) + machine.keyUp(ctrlAltDown, VK.ArrowDown) + } + clock.advance(2_000) + machine.keyUp(ctrlRightAlt, VK.AltRight) + expect(events.some((e) => e.actionId === 'command')).toBe(false) + }) + + it('a held default Ctrl+RightAlt fires pressed after the grace and released on key-up', () => { + machine.setBindings(defaultEntries()) + machine.keyDown(ctrlRightAlt) + clock.advance(ALTGR_CHORD_GRACE_MS - 1) + expect(events).toEqual([]) + clock.advance(1) + expect(events.map(label)).toEqual(['command:pressed']) + + clock.advance(1_000 - ALTGR_CHORD_GRACE_MS) + machine.keyUp(ctrlRightAlt, VK.AltRight) + expect(events.map(label)).toEqual(['command:pressed', 'command:released']) + expect(events[1]?.durationMs).toBe(1_000) + }) + + it('a rebound hold-to-talk Ctrl+Alt+RightShift gets its released event', () => { + machine.setBindings([ + entry('dictation', kb(VK.ShiftRight, { ctrl: true, alt: true }), { holdMode: true }) + ]) + const key = keyOf(VK.ShiftRight, { ctrl: true, alt: true, shift: true }) + + machine.keyDown(key) + clock.advance(1_000) + machine.keyUp(key, VK.ShiftRight) + + expect(events.map(label)).toEqual(['dictation:pressed', 'dictation:released']) + expect(events[1]?.durationMs).toBe(1_000) + }) + + it('a tap shorter than the grace emits pressed then released in order, never after key-up', () => { + machine.setBindings([ + entry('dictation', kb(VK.ShiftRight, { ctrl: true, alt: true }), { holdMode: true }) + ]) + const key = keyOf(VK.ShiftRight, { ctrl: true, alt: true, shift: true }) + + machine.keyDown(key) + clock.advance(100) + machine.keyUp(key, VK.ShiftRight) + expect(events.map(label)).toEqual(['dictation:pressed', 'dictation:released']) + + clock.advance(1_000) + expect(events).toHaveLength(2) // 늦게 도착하는 pressed 가 없다 + expect(clock.activeTimerCount).toBe(0) + }) + + it('keeps every action that shares an AltGr-shaped binding pending, not just the last', () => { + const shared = kb(VK.AltRight, { ctrl: true }) + machine.setBindings([entry('command', shared), entry('caption', shared)]) + machine.keyDown(ctrlRightAlt) + clock.advance(ALTGR_CHORD_GRACE_MS) + expect(events.map(label).sort()).toEqual(['caption:pressed', 'command:pressed']) + }) + + it('does not defer bindings that are not AltGr-shaped', () => { + machine.setBindings(defaultEntries()) + machine.keyDown(keyOf(VK.AltRight, { alt: true })) + expect(events.map(label)).toEqual(['dictation:pressed']) + }) + + it('a cancelled deferral does not leak into the next press of the same chord', () => { + machine.setBindings(defaultEntries()) + machine.keyDown(ctrlRightAlt) + machine.keyDown(ctrlAltDown) + machine.keyUp(ctrlAltDown, VK.ArrowDown) + machine.keyUp(ctrlRightAlt, VK.AltRight) + events = [] + + clock.advance(1_000) + machine.keyDown(ctrlRightAlt) + clock.advance(ALTGR_CHORD_GRACE_MS + 200) + machine.keyUp(ctrlRightAlt, VK.AltRight) + expect(events.map(label)).toEqual(['command:pressed', 'command:released']) + }) +}) + +// ============================================================ +// reset / setBindings +// ============================================================ + +describe('ChordStateMachine — reset', () => { + it('reset drops a pending AltGr emit and all held state', () => { + machine.setBindings(defaultEntries()) + machine.keyDown(keyOf(VK.AltRight, { ctrl: true, alt: true })) + machine.reset() + clock.advance(1_000) + expect(events).toEqual([]) + expect(clock.activeTimerCount).toBe(0) + + // 눌림 상태도 비워졌으므로 같은 키를 다시 누를 수 있다. + machine.keyDown(keyOf(VK.AltRight, { alt: true })) + expect(events.map(label)).toEqual(['dictation:pressed']) + }) + + it('setBindings resets pending timers and the double-press window', () => { + machine.setBindings(defaultEntries()) + machine.keyDown(keyOf(VK.AltRight, { ctrl: true, alt: true })) + machine.setBindings(defaultEntries()) + clock.advance(1_000) + expect(events).toEqual([]) + + const single = keyOf(VK.AltRight) + machine.keyDown(single) + machine.keyUp(single, VK.AltRight) + machine.setBindings(defaultEntries()) + clock.advance(50) + machine.keyDown(single) + // 재로드 뒤라 더블프레스로 보지 않는다. + expect(events.map(label)).toEqual([ + 'dictation:pressed', + 'dictation:released', + 'dictation:pressed' + ]) + }) + + it('registeredBindings returns one normalized binding per key', () => { + machine.setBindings(defaultEntries()) + const keys = machine.registeredBindings().map(bindingKey) + expect(new Set(keys).size).toBe(keys.length) + expect(keys.length).toBe(machine.registeredBindingCount) + // dictation / hands-free 가 공유하는 Right Alt 는 한 번만 나온다. + expect(keys.filter((k) => k === keyOf(VK.AltRight))).toHaveLength(1) + }) +}) diff --git a/packages/core/src/keybinding-runtime.ts b/packages/core/src/keybinding-runtime.ts new file mode 100644 index 0000000..db32453 --- /dev/null +++ b/packages/core/src/keybinding-runtime.ts @@ -0,0 +1,402 @@ +// packages/core/src/keybinding-runtime.ts +// +// 전역 키바인딩 런타임 상태 머신 (순수 정책). +// +// press / release · 더블프레스 · auto-repeat 무시 · 구성 키 release · AltGr 보류를 +// 한곳에서 판정한다. 입력 장치 후킹(uiohook)·Electron·시계에 의존하지 않는다 — +// 이벤트는 정본 좌표계(bindingKey / Windows VK)로 들어오고, 시간은 주입된 시계로 잰다. +// 그래서 가짜 타이머만으로 전 경로를 테스트할 수 있다. +// +// 어댑터(데스크톱 KeyBindingService)의 책임: +// - 장치 이벤트 → bindingKey / VK 변환 +// - 관측 불가능한 키 걸러내기(등록 전에) +// - macOS beep 차단용 accelerator, 설정 로딩 +// +// 런타임 상태는 액션 id 가 아니라 bindingKey() 로 키잉한다 — +// 한 액션에 여러 바인딩이 붙고, 여러 액션이 한 바인딩을 공유하기 때문이다. + +import { TIMING } from './constants' +import { bindingKey, isModifierKeyCode, normalizeBinding } from './keybinding' +import type { KeyBinding, KeyBindingActionId } from './keybinding' + +// ============================================================ +// 공개 타입 · 포트 +// ============================================================ + +/** 상태 머신이 내보내는 트리거. 데스크톱 `KeyBindingTriggerPayload` 의 정본이다. */ +export interface ChordTriggerEvent { + actionId: KeyBindingActionId + type: 'pressed' | 'released' + /** 더블프레스로 트리거된 누름인지 */ + isDoublePress: boolean + /** 액션 스펙의 holdMode — 소비자가 hold-to-talk / 토글을 분기하는 데 쓴다 */ + holdMode: boolean + timestamp: number + /** type === 'released' 일 때의 누름 유지 시간 (pressed 는 0) */ + durationMs: number + /** 매칭된 바인딩(정규화 완료)의 주 키 코드와 수정자 상태 */ + binding: KeyBinding +} + +/** 상태 머신에 등록하는 바인딩 하나. binding 은 정규화 전이어도 된다. */ +export interface ChordBindingEntry { + actionId: KeyBindingActionId + holdMode: boolean + doublePress: boolean + binding: KeyBinding +} + +/** + * 시간 포트. 실제 구현은 Date.now / setTimeout, 테스트는 가짜 타이머를 넣는다. + * schedule 은 취소 함수를 돌려준다 — 타이머 핸들 타입을 core 가 알 필요가 없다. + */ +export interface ChordClock { + now(): number + schedule(callback: () => void, delayMs: number): () => void +} + +export interface ChordStateMachineOptions { + clock: ChordClock + onTrigger: (event: ChordTriggerEvent) => void + /** 진단 로그. core 는 console 을 쓰지 않으므로 주입받는다. */ + debug?: (message: string) => void + /** 더블프레스 판정 간격 (기본 TIMING.DOUBLE_PRESS_DURATION) */ + doublePressWindowMs?: number + /** AltGr 형태 트리거 보류 시간 (기본 ALTGR_CHORD_GRACE_MS) */ + altGrGraceMs?: number +} + +/** + * AltGr(=Ctrl+Alt) 함정 가드의 보류 시간. + * + * 오른쪽 Alt 는 Windows 가 Ctrl+Alt 로 보내므로, Ctrl+Alt+화살표 같은 조합을 누르면 + * Alt 를 누르는 순간 Ctrl+AltRight 바인딩이 먼저 발동한다(실측 신고: 제안 탐색 대신 + * 음성 파이프라인이 돌았다). 수정자만으로 끝나면서 Ctrl+Alt 가 함께 눌린 트리거는 + * 이 시간만큼 보류하고, 그 사이 다른 바인딩이 눌리면(=조합을 만들려던 것) 취소한다. + */ +export const ALTGR_CHORD_GRACE_MS = 280 + +// ============================================================ +// 순수 판정 함수 +// ============================================================ + +type ModifierFlag = 'ctrl' | 'alt' | 'shift' | 'meta' +const MODIFIER_FLAGS: readonly ModifierFlag[] = ['ctrl', 'alt', 'shift', 'meta'] + +/** + * 수정자 키 VK 가 켜는 수정자 플래그. 수정자가 아니면 null. + * + * 정본은 keybinding.ts 의 정규화 규칙(주 키가 수정자 자신이면 그 플래그를 끈다)이다. + * 같은 표를 다시 적지 않고, normalizeBinding 이 끄는 플래그로 역산한다. + */ +export function modifierFlagOfKeyCode(vk: number): ModifierFlag | null { + const probe = normalizeBinding({ + device: 'keyboard', + code: vk, + ctrl: true, + alt: true, + shift: true, + meta: true + }) + return MODIFIER_FLAGS.find((flag) => !probe[flag]) ?? null +} + +/** + * AltGr(=Ctrl+Alt) 형태의 수정자 전용 조합인가. + * + * 정규화는 주 키 자신의 수정자 플래그를 끈다(Ctrl+RightAlt → {AltRight, ctrl}). 그래서 + * 저장된 플래그만 보면 기본 바인딩 Ctrl+RightAlt 가 AltGr 형태로 보이지 않는다. + * 주 키가 켜는 플래그를 되살려(= 사용자가 실제로 누르는 수정자 집합) 판정한다. + */ +export function isAltGrShapedBinding(binding: KeyBinding): boolean { + if (binding.device !== 'keyboard') return false + if (!isModifierKeyCode(binding.code)) return false + const self = modifierFlagOfKeyCode(binding.code) + const ctrl = binding.ctrl || self === 'ctrl' + const alt = binding.alt || self === 'alt' + return ctrl && alt +} + +/** + * 놓인 키(VK)가 바인딩의 주 키이거나 켜진 수정자의 구성 키인가. + * 마우스 바인딩(Ctrl+뒤로 등)도 수정자를 먼저 놓으면 release 되어야 한다 — + * 그 뒤의 mouseup 은 수정자 없이 들어와 정확 매칭이 실패하기 때문이다. + */ +function isComponentOf(vk: number, binding: KeyBinding): boolean { + if (binding.device === 'keyboard' && binding.code === vk) return true + const flag = modifierFlagOfKeyCode(vk) + return flag !== null && binding[flag] +} + +// ============================================================ +// 상태 머신 +// ============================================================ + +type TriggerStatus = 'pending' | 'emitted' | 'cancelled' + +interface IndexedBinding { + actionId: KeyBindingActionId + holdMode: boolean + doublePress: boolean + binding: KeyBinding + altGrShaped: boolean +} + +interface ActiveTrigger { + entry: IndexedBinding + key: string + isDoublePress: boolean + pressedAt: number + /** + * pending — AltGr 보류 중 (pressed 미발송) + * emitted — pressed 발송됨, released 를 보내야 한다 + * cancelled — 보류가 다른 바인딩 눌림으로 취소됨, released 도 보내지 않는다 + */ + status: TriggerStatus + cancelTimer: (() => void) | null +} + +export class ChordStateMachine { + private readonly _clock: ChordClock + private readonly _onTrigger: (event: ChordTriggerEvent) => void + private readonly _debug: (message: string) => void + private readonly _doublePressWindowMs: number + private readonly _altGrGraceMs: number + + /** bindingKey → 그 바인딩을 쓰는 액션들 (삽입 순서 유지) */ + private _byKey: Map = new Map() + + /** 눌림 상태 — auto-repeat 무시 · release 매칭 */ + private readonly _isDown: Set = new Set() + private readonly _pressStart: Map = new Map() + private readonly _lastPress: Map = new Map() + + /** 현재 누름에서 실제로 트리거된 액션 — release 를 같은 대상에게만 보낸다 */ + private readonly _active: Map = new Map() + + /** AltGr 보류 중인 트리거 */ + private readonly _pending: Set = new Set() + + constructor(options: ChordStateMachineOptions) { + this._clock = options.clock + this._onTrigger = options.onTrigger + this._debug = options.debug ?? ((): void => {}) + this._doublePressWindowMs = options.doublePressWindowMs ?? TIMING.DOUBLE_PRESS_DURATION + this._altGrGraceMs = options.altGrGraceMs ?? ALTGR_CHORD_GRACE_MS + } + + // ── 등록 ─────────────────────────────────────────────── + + /** 바인딩 목록을 교체한다. 런타임 상태(눌림 · 보류 타이머)도 함께 초기화한다. */ + setBindings(entries: readonly ChordBindingEntry[]): void { + this.reset() + const next = new Map() + for (const raw of entries) { + const binding = normalizeBinding(raw.binding) + const key = bindingKey(binding) + const indexed: IndexedBinding = { + actionId: raw.actionId, + holdMode: raw.holdMode, + doublePress: raw.doublePress, + binding, + altGrShaped: isAltGrShapedBinding(binding) + } + const existing = next.get(key) + if (existing === undefined) { + next.set(key, [indexed]) + continue + } + // 같은 액션이 같은 바인딩을 두 번 들고 있으면 한 번 누를 때 두 번 트리거된다. + if (existing.some((e) => e.actionId === raw.actionId)) continue + existing.push(indexed) + } + this._byKey = next + } + + /** 등록된 바인딩 수 (bindingKey 기준) */ + get registeredBindingCount(): number { + return this._byKey.size + } + + /** bindingKey 마다 대표 바인딩 하나 (정규화 완료). accelerator 동기화용. */ + registeredBindings(): KeyBinding[] { + const out: KeyBinding[] = [] + for (const entries of this._byKey.values()) { + const first = entries[0] + if (first !== undefined) out.push({ ...first.binding }) + } + return out + } + + /** 눌림 · 더블프레스 · 보류 타이머를 모두 비운다. 보류된 pressed 는 버린다. */ + reset(): void { + for (const trigger of this._pending) trigger.cancelTimer?.() + this._pending.clear() + this._isDown.clear() + this._pressStart.clear() + this._lastPress.clear() + this._active.clear() + } + + // ── 입력 ─────────────────────────────────────────────── + + /** 키/버튼 눌림. eventKey 는 이벤트의 bindingKey. */ + keyDown(eventKey: string): void { + const entries = this._byKey.get(eventKey) + if (entries === undefined || entries.length === 0) return + + // 키 반복(auto-repeat) 무시 + if (this._isDown.has(eventKey)) return + this._isDown.add(eventKey) + + const now = this._clock.now() + this._pressStart.set(eventKey, now) + + // 다른 바인딩이 이어졌다 = 조합을 만들려던 것 → 보류 중인 AltGr 트리거는 취소. + this._cancelPendingExcept(eventKey) + + // 더블프레스 감지는 이 바인딩을 쓰는 액션 중 하나라도 doublePress 일 때만 한다. + // 그렇지 않으면 단일프레스 액션을 빠르게 두 번 누를 때 두 번째가 삼켜진다. + let isDoublePress = false + if (entries.some((entry) => entry.doublePress)) { + const lastPress = this._lastPress.get(eventKey) + isDoublePress = lastPress !== undefined && now - lastPress < this._doublePressWindowMs + if (isDoublePress) { + this._lastPress.delete(eventKey) + } else { + this._lastPress.set(eventKey, now) + } + } + + // dictation(단일) 과 hands-free(더블) 는 같은 바인딩을 공유한다 — + // 첫 매치만 반환하지 않고 doublePress 여부로 갈라 해당하는 액션을 모두 트리거한다. + const triggers: ActiveTrigger[] = [] + for (const entry of entries) { + if (entry.doublePress !== isDoublePress) continue + triggers.push({ + entry, + key: eventKey, + isDoublePress, + pressedAt: now, + status: 'emitted', + cancelTimer: null + }) + } + this._active.set(eventKey, triggers) + + for (const trigger of triggers) { + this._debug(`Key binding pressed: "${trigger.entry.actionId}" (double=${isDoublePress})`) + if (trigger.entry.altGrShaped) { + this._deferPressed(trigger) + } else { + this._emitPressed(trigger) + } + } + } + + /** + * 키/버튼 놓임. + * + * eventKey 가 눌림 상태와 정확히 맞으면 그 바인딩을 놓는다. 아니면 componentVk(놓인 + * 키의 VK)가 눌림 상태 바인딩의 주 키/수정자인지 본다 — Alt+1 에서 수정자를 먼저 + * 놓아도 release 를 놓치지 않기 위해서다. mouseup 은 componentVk 를 null 로 넘겨 + * 정확 매칭만 한다(마우스 버튼은 키보드 바인딩의 구성 키가 아니다). + */ + keyUp(eventKey: string | null, componentVk: number | null): void { + if (eventKey !== null && this._isDown.has(eventKey)) { + this._release(eventKey) + return + } + if (componentVk === null) return + + for (const [key, entries] of this._byKey) { + if (!this._isDown.has(key)) continue + const first = entries[0] + if (first === undefined) continue + if (isComponentOf(componentVk, first.binding)) { + this._release(key) + return + } + } + } + + // ── 내부 ─────────────────────────────────────────────── + + private _payload( + trigger: ActiveTrigger, + type: ChordTriggerEvent['type'], + timestamp: number, + durationMs: number + ): ChordTriggerEvent { + return { + actionId: trigger.entry.actionId, + type, + isDoublePress: trigger.isDoublePress, + holdMode: trigger.entry.holdMode, + timestamp, + durationMs, + binding: { ...trigger.entry.binding } + } + } + + private _emitPressed(trigger: ActiveTrigger): void { + trigger.status = 'emitted' + this._onTrigger(this._payload(trigger, 'pressed', trigger.pressedAt, 0)) + } + + /** + * AltGr 형태 트리거는 곧 다른 키가 이어질 수 있으므로 잠깐 보류한다. + * 유예가 끝나거나 그 조합 자체가 놓이면 발송하고, 다른 바인딩이 눌리면 취소한다. + */ + private _deferPressed(trigger: ActiveTrigger): void { + trigger.status = 'pending' + this._pending.add(trigger) + trigger.cancelTimer = this._clock.schedule(() => { + if (!this._pending.delete(trigger)) return + trigger.cancelTimer = null + this._debug(`AltGr 형태 트리거 유예 후 발동: "${trigger.entry.actionId}"`) + this._emitPressed(trigger) + }, this._altGrGraceMs) + } + + private _cancelPendingExcept(eventKey: string): void { + for (const trigger of this._pending) { + if (trigger.key === eventKey) continue + trigger.cancelTimer?.() + trigger.cancelTimer = null + trigger.status = 'cancelled' + this._pending.delete(trigger) + this._debug(`AltGr 형태 보류 트리거 취소 (다른 키가 이어짐): "${trigger.entry.actionId}"`) + } + } + + private _release(key: string): void { + this._isDown.delete(key) + + const now = this._clock.now() + const pressStart = this._pressStart.get(key) + const durationMs = pressStart !== undefined ? now - pressStart : 0 + this._pressStart.delete(key) + + const triggers = this._active.get(key) ?? [] + this._active.delete(key) + + for (const trigger of triggers) { + if (trigger.status === 'cancelled') { + this._debug( + `AltGr 보류가 취소된 바인딩의 released 는 내보내지 않는다: "${trigger.entry.actionId}"` + ) + continue + } + if (trigger.status === 'pending') { + // 유예 중에 조합 자체가 놓였다 = 다른 키는 오지 않았다. 순서를 지켜 지금 발송한다. + trigger.cancelTimer?.() + trigger.cancelTimer = null + this._pending.delete(trigger) + this._emitPressed(trigger) + } + this._debug(`Key binding released: "${trigger.entry.actionId}" (duration=${durationMs}ms)`) + this._onTrigger(this._payload(trigger, 'released', now, durationMs)) + } + } +}