// src/main/windows/popup-navigation.ts // 히스토리/명령어 팝업의 표시·키보드 내비게이션 컨트롤러. // // 두 팝업은 포커스를 갖지 않으므로(focusable:false) 키보드 조작은 전역 단축키 // (Up/Down/Return/Escape/1-9)로만 들어온다. 예전엔 bootstrap 이 팝업마다 단축키를 따로 // 등록했는데, Electron 은 이미 등록된 가속기에 대한 재등록을 조용히 거부(false 반환)하므로 // 히스토리 팝업을 연 채 명령어 팝업을 열면 Enter 가 여전히 히스토리 항목을 삽입했다. // 이제 단축키 소유자(owner)는 하나이고, 한 팝업을 열기 전에 다른 팝업을 닫는다. // Electron 의존은 포트(PopupWindowPort/ShortcutPort)로 주입한다 — 이 파일은 순수 로직이다. export type PopupKind = 'history' | 'command' export interface PopupWindowPort { /** 최신 데이터로 팝업을 띄운다 */ show(): void hide(): void isVisible(): boolean sendKey(key: string): void } export interface ShortcutPort { /** 성공하면 true — Electron globalShortcut.register 와 같은 의미 */ register(accelerator: string, callback: () => void): boolean unregister(accelerator: string): void } export interface PopupNavigationDeps { windows: Record shortcuts: ShortcutPort /** 히스토리 항목 텍스트를 포커스된 앱에 삽입한다 */ insertText(text: string): Promise /** 명령어 선택(빈 id 는 선택 해제)을 설정에 반영하고 UI 에 알린다 */ applyCommandSelection(selection: { id: string; name: string }): void /** 지연 실행 — 기본 setTimeout */ schedule?(fn: () => void, ms: number): void warn?(message: string): void } /** 팝업 키 내비게이션 가속기 → 팝업에 보낼 키 이름 */ export const POPUP_NAV_KEYS: ReadonlyArray<{ accel: string; key: string }> = [ { accel: 'Up', key: 'ArrowUp' }, { accel: 'Down', key: 'ArrowDown' }, { accel: 'Return', key: 'Enter' }, { accel: 'Escape', key: 'Escape' }, { accel: '1', key: '1' }, { accel: '2', key: '2' }, { accel: '3', key: '3' }, { accel: '4', key: '4' }, { accel: '5', key: '5' }, { accel: '6', key: '6' }, { accel: '7', key: '7' }, { accel: '8', key: '8' }, { accel: '9', key: '9' }, ] /** 히스토리 팝업이 사라진 뒤 삽입까지 기다리는 시간(포커스 복귀 대기) */ export const HISTORY_INSERT_DELAY_MS = 200 export class PopupNavigationController { private _owner: PopupKind | null = null private _keysRegistered = false constructor(private readonly _deps: PopupNavigationDeps) {} /** 현재 내비게이션 키를 받는 팝업 */ get owner(): PopupKind | null { return this._owner } toggle(kind: PopupKind): void { if (this._deps.windows[kind].isVisible()) { this.dismiss(kind) return } this.show(kind) } /** 다른 팝업을 닫고 이 팝업을 띄운 뒤 내비게이션 키를 이 팝업에 묶는다. */ show(kind: PopupKind): void { const other: PopupKind = kind === 'history' ? 'command' : 'history' if (this._deps.windows[other].isVisible()) { this._deps.windows[other].hide() } this._deps.windows[kind].show() this._owner = kind this._registerKeys() } /** 팝업을 닫는다. 키는 그 팝업이 소유자일 때만 해제한다(다른 팝업의 키를 뺏지 않게). */ dismiss(kind: PopupKind): void { this._deps.windows[kind].hide() if (this._owner === kind) { this._owner = null this._unregisterKeys() } } /** 히스토리 항목 선택 → 팝업을 닫고 잠시 뒤 삽입 */ selectHistoryItem(text: string): void { this.dismiss('history') const schedule = this._deps.schedule ?? ((fn: () => void, ms: number) => { setTimeout(fn, ms) }) schedule(() => { this._deps.insertText(text).catch((error: unknown) => { this._warn(`History popup insert failed: ${error instanceof Error ? error.message : String(error)}`) }) }, HISTORY_INSERT_DELAY_MS) } /** 명령어 선택 → 팝업을 닫고 선택을 반영 */ selectCommand(selection: { id: string; name: string }): void { this.dismiss('command') this._deps.applyCommandSelection(selection) } dispose(): void { this._owner = null this._unregisterKeys() } private _registerKeys(): void { if (this._keysRegistered) return this._keysRegistered = true for (const { accel, key } of POPUP_NAV_KEYS) { try { // 콜백은 등록 시점이 아니라 "지금의 소유자"에게 보낸다 const ok = this._deps.shortcuts.register(accel, () => this._routeKey(key)) if (!ok) this._warn(`Popup nav key not registered (held elsewhere): ${accel}`) } catch { // 일부 키는 globalShortcut으로 등록 불가할 수 있음 this._warn(`Popup nav key registration threw: ${accel}`) } } } private _unregisterKeys(): void { if (!this._keysRegistered) return this._keysRegistered = false for (const { accel } of POPUP_NAV_KEYS) { try { this._deps.shortcuts.unregister(accel) } catch { // 이미 해제된 경우 무시 } } } private _routeKey(key: string): void { const owner = this._owner if (!owner) return const win = this._deps.windows[owner] if (win.isVisible()) { win.sendKey(key) return } // 팝업이 다른 경로로 사라졌다 — 전역 키(Enter/방향키/숫자)를 계속 붙잡지 않게 놓는다 this._owner = null this._unregisterKeys() } private _warn(message: string): void { this._deps.warn?.(message) } }