refactor(keybinding): extract chord state machine into core and fix AltGr guard

This commit is contained in:
Yun Chan 2026-09-28 00:53:46 +09:00
parent de1e8a82a4
commit 9cd81b48c1
3 changed files with 960 additions and 355 deletions

View 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))
}
}
}