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