d3ro-voice/packages/core/src/keybinding.ts

1350 lines
41 KiB
TypeScript

// packages/core/src/keybinding.ts
//
// 키바인딩 SSOT (Single Source of Truth).
//
// 이 모듈이 다음 전부의 정본이다:
// 1) 바인딩 표현 — KeyBinding (키보드 VK / 마우스 버튼 공용)
// 2) 바인딩 가능한 액션 — KEYBINDING_ACTIONS
// 3) 선택 가능한 키 목록 — KEY_CATALOG (드롭다운 · 검색 · 검증 공용)
// 4) 동일성 / 정규화 — bindingKey(), normalizeBinding()
// 5) 검증 / 충돌 판정 — validateBinding(), detectBindingConflicts()
// 6) 표시 라벨 — formatBindingSegments()
//
// 메인 프로세스(KeyBindingService), preload, 렌더러 UI는 전부 이 모듈만 쓴다.
// 키 목록·라벨·검증 규칙을 다른 곳에 다시 정의하지 않는다.
//
// 좌표계 규약:
// device === 'keyboard' → code 는 **Windows Virtual-Key 코드**
// device === 'mouse' → code 는 **MouseButton 값 (libuiohook MOUSE_BUTTON1..5)**
// uiohook 키코드로의 변환은 메인 프로세스 전용 어댑터가 담당한다.
// 저장·IPC·UI 는 언제나 이 좌표계를 쓴다.
// ============================================================
// 기본 타입
// ============================================================
/** 바인딩 입력 장치 */
export type BindingDevice = 'keyboard' | 'mouse'
/**
* 마우스 버튼 코드 (libuiohook `MOUSE_BUTTON1..5`).
* uiohook-napi 가 상수를 export 하지 않으므로 여기서 정의한다.
*/
export const MouseButton = {
Left: 1,
Right: 2,
Middle: 3,
/** X1 — 일반적으로 "뒤로" */
Back: 4,
/** X2 — 일반적으로 "앞으로" */
Forward: 5
} as const
export type MouseButtonCode = (typeof MouseButton)[keyof typeof MouseButton]
/**
* 단일 키바인딩. 저장·IPC·UI 전 구간 공용 정본 표현.
*
* `displayLabel` 같은 파생 문자열은 담지 않는다 —
* 라벨은 언제나 formatBindingSegments()로 파생시킨다(로케일·플랫폼 변경에 따라감).
*/
export interface KeyBinding {
device: BindingDevice
/** keyboard → Windows VK 코드 / mouse → MouseButtonCode */
code: number
ctrl: boolean
alt: boolean
shift: boolean
meta: boolean
}
/** 액션 하나에 바인딩된 목록 (0개 이상 — 다중 바인딩) */
export type KeyBindingList = readonly KeyBinding[]
/** 전체 바인딩 맵 — 액션 id → 바인딩 목록 */
export type KeyBindingMap = Record<KeyBindingActionId, KeyBinding[]>
// ============================================================
// Windows VK 상수 (이 모듈에서 쓰는 것만)
// ============================================================
export const VK = {
Backspace: 0x08,
Tab: 0x09,
Enter: 0x0d,
Pause: 0x13,
CapsLock: 0x14,
Escape: 0x1b,
Space: 0x20,
PageUp: 0x21,
PageDown: 0x22,
End: 0x23,
Home: 0x24,
ArrowLeft: 0x25,
ArrowUp: 0x26,
ArrowRight: 0x27,
ArrowDown: 0x28,
PrintScreen: 0x2c,
Insert: 0x2d,
Delete: 0x2e,
Digit0: 0x30,
Digit9: 0x39,
A: 0x41,
Z: 0x5a,
MetaLeft: 0x5b,
MetaRight: 0x5c,
Numpad0: 0x60,
Numpad9: 0x69,
NumpadMultiply: 0x6a,
NumpadAdd: 0x6b,
NumpadSubtract: 0x6d,
NumpadDecimal: 0x6e,
NumpadDivide: 0x6f,
F1: 0x70,
F24: 0x87,
NumLock: 0x90,
ScrollLock: 0x91,
ShiftLeft: 0xa0,
ShiftRight: 0xa1,
CtrlLeft: 0xa2,
CtrlRight: 0xa3,
AltLeft: 0xa4,
AltRight: 0xa5,
Semicolon: 0xba,
Equal: 0xbb,
Comma: 0xbc,
Minus: 0xbd,
Period: 0xbe,
Slash: 0xbf,
Backquote: 0xc0,
BracketLeft: 0xdb,
Backslash: 0xdc,
BracketRight: 0xdd,
Quote: 0xde
} as const
/**
* 수정자 키 자체의 VK 코드.
* 좌/우 구분 코드와, 브라우저 KeyboardEvent 가 주는 비구분 코드(16/17/18)를 모두 포함한다.
*/
const MODIFIER_VK_CODES: ReadonlySet<number> = new Set([
0x10, 0x11, 0x12, // Shift / Ctrl / Alt (좌우 비구분 — 브라우저 이벤트)
VK.ShiftLeft,
VK.ShiftRight,
VK.CtrlLeft,
VK.CtrlRight,
VK.AltLeft,
VK.AltRight,
VK.MetaLeft,
VK.MetaRight
])
/** 해당 VK 코드가 수정자 키 자체인지 */
export function isModifierKeyCode(code: number): boolean {
return MODIFIER_VK_CODES.has(code)
}
/**
* 수정자 키 자체를 주 키로 쓸 때, 함께 켜지는 수정자 플래그를 판정한다.
* 예) 주 키가 Right Alt 이면 alt 플래그는 "자기 자신"이므로 조합으로 치지 않는다.
*/
function selfModifierOf(code: number): keyof Pick<
KeyBinding,
'ctrl' | 'alt' | 'shift' | 'meta'
> | null {
if (code === 0x10 || code === VK.ShiftLeft || code === VK.ShiftRight) return 'shift'
if (code === 0x11 || code === VK.CtrlLeft || code === VK.CtrlRight) return 'ctrl'
if (code === 0x12 || code === VK.AltLeft || code === VK.AltRight) return 'alt'
if (code === VK.MetaLeft || code === VK.MetaRight) return 'meta'
return null
}
// ============================================================
// 키 카탈로그 — 드롭다운 / 검색 / 검증 공용 원천
// ============================================================
export type KeyCatalogGroup =
| 'mouse'
| 'modifier'
| 'function'
| 'letter'
| 'digit'
| 'numpad'
| 'navigation'
| 'editing'
| 'punctuation'
| 'system'
/**
* 선택 가능한 키 하나.
*
* `label` 은 로케일 무관 키캡 표기(국제 표준: 'A', 'Ctrl', 'F5', '←')이며 번역하지 않는다.
* `labelKey` 가 있는 항목만 t() 로 번역해 표시한다(마우스 버튼처럼 설명적 이름이 필요한 경우).
*/
export interface KeyCatalogEntry {
device: BindingDevice
code: number
group: KeyCatalogGroup
/** 키캡 표기 (번역 대상 아님) */
label: string
/** macOS 전용 키캡 표기 — 없으면 label 사용 */
macLabel?: string
/** 번역이 필요한 설명적 이름의 i18n 키 (없으면 label 을 그대로 표시) */
labelKey?: string
/** 검색용 영문 별칭 (소문자) */
aliases: readonly string[]
/** 단독 사용 불가 — 수정자 1개 이상 필요 */
requiresModifier: boolean
/** 바인딩 자체가 불가능한 경우의 사유 i18n 키 (선택 목록에 비활성으로 노출) */
disabledReasonKey?: string
/**
* 눌러도 원래 동작이 함께 실행된다는 경고의 i18n 키.
* uiohook-napi 는 이벤트 suppress 가 불가능하다(비동기 디스패치).
*/
passthroughWarningKey?: string
}
const LETTER_ALIASES: Readonly<Record<string, readonly string[]>> = {}
function letterEntries(): KeyCatalogEntry[] {
const out: KeyCatalogEntry[] = []
for (let code = VK.A; code <= VK.Z; code += 1) {
const label = String.fromCharCode(code)
out.push({
device: 'keyboard',
code,
group: 'letter',
label,
aliases: LETTER_ALIASES[label] ?? [label.toLowerCase()],
requiresModifier: true
})
}
return out
}
function digitEntries(): KeyCatalogEntry[] {
const out: KeyCatalogEntry[] = []
for (let code = VK.Digit0; code <= VK.Digit9; code += 1) {
const label = String.fromCharCode(code)
out.push({
device: 'keyboard',
code,
group: 'digit',
label,
aliases: [label, `digit${label}`],
requiresModifier: true
})
}
return out
}
function functionEntries(): KeyCatalogEntry[] {
const out: KeyCatalogEntry[] = []
for (let code = VK.F1; code <= VK.F24; code += 1) {
const n = code - VK.F1 + 1
out.push({
device: 'keyboard',
code,
group: 'function',
label: `F${n}`,
aliases: [`f${n}`, `function${n}`],
// 기능 키는 단독 바인딩이 관례적으로 안전하다.
requiresModifier: false
})
}
return out
}
function numpadEntries(): KeyCatalogEntry[] {
const out: KeyCatalogEntry[] = []
for (let code = VK.Numpad0; code <= VK.Numpad9; code += 1) {
const n = code - VK.Numpad0
out.push({
device: 'keyboard',
code,
group: 'numpad',
label: `Num${n}`,
aliases: [`num${n}`, `numpad${n}`, `keypad${n}`],
requiresModifier: true
})
}
const ops: readonly (readonly [number, string, readonly string[]])[] = [
[VK.NumpadDivide, 'Num/', ['numdivide', 'numpaddivide']],
[VK.NumpadMultiply, 'Num*', ['nummultiply', 'numpadmultiply']],
[VK.NumpadSubtract, 'Num-', ['numsubtract', 'numpadminus']],
[VK.NumpadAdd, 'Num+', ['numadd', 'numpadplus']],
[VK.NumpadDecimal, 'Num.', ['numdecimal', 'numpaddot']]
]
for (const [code, label, aliases] of ops) {
out.push({
device: 'keyboard',
code,
group: 'numpad',
label,
aliases,
requiresModifier: true
})
}
return out
}
const MODIFIER_ENTRIES: readonly KeyCatalogEntry[] = [
{
device: 'keyboard',
code: VK.CtrlLeft,
group: 'modifier',
label: 'Left Ctrl',
macLabel: 'Left ⌃',
aliases: ['leftctrl', 'lctrl', 'control'],
requiresModifier: false
},
{
device: 'keyboard',
code: VK.CtrlRight,
group: 'modifier',
label: 'Right Ctrl',
macLabel: 'Right ⌃',
aliases: ['rightctrl', 'rctrl', 'control'],
requiresModifier: false
},
{
device: 'keyboard',
code: VK.AltLeft,
group: 'modifier',
label: 'Left Alt',
macLabel: 'Left ⌥',
aliases: ['leftalt', 'lalt', 'option'],
requiresModifier: false
},
{
device: 'keyboard',
code: VK.AltRight,
group: 'modifier',
label: 'Right Alt',
macLabel: 'Right ⌥',
aliases: ['rightalt', 'ralt', 'altgr', 'option'],
requiresModifier: false
},
{
device: 'keyboard',
code: VK.ShiftLeft,
group: 'modifier',
label: 'Left Shift',
macLabel: 'Left ⇧',
aliases: ['leftshift', 'lshift'],
requiresModifier: false
},
{
device: 'keyboard',
code: VK.ShiftRight,
group: 'modifier',
label: 'Right Shift',
macLabel: 'Right ⇧',
aliases: ['rightshift', 'rshift'],
requiresModifier: false
},
{
device: 'keyboard',
code: VK.MetaLeft,
group: 'modifier',
label: 'Left Win',
macLabel: 'Left ⌘',
aliases: ['leftwin', 'lwin', 'command', 'super'],
requiresModifier: false
},
{
device: 'keyboard',
code: VK.MetaRight,
group: 'modifier',
label: 'Right Win',
macLabel: 'Right ⌘',
aliases: ['rightwin', 'rwin', 'command', 'super'],
requiresModifier: false
}
]
const EDITING_ENTRIES: readonly KeyCatalogEntry[] = [
{
device: 'keyboard',
code: VK.Space,
group: 'editing',
label: 'Space',
aliases: ['space', 'spacebar'],
requiresModifier: true
},
{
device: 'keyboard',
code: VK.Enter,
group: 'editing',
label: 'Enter',
macLabel: '↩',
aliases: ['enter', 'return'],
requiresModifier: true
},
{
device: 'keyboard',
code: VK.Tab,
group: 'editing',
label: 'Tab',
aliases: ['tab'],
requiresModifier: true
},
{
device: 'keyboard',
code: VK.Backspace,
group: 'editing',
label: 'Backspace',
macLabel: '⌫',
aliases: ['backspace'],
requiresModifier: true
},
{
device: 'keyboard',
code: VK.Escape,
group: 'editing',
label: 'Esc',
aliases: ['esc', 'escape'],
requiresModifier: true
},
{
device: 'keyboard',
code: VK.CapsLock,
group: 'editing',
label: 'CapsLock',
aliases: ['capslock', 'caps'],
requiresModifier: true
}
]
const NAVIGATION_ENTRIES: readonly KeyCatalogEntry[] = [
{
device: 'keyboard',
code: VK.Insert,
group: 'navigation',
label: 'Insert',
aliases: ['insert', 'ins'],
requiresModifier: true
},
{
device: 'keyboard',
code: VK.Delete,
group: 'navigation',
label: 'Delete',
macLabel: '⌦',
aliases: ['delete', 'del'],
requiresModifier: true
},
{
device: 'keyboard',
code: VK.Home,
group: 'navigation',
label: 'Home',
aliases: ['home'],
requiresModifier: true
},
{
device: 'keyboard',
code: VK.End,
group: 'navigation',
label: 'End',
aliases: ['end'],
requiresModifier: true
},
{
device: 'keyboard',
code: VK.PageUp,
group: 'navigation',
label: 'PgUp',
aliases: ['pageup', 'pgup'],
requiresModifier: true
},
{
device: 'keyboard',
code: VK.PageDown,
group: 'navigation',
label: 'PgDn',
aliases: ['pagedown', 'pgdn'],
requiresModifier: true
},
{
device: 'keyboard',
code: VK.ArrowLeft,
group: 'navigation',
label: '←',
aliases: ['left', 'arrowleft'],
requiresModifier: true
},
{
device: 'keyboard',
code: VK.ArrowUp,
group: 'navigation',
label: '↑',
aliases: ['up', 'arrowup'],
requiresModifier: true
},
{
device: 'keyboard',
code: VK.ArrowRight,
group: 'navigation',
label: '→',
aliases: ['right', 'arrowright'],
requiresModifier: true
},
{
device: 'keyboard',
code: VK.ArrowDown,
group: 'navigation',
label: '↓',
aliases: ['down', 'arrowdown'],
requiresModifier: true
}
]
const PUNCTUATION_ENTRIES: readonly KeyCatalogEntry[] = [
{ device: 'keyboard', code: VK.Semicolon, group: 'punctuation', label: ';', aliases: ['semicolon'], requiresModifier: true },
{ device: 'keyboard', code: VK.Equal, group: 'punctuation', label: '=', aliases: ['equal', 'plus'], requiresModifier: true },
{ device: 'keyboard', code: VK.Comma, group: 'punctuation', label: ',', aliases: ['comma'], requiresModifier: true },
{ device: 'keyboard', code: VK.Minus, group: 'punctuation', label: '-', aliases: ['minus', 'dash', 'hyphen'], requiresModifier: true },
{ device: 'keyboard', code: VK.Period, group: 'punctuation', label: '.', aliases: ['period', 'dot'], requiresModifier: true },
{ device: 'keyboard', code: VK.Slash, group: 'punctuation', label: '/', aliases: ['slash'], requiresModifier: true },
{ device: 'keyboard', code: VK.Backquote, group: 'punctuation', label: '`', aliases: ['backquote', 'grave', 'tilde'], requiresModifier: true },
{ device: 'keyboard', code: VK.BracketLeft, group: 'punctuation', label: '[', aliases: ['bracketleft'], requiresModifier: true },
{ device: 'keyboard', code: VK.Backslash, group: 'punctuation', label: '\\', aliases: ['backslash'], requiresModifier: true },
{ device: 'keyboard', code: VK.BracketRight, group: 'punctuation', label: ']', aliases: ['bracketright'], requiresModifier: true },
{ device: 'keyboard', code: VK.Quote, group: 'punctuation', label: "'", aliases: ['quote', 'apostrophe'], requiresModifier: true }
]
const SYSTEM_ENTRIES: readonly KeyCatalogEntry[] = [
{
device: 'keyboard',
code: VK.PrintScreen,
group: 'system',
label: 'PrtSc',
aliases: ['printscreen', 'prtsc'],
requiresModifier: false
},
{
device: 'keyboard',
code: VK.ScrollLock,
group: 'system',
label: 'ScrLk',
aliases: ['scrolllock', 'scrlk'],
requiresModifier: false
},
{
device: 'keyboard',
code: VK.Pause,
group: 'system',
label: 'Pause',
aliases: ['pause', 'break'],
requiresModifier: false
},
{
device: 'keyboard',
code: VK.NumLock,
group: 'system',
label: 'NumLock',
aliases: ['numlock'],
requiresModifier: true
}
]
/**
* 마우스 버튼.
*
* uiohook-napi 는 이벤트를 삼킬 수 없으므로(비동기 디스패치) 바인딩해도 원래 동작이 함께 실행된다.
* 따라서:
* - 좌클릭은 바인딩 금지 (모든 UI 조작이 트리거를 일으킨다)
* - 우클릭 / 가운데 클릭은 수정자 필수
* - X1 / X2 는 단독 허용 (기본 동작이 앱 탐색 정도라 부작용이 작다)
*/
const MOUSE_ENTRIES: readonly KeyCatalogEntry[] = [
{
device: 'mouse',
code: MouseButton.Left,
group: 'mouse',
label: 'Mouse Left',
labelKey: 'keybinding.mouse.left',
aliases: ['mouseleft', 'leftclick', 'lmb', 'mb1'],
requiresModifier: true,
disabledReasonKey: 'keybinding.disabled.mouseLeft'
},
{
device: 'mouse',
code: MouseButton.Right,
group: 'mouse',
label: 'Mouse Right',
labelKey: 'keybinding.mouse.right',
aliases: ['mouseright', 'rightclick', 'rmb', 'mb2'],
requiresModifier: true,
passthroughWarningKey: 'keybinding.warning.mousePassthrough'
},
{
device: 'mouse',
code: MouseButton.Middle,
group: 'mouse',
label: 'Mouse Middle',
labelKey: 'keybinding.mouse.middle',
aliases: ['mousemiddle', 'middleclick', 'mmb', 'mb3', 'wheelclick'],
requiresModifier: true,
passthroughWarningKey: 'keybinding.warning.mousePassthrough'
},
{
device: 'mouse',
code: MouseButton.Back,
group: 'mouse',
label: 'Mouse Back',
labelKey: 'keybinding.mouse.back',
aliases: ['mouseback', 'mouse4', 'mb4', 'xbutton1', 'thumb'],
requiresModifier: false,
passthroughWarningKey: 'keybinding.warning.mousePassthrough'
},
{
device: 'mouse',
code: MouseButton.Forward,
group: 'mouse',
label: 'Mouse Forward',
labelKey: 'keybinding.mouse.forward',
aliases: ['mouseforward', 'mouse5', 'mb5', 'xbutton2', 'thumb'],
requiresModifier: false,
passthroughWarningKey: 'keybinding.warning.mousePassthrough'
}
]
/** 선택 가능한 전체 키 목록. 드롭다운 표시 순서이기도 하다. */
export const KEY_CATALOG: readonly KeyCatalogEntry[] = Object.freeze([
...MOUSE_ENTRIES,
...MODIFIER_ENTRIES,
...functionEntries(),
...letterEntries(),
...digitEntries(),
...NAVIGATION_ENTRIES,
...EDITING_ENTRIES,
...numpadEntries(),
...PUNCTUATION_ENTRIES,
...SYSTEM_ENTRIES
])
/** 그룹 표시 순서 (UI 섹션 헤더 순서) */
export const KEY_CATALOG_GROUP_ORDER: readonly KeyCatalogGroup[] = Object.freeze([
'mouse',
'modifier',
'function',
'letter',
'digit',
'navigation',
'editing',
'numpad',
'punctuation',
'system'
])
/** 그룹 라벨 i18n 키 */
export const KEY_CATALOG_GROUP_LABEL_KEYS: Readonly<Record<KeyCatalogGroup, string>> =
Object.freeze({
mouse: 'keybinding.group.mouse',
modifier: 'keybinding.group.modifier',
function: 'keybinding.group.function',
letter: 'keybinding.group.letter',
digit: 'keybinding.group.digit',
numpad: 'keybinding.group.numpad',
navigation: 'keybinding.group.navigation',
editing: 'keybinding.group.editing',
punctuation: 'keybinding.group.punctuation',
system: 'keybinding.group.system'
})
const CATALOG_INDEX: ReadonlyMap<string, KeyCatalogEntry> = new Map(
KEY_CATALOG.map((entry) => [`${entry.device}:${entry.code}`, entry])
)
/** device + code 로 카탈로그 항목을 찾는다. 없으면 null. */
export function findKeyCatalogEntry(
device: BindingDevice,
code: number
): KeyCatalogEntry | null {
return CATALOG_INDEX.get(`${device}:${code}`) ?? null
}
// ============================================================
// 액션 카탈로그 — 바인딩 가능한 기능 목록
// ============================================================
export type KeyBindingActionId =
| 'dictation'
| 'hands-free'
| 'command'
| 'caption'
| 'history-popup'
| 'command-popup'
| 'suggestion-accept'
| 'suggestion-next'
| 'suggestion-prev'
| 'suggestion-dismiss'
/** 액션 그룹 (설정 화면 섹션) */
export type KeyBindingActionGroup = 'voice' | 'window' | 'input'
export interface KeyBindingActionSpec {
id: KeyBindingActionId
group: KeyBindingActionGroup
/** 액션 이름 i18n 키 */
labelKey: string
/** 액션 설명 i18n 키 */
descriptionKey: string
/** 누르고 있는 동안 활성(hold-to-talk) 방식인지 */
holdMode: boolean
/** 더블프레스로 트리거되는지 */
doublePress: boolean
/** 기본 바인딩 */
defaultBindings: readonly KeyBinding[]
}
/** 키보드 바인딩을 간결하게 만든다. 기본값(옛 기본값 등)을 구성할 때 이 모듈 밖에서도 쓴다. */
export function kb(
code: number,
mods: Partial<Pick<KeyBinding, 'ctrl' | 'alt' | 'shift' | 'meta'>> = {}
): KeyBinding {
return {
device: 'keyboard',
code,
ctrl: mods.ctrl ?? false,
alt: mods.alt ?? false,
shift: mods.shift ?? false,
meta: mods.meta ?? false
}
}
/**
* 바인딩 가능한 전체 액션.
*
* 하드코딩 단축키를 남기지 않는다 — 앱의 모든 전역 단축키는 이 목록에 있어야 하고,
* 전부 같은 방식으로 재바인딩된다.
*/
export const KEYBINDING_ACTIONS: readonly KeyBindingActionSpec[] = Object.freeze([
{
id: 'dictation',
group: 'voice',
labelKey: 'keybinding.action.dictation',
descriptionKey: 'keybinding.action.dictation.desc',
holdMode: true,
doublePress: false,
defaultBindings: [kb(VK.AltRight)]
},
{
id: 'hands-free',
group: 'voice',
labelKey: 'keybinding.action.handsFree',
descriptionKey: 'keybinding.action.handsFree.desc',
holdMode: false,
doublePress: true,
// dictation 과 같은 키를 더블프레스로 구분한다.
defaultBindings: [kb(VK.AltRight)]
},
{
id: 'command',
group: 'voice',
labelKey: 'keybinding.action.command',
descriptionKey: 'keybinding.action.command.desc',
holdMode: false,
doublePress: false,
defaultBindings: [kb(VK.AltRight, { ctrl: true })]
},
{
id: 'caption',
group: 'voice',
labelKey: 'keybinding.action.caption',
descriptionKey: 'keybinding.action.caption.desc',
holdMode: false,
doublePress: false,
defaultBindings: [kb(VK.AltRight, { ctrl: true, shift: true })]
},
{
id: 'history-popup',
group: 'window',
labelKey: 'keybinding.action.historyPopup',
descriptionKey: 'keybinding.action.historyPopup.desc',
holdMode: false,
doublePress: false,
defaultBindings: [kb(0x56 /* V */, { ctrl: true, shift: true })]
},
{
id: 'command-popup',
group: 'window',
labelKey: 'keybinding.action.commandPopup',
descriptionKey: 'keybinding.action.commandPopup.desc',
holdMode: false,
doublePress: false,
defaultBindings: [kb(0x43 /* C */, { ctrl: true, shift: true })]
},
{
id: 'suggestion-accept',
group: 'input',
labelKey: 'keybinding.action.suggestionAccept',
descriptionKey: 'keybinding.action.suggestionAccept.desc',
holdMode: false,
doublePress: false,
// Ctrl+Alt+↑/↓ 로 후보를 오가고(페이지는 따라 넘어간다), 수락은 Enter.
// 좌우 화살표는 쓰지 않는다 — Intel 그래픽 드라이버의 화면 회전 단축키와 겹친다.
defaultBindings: [kb(VK.Enter, { ctrl: true, alt: true })]
},
{
id: 'suggestion-next',
group: 'input',
labelKey: 'keybinding.action.suggestionNext',
descriptionKey: 'keybinding.action.suggestionNext.desc',
holdMode: false,
doublePress: false,
defaultBindings: [kb(VK.ArrowDown, { ctrl: true, alt: true })]
},
{
id: 'suggestion-prev',
group: 'input',
labelKey: 'keybinding.action.suggestionPrev',
descriptionKey: 'keybinding.action.suggestionPrev.desc',
holdMode: false,
doublePress: false,
defaultBindings: [kb(VK.ArrowUp, { ctrl: true, alt: true })]
},
{
id: 'suggestion-dismiss',
group: 'input',
labelKey: 'keybinding.action.suggestionDismiss',
descriptionKey: 'keybinding.action.suggestionDismiss.desc',
holdMode: false,
doublePress: false,
// "모든 액션은 기본 바인딩을 하나 이상 갖는다" 가 카탈로그 불변식이라
// (KEYBINDING_ACTIONS 불변식 테스트) 빈 배열을 기본값으로 두지 않는다.
// 평범한 Esc(전역, 수정자 없음)가 오버레이가 떠 있을 때만 반응하는
// 별도 경로로 항상 닫아 주므로, 이 바인딩은 보조 수단이다.
defaultBindings: [kb(VK.Backspace, { ctrl: true, alt: true })]
}
])
const ACTION_INDEX: ReadonlyMap<KeyBindingActionId, KeyBindingActionSpec> = new Map(
KEYBINDING_ACTIONS.map((a) => [a.id, a])
)
export function findActionSpec(id: KeyBindingActionId): KeyBindingActionSpec | null {
return ACTION_INDEX.get(id) ?? null
}
export function isKeyBindingActionId(value: string): value is KeyBindingActionId {
return ACTION_INDEX.has(value as KeyBindingActionId)
}
/** 기본 바인딩 맵 (설정 초기값 · 리셋용) */
export function createDefaultBindingMap(): KeyBindingMap {
const out = {} as KeyBindingMap
for (const action of KEYBINDING_ACTIONS) {
out[action.id] = action.defaultBindings.map((b) => ({ ...b }))
}
return out
}
// ============================================================
// 정규화 · 동일성
// ============================================================
/**
* 바인딩을 정규화한다.
*
* - 주 키가 수정자 키 자체이면 그 수정자 플래그를 끈다
* (Right Alt 단독 바인딩이 "Alt + Right Alt" 로 저장되는 것을 막는다)
* - 알 수 없는 장치는 keyboard 로 취급한다
*/
export function normalizeBinding(binding: KeyBinding): KeyBinding {
const device: BindingDevice = binding.device === 'mouse' ? 'mouse' : 'keyboard'
const next: KeyBinding = {
device,
code: binding.code,
ctrl: binding.ctrl,
alt: binding.alt,
shift: binding.shift,
meta: binding.meta
}
if (device === 'keyboard') {
const self = selfModifierOf(binding.code)
if (self !== null) next[self] = false
}
return next
}
/**
* 바인딩의 안정적 식별 문자열.
* 런타임 상태 맵(press 중 여부 · 마지막 press 시각)의 키로 쓴다 — 액션 id 가 아니라 이것으로 키잉해야
* 한 액션에 여러 바인딩이 붙어도 상태가 섞이지 않는다.
*/
export function bindingKey(binding: KeyBinding): string {
const b = normalizeBinding(binding)
const mods =
(b.ctrl ? 'C' : '') + (b.alt ? 'A' : '') + (b.shift ? 'S' : '') + (b.meta ? 'M' : '')
return `${b.device === 'mouse' ? 'm' : 'k'}:${b.code}:${mods}`
}
export function bindingsEqual(a: KeyBinding, b: KeyBinding): boolean {
return bindingKey(a) === bindingKey(b)
}
/** 수정자가 하나라도 켜져 있는지 (정규화 후 기준) */
export function hasModifier(binding: KeyBinding): boolean {
const b = normalizeBinding(binding)
return b.ctrl || b.alt || b.shift || b.meta
}
// ============================================================
// 표시 라벨
// ============================================================
export type BindingPlatform = 'darwin' | 'win32' | 'linux'
/**
* 표시용 세그먼트 하나.
* `i18nKey` 가 있으면 t() 로 번역해 쓰고, 없으면 `label` 을 그대로 쓴다.
*/
export interface BindingSegment {
label: string
i18nKey?: string
}
/**
* 바인딩 → 표시 세그먼트 배열.
*
* 수정자 표기 순서:
* macOS ⌃ ⌥ ⇧ ⌘ + key (Apple 표준)
* Win/Linux Ctrl Win Alt Shift + key
*/
export function formatBindingSegments(
binding: KeyBinding | null,
platform: BindingPlatform
): BindingSegment[] {
if (binding === null) return []
const b = normalizeBinding(binding)
const segments: BindingSegment[] = []
const mac = platform === 'darwin'
if (mac) {
if (b.ctrl) segments.push({ label: '⌃' })
if (b.alt) segments.push({ label: '⌥' })
if (b.shift) segments.push({ label: '⇧' })
if (b.meta) segments.push({ label: '⌘' })
} else {
if (b.ctrl) segments.push({ label: 'Ctrl' })
if (b.meta) segments.push({ label: 'Win' })
if (b.alt) segments.push({ label: 'Alt' })
if (b.shift) segments.push({ label: 'Shift' })
}
const entry = findKeyCatalogEntry(b.device, b.code)
if (entry === null) {
segments.push({ label: b.device === 'mouse' ? `Mouse${b.code}` : `Key${b.code}` })
} else {
segments.push({
label: mac ? (entry.macLabel ?? entry.label) : entry.label,
...(entry.labelKey === undefined ? {} : { i18nKey: entry.labelKey })
})
}
return segments
}
/** 세그먼트를 플랫폼 관례대로 결합한다. macOS 는 구분자 없음. */
export function joinBindingSegments(
segments: readonly string[],
platform: BindingPlatform
): string {
if (segments.length === 0) return ''
return platform === 'darwin' ? segments.join('') : segments.join(' + ')
}
// ============================================================
// 검증
// ============================================================
export type BindingRejectReason =
| 'unknown-key'
| 'device-disabled'
| 'modifier-required'
| 'system-reserved'
export interface BindingValidation {
valid: boolean
/** 거부 사유 (valid === true 이면 null) */
reason: BindingRejectReason | null
/** 거부 사유 i18n 키 (valid === true 이면 null) */
reasonKey: string | null
/** 허용되지만 사용자에게 알려야 하는 경고의 i18n 키 */
warningKey: string | null
}
/**
* 시스템 예약 조합.
* 문자열이 아니라 구조로 비교한다 — 문자열 비교는 수정자 입력 순서에 따라 뚫린다.
*/
const RESERVED_COMBOS: readonly KeyBinding[] = Object.freeze([
kb(0x43, { ctrl: true }), // Ctrl+C
kb(0x56, { ctrl: true }), // Ctrl+V
kb(0x58, { ctrl: true }), // Ctrl+X
kb(0x5a, { ctrl: true }), // Ctrl+Z
kb(0x41, { ctrl: true }), // Ctrl+A
kb(0x53, { ctrl: true }), // Ctrl+S
kb(0x57, { ctrl: true }), // Ctrl+W
kb(VK.F1 + 3, { alt: true }), // Alt+F4
kb(VK.Tab, { alt: true }), // Alt+Tab
kb(VK.Escape, { ctrl: true, alt: true }), // Ctrl+Alt+Esc
kb(VK.Delete, { ctrl: true, alt: true }) // Ctrl+Alt+Delete
])
const RESERVED_KEYS: ReadonlySet<string> = new Set(RESERVED_COMBOS.map(bindingKey))
/** 바인딩이 사용 가능한지 판정한다. 녹화·드롭다운 선택 양쪽에서 이 함수만 쓴다. */
export function validateBinding(binding: KeyBinding): BindingValidation {
const b = normalizeBinding(binding)
const entry = findKeyCatalogEntry(b.device, b.code)
if (entry === null) {
return {
valid: false,
reason: 'unknown-key',
reasonKey: 'keybinding.reject.unknownKey',
warningKey: null
}
}
if (entry.disabledReasonKey !== undefined) {
return {
valid: false,
reason: 'device-disabled',
reasonKey: entry.disabledReasonKey,
warningKey: null
}
}
if (RESERVED_KEYS.has(bindingKey(b))) {
return {
valid: false,
reason: 'system-reserved',
reasonKey: 'keybinding.reject.systemReserved',
warningKey: null
}
}
if (entry.requiresModifier && !hasModifier(b)) {
return {
valid: false,
reason: 'modifier-required',
reasonKey: 'keybinding.reject.modifierRequired',
warningKey: null
}
}
return {
valid: true,
reason: null,
reasonKey: null,
warningKey: entry.passthroughWarningKey ?? null
}
}
// ============================================================
// 충돌 판정
// ============================================================
export interface BindingConflict {
/** 이미 같은 바인딩을 점유한 액션 */
actionId: KeyBindingActionId
}
/**
* 다른 액션이 이미 같은 바인딩을 쓰고 있는지 검사한다.
*
* dictation / hands-free 처럼 홀드와 더블프레스로 구분되는 쌍은 충돌이 아니다 —
* 같은 키를 공유하는 것이 기본 설계다.
*/
export function detectBindingConflicts(
targetAction: KeyBindingActionId,
binding: KeyBinding,
map: Readonly<KeyBindingMap>
): BindingConflict[] {
const key = bindingKey(binding)
const target = findActionSpec(targetAction)
const conflicts: BindingConflict[] = []
for (const action of KEYBINDING_ACTIONS) {
if (action.id === targetAction) continue
// 홀드 vs 더블프레스는 같은 키를 공유해도 런타임에서 구분된다.
if (target !== null && target.doublePress !== action.doublePress) continue
const bindings = map[action.id] ?? []
if (bindings.some((b) => bindingKey(b) === key)) {
conflicts.push({ actionId: action.id })
}
}
return conflicts
}
/** 저장된 전체 단축키 설정에서 사용 불가·중복 바인딩을 수집한 결과. */
export interface KeyBindingAuditIssue {
kind: 'invalid' | 'conflict'
actionId: KeyBindingActionId
bindingIndex: number
binding: KeyBinding
reasonKey: string | null
conflictActionIds: KeyBindingActionId[]
}
/**
* 전체 바인딩 맵을 한 번에 검수한다.
*
* hold/double-press 예외는 detectBindingConflicts()의 기존 계약을 그대로 따른다.
*/
export function auditKeyBindingMap(map: Readonly<KeyBindingMap>): KeyBindingAuditIssue[] {
const issues: KeyBindingAuditIssue[] = []
const reportedPairs = new Set<string>()
for (const action of KEYBINDING_ACTIONS) {
const bindings = map[action.id] ?? []
for (const [bindingIndex, binding] of bindings.entries()) {
const validation = validateBinding(binding)
if (!validation.valid) {
issues.push({
kind: 'invalid',
actionId: action.id,
bindingIndex,
binding: { ...binding },
reasonKey: validation.reasonKey,
conflictActionIds: []
})
}
const conflictActionIds = detectBindingConflicts(action.id, binding, map)
.map((conflict) => conflict.actionId)
.filter((otherActionId) => action.id.localeCompare(otherActionId) < 0)
.filter((otherActionId) => {
const pairKey = `${bindingKey(binding)}:${action.id}:${otherActionId}`
if (reportedPairs.has(pairKey)) return false
reportedPairs.add(pairKey)
return true
})
.sort((a, b) => a.localeCompare(b))
if (conflictActionIds.length > 0) {
issues.push({
kind: 'conflict',
actionId: action.id,
bindingIndex,
binding: { ...binding },
reasonKey: null,
conflictActionIds
})
}
}
}
return issues
}
// ============================================================
// 검색 (드롭다운 필터)
// ============================================================
/**
* 카탈로그 검색.
*
* `localizedLabels` 로 번역된 이름을 넘기면 함께 매칭한다(마우스 버튼 등).
* 키는 `${device}:${code}`.
*/
export function searchKeyCatalog(
query: string,
localizedLabels: Readonly<Record<string, string>> = {}
): KeyCatalogEntry[] {
const q = query.trim().toLowerCase()
if (q === '') return [...KEY_CATALOG]
return KEY_CATALOG.filter((entry) => {
if (entry.label.toLowerCase().includes(q)) return true
if (entry.aliases.some((a) => a.includes(q))) return true
const localized = localizedLabels[`${entry.device}:${entry.code}`]
if (localized !== undefined && localized.toLowerCase().includes(q)) return true
return false
})
}
// ============================================================
// IPC 계약
// ============================================================
/** `keybinding:setBindings` 파라미터 */
export interface SetKeyBindingsParams {
actionId: KeyBindingActionId
bindings: KeyBinding[]
}
/** `keybinding:resetAction` 파라미터 */
export interface ResetKeyBindingParams {
actionId: KeyBindingActionId
}
/** `keybinding:validate` 파라미터 */
export interface ValidateKeyBindingParams {
actionId: KeyBindingActionId
binding: KeyBinding
}
/** `keybinding:validate` 결과 — 유효성 + 액션 간 충돌 */
export interface KeyBindingValidationResult {
validation: BindingValidation
conflicts: BindingConflict[]
}
/** `keybinding:triggered` 페이로드 */
export interface KeyBindingTriggeredEvent {
actionId: KeyBindingActionId
type: 'pressed' | 'released'
isDoublePress: boolean
}
/** `keybinding:changed` 페이로드 */
export interface KeyBindingChangedEvent {
map: KeyBindingMap
}
// ============================================================
// 역직렬화 가드 (저장소 · IPC 경계)
// ============================================================
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
/** 임의의 값이 KeyBinding 형태인지 검사한다. electron-store 는 스키마 검증을 하지 않으므로 필요하다. */
export function isKeyBinding(value: unknown): value is KeyBinding {
if (!isRecord(value)) return false
if (value.device !== 'keyboard' && value.device !== 'mouse') return false
if (typeof value.code !== 'number' || !Number.isFinite(value.code)) return false
return (
typeof value.ctrl === 'boolean' &&
typeof value.alt === 'boolean' &&
typeof value.shift === 'boolean' &&
typeof value.meta === 'boolean'
)
}
/**
* 저장된 값에서 바인딩 목록을 복원한다. 손상된 항목은 조용히 버린다.
* 결과가 비면 호출부가 기본값으로 되돌릴 수 있게 빈 배열을 반환한다.
*/
export function parseBindingList(value: unknown): KeyBinding[] {
if (!Array.isArray(value)) return []
const out: KeyBinding[] = []
const seen = new Set<string>()
for (const item of value) {
if (!isKeyBinding(item)) continue
const normalized = normalizeBinding(item)
const key = bindingKey(normalized)
if (seen.has(key)) continue
seen.add(key)
out.push(normalized)
}
return out
}
/**
* 저장된 전체 맵을 복원한다 (옛 의미). 누락·손상 액션 **과 명시적 빈 목록** 을 기본값으로 채운다.
*
* @deprecated 저장소를 읽는 경로는 `decodeKeyBindingMap` 을 쓴다. 이 함수는 사용자가 지운
* 액션(`[]`)을 기본값으로 되살리므로, 저장값을 읽고 다시 쓰는 곳에서 쓰면 지운 단축키가
* 되돌아온다. 옛 호출부 호환을 위해서만 남긴다.
*/
export function parseBindingMap(value: unknown): KeyBindingMap {
const defaults = createDefaultBindingMap()
if (!isRecord(value)) return defaults
const out = {} as KeyBindingMap
for (const action of KEYBINDING_ACTIONS) {
const parsed = parseBindingList(value[action.id])
out[action.id] = parsed.length > 0 ? parsed : defaults[action.id]
}
return out
}
// ============================================================
// 저장 코덱 — 저장된 keyBindings 를 읽는 단일 정본
// ============================================================
/** 액션 하나의 저장값을 해석한 결과 */
type StoredListState =
/** 저장값 그대로 쓸 수 있다 */
| 'intact'
/** 유효 항목은 있지만 손상·중복 항목을 버렸거나 정규화로 모양이 바뀌었다 */
| 'normalized'
/** 누락·배열 아님·전부 손상 — 기본값으로 복구했다 */
| 'repaired'
function sameStoredShape(stored: KeyBinding, decoded: KeyBinding): boolean {
return (
stored.device === decoded.device &&
stored.code === decoded.code &&
stored.ctrl === decoded.ctrl &&
stored.alt === decoded.alt &&
stored.shift === decoded.shift &&
stored.meta === decoded.meta
)
}
function decodeBindingList(
value: unknown,
fallback: readonly KeyBinding[]
): { bindings: KeyBinding[]; state: StoredListState } {
const repaired = (): { bindings: KeyBinding[]; state: StoredListState } => ({
bindings: fallback.map((b) => ({ ...b })),
state: 'repaired'
})
if (!Array.isArray(value)) return repaired()
// 명시적 빈 목록 = 사용자가 이 액션의 단축키를 모두 지웠다. 기본값으로 되살리지 않는다.
if (value.length === 0) return { bindings: [], state: 'intact' }
const bindings = parseBindingList(value)
// 비어 있지 않았는데 쓸 만한 항목이 하나도 없다 = 손상. 기본값으로 복구한다.
if (bindings.length === 0) return repaired()
const intact =
bindings.length === value.length &&
bindings.every((binding, index) => {
const stored: unknown = value[index]
return isKeyBinding(stored) && sameStoredShape(stored, binding)
})
return { bindings, state: intact ? 'intact' : 'normalized' }
}
/** `decodeKeyBindingMap` 결과 */
export interface DecodedKeyBindingMap {
/** 모든 액션 키를 가진 정규화된 맵. 명시적 빈 목록은 빈 목록 그대로다. */
map: KeyBindingMap
/** 저장값이 없거나(새 액션 포함) 손상되어 기본값으로 채운 액션 */
repairedActions: KeyBindingActionId[]
/** 결과가 저장된 모양과 달라 다시 써야 하는지 (복구 · 손상 항목 제거 · 정규화) */
needsRewrite: boolean
}
/**
* 저장된 keyBindings 를 엄격하게 해석한다 — 저장소를 읽는 모든 경로(설정 로딩 · IPC ·
* 마이그레이션 · 후킹 서비스)의 단일 정본.
*
* - 명시적 빈 목록(`[]`)은 "이 액션은 단축키 없음" 으로 보존한다.
* - 누락 · 배열 아님 · 비어 있지 않은데 유효 항목이 없는 액션만 기본값으로 복구한다.
* - 유효 항목은 정규화하고 중복을 없앤다. 알 수 없는 액션 키는 버린다.
*/
export function decodeKeyBindingMap(value: unknown): DecodedKeyBindingMap {
const record = isRecord(value) && !Array.isArray(value) ? value : null
const map = {} as KeyBindingMap
const repairedActions: KeyBindingActionId[] = []
let needsRewrite = record === null
for (const action of KEYBINDING_ACTIONS) {
const decoded = decodeBindingList(record?.[action.id], action.defaultBindings)
map[action.id] = decoded.bindings
if (decoded.state === 'repaired') repairedActions.push(action.id)
if (decoded.state !== 'intact') needsRewrite = true
}
return { map, repairedActions, needsRewrite }
}