d3ro-voice/apps/desktop/src/main/windows/popup-navigation.ts

157 lines
5.6 KiB
TypeScript

// 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<PopupKind, PopupWindowPort>
shortcuts: ShortcutPort
/** 히스토리 항목 텍스트를 포커스된 앱에 삽입한다 */
insertText(text: string): Promise<void>
/** 명령어 선택(빈 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)
}
}