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