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

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