// 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 // ============================================================ // 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 = 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> = {} 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> = 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 = 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> = {} ): 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 = 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 = 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 ): 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): KeyBindingAuditIssue[] { const issues: KeyBindingAuditIssue[] = [] const reportedPairs = new Set() 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> = {} ): 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 { 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() 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 } }