refactor(keybinding): extract chord state machine into core and fix AltGr guard
This commit is contained in:
parent
de1e8a82a4
commit
9cd81b48c1
3 changed files with 960 additions and 355 deletions
|
|
@ -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<number> = 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<string>()
|
||||
|
||||
// ============================================================
|
||||
// 이벤트 페이로드
|
||||
// ============================================================
|
||||
|
||||
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
|
||||
/**
|
||||
* 매칭된 바인딩의 주 키 코드와 수정자 상태.
|
||||
* `triggered` 이벤트 페이로드. 정본은 core 의 ChordTriggerEvent 다.
|
||||
*
|
||||
* 소비자가 "수정자만으로 끝나는 조합인지" 를 판단해 AltGr 함정을 피하는 데 쓴다.
|
||||
* (오른쪽 Alt 는 Windows 가 Ctrl+Alt 로 보내므로, Ctrl+Alt+화살표를 누르면
|
||||
* Alt 를 누르는 순간 Ctrl+AltRight 바인딩이 먼저 발동한다)
|
||||
* binding 은 매칭된 바인딩(정규화 완료)의 주 키 코드와 수정자 상태다.
|
||||
*/
|
||||
binding: {
|
||||
device: 'keyboard' | 'mouse'
|
||||
code: number
|
||||
ctrl: boolean
|
||||
alt: boolean
|
||||
shift: boolean
|
||||
meta: boolean
|
||||
}
|
||||
}
|
||||
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<string, RegisteredBinding[]> = 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<string> = new Set()
|
||||
|
||||
/** 키 반복(auto-repeat) 방지: 현재 눌려있는 바인딩 */
|
||||
private _isKeyDown: Map<string, boolean> = new Map()
|
||||
|
||||
/** hold duration 계산용: press 시작 시각 */
|
||||
private _pressStartTime: Map<string, number> = new Map()
|
||||
|
||||
/** 더블프레스 감지용: 마지막 press 시각 */
|
||||
private _lastPressTime: Map<string, number> = new Map()
|
||||
|
||||
/** 현재 누름에서 실제로 트리거된 액션 — release 를 같은 대상에게만 보낸다 */
|
||||
private _activeTriggers: Map<string, ActiveTrigger[]> = 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 타입 오버라이드 ───────────────────────
|
||||
|
|
|
|||
479
packages/core/__tests__/keybinding-runtime.test.ts
Normal file
479
packages/core/__tests__/keybinding-runtime.test.ts
Normal file
|
|
@ -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<Pick<KeyBinding, 'ctrl' | 'alt' | 'shift' | 'meta'>> = {}
|
||||
): 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)
|
||||
})
|
||||
})
|
||||
402
packages/core/src/keybinding-runtime.ts
Normal file
402
packages/core/src/keybinding-runtime.ts
Normal file
|
|
@ -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<string, IndexedBinding[]> = new Map()
|
||||
|
||||
/** 눌림 상태 — auto-repeat 무시 · release 매칭 */
|
||||
private readonly _isDown: Set<string> = new Set()
|
||||
private readonly _pressStart: Map<string, number> = new Map()
|
||||
private readonly _lastPress: Map<string, number> = new Map()
|
||||
|
||||
/** 현재 누름에서 실제로 트리거된 액션 — release 를 같은 대상에게만 보낸다 */
|
||||
private readonly _active: Map<string, ActiveTrigger[]> = new Map()
|
||||
|
||||
/** AltGr 보류 중인 트리거 */
|
||||
private readonly _pending: Set<ActiveTrigger> = 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<string, IndexedBinding[]>()
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue