d3ro-voice/apps/desktop/src/main/windows/suggestion-overlay-presenter.ts

126 lines
5.1 KiB
TypeScript

// src/main/windows/suggestion-overlay-presenter.ts
// 입력 제안 오버레이 표시 정책의 단일 정본 (RecordingTipPresenter 와 같은 형태).
//
// 예전엔 bootstrap 의 이벤트 클로저 하나에 표시 여부 판단, 텔레메트리 표시 플래그 동기화, show/update/hide
// 선택, payload 매핑(show 와 update 가 같은 7개 필드를 따로 나열), 예외 삼키기가 섞여 있었다. bootstrap 은
// electron·서비스를 최상위에서 import 해 이 정책을 테스트할 수 없었고, 필드를 추가할 때마다 두 매핑을 따로
// 고쳐야 했다(누락돼도 컴파일러가 못 잡았다). 이제 매핑은 함수 하나이고, 창은 뷰 포트로 주입한다.
import type { AnchorKind, PhysicalRect, SuggestionState } from '@d3ro/core/input-intelligence'
import { decideSuggestionOverlayAction } from '../suggestion-overlay-policy'
import {
hideSuggestionOverlay,
isSuggestionOverlayVisible,
showSuggestionOverlay,
updateSuggestionOverlay,
} from './WindowManager'
/** 오버레이 내용 — show 와 update 가 같이 쓴다 */
export type SuggestionOverlayContentPayload = Pick<
SuggestionState,
'candidates' | 'activeIndex' | 'targetTotal' | 'generating' | 'warmingUp' | 'partialText' | 'provenance'
>
/** 새로 띄울 때만 필요한 배치 정보를 더한 payload */
export interface SuggestionOverlayShowPayload extends SuggestionOverlayContentPayload {
anchor: PhysicalRect | null
anchorKind: AnchorKind
appName: string | null
}
/** 상태 → 오버레이 내용 (단일 매핑) */
export function toSuggestionOverlayContent(state: SuggestionState): SuggestionOverlayContentPayload {
return {
candidates: state.candidates,
activeIndex: state.activeIndex,
targetTotal: state.targetTotal,
generating: state.generating,
warmingUp: state.warmingUp,
partialText: state.partialText,
provenance: state.provenance,
}
}
/** 상태 → 새로 띄울 때의 payload (내용 + 앵커·앱 이름) */
export function toSuggestionOverlayShowPayload(state: SuggestionState): SuggestionOverlayShowPayload {
return {
...toSuggestionOverlayContent(state),
anchor: state.anchor,
anchorKind: state.anchorKind,
appName: state.appName,
}
}
/** 보여 줄 것(후보·생성 중·준비 중·부분 텍스트)이 있고 붙을 앵커가 있으면 표시한다. */
export function shouldPresentSuggestion(state: SuggestionState): boolean {
const hasSomethingToShow =
state.candidates.length > 0 || state.generating || state.warmingUp || !!state.partialText
return hasSomethingToShow && !!state.anchor
}
/** 오버레이 창 포트 — 테스트에서는 가짜로 바꾼다 */
export interface SuggestionOverlayView {
show(payload: SuggestionOverlayShowPayload): void
update(payload: SuggestionOverlayContentPayload): void
hide(): void
isVisible(): boolean
}
export interface SuggestionOverlayPresenterDeps {
view: SuggestionOverlayView
/** 텔레메트리에 "제안을 보여 주는 중" 을 알린다(입력 가로채기 판단용) */
setPresentationActive(active: boolean): void
warn(message: string): void
}
function describe(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
export class SuggestionOverlayPresenter {
constructor(private readonly _deps: SuggestionOverlayPresenterDeps) {}
/**
* 제안 상태가 바뀌었다. 오버레이·창 호출이 던져도 삼킨다 — UI 문제로 서비스의 진행 플래그가
* 굳어 기능 전체가 멈추면 안 된다. (텔레메트리 동기화는 오버레이 호출보다 먼저, 같은 보호 안에서)
*/
onUpdated(state: SuggestionState): void {
try {
const shouldPresent = shouldPresentSuggestion(state)
this._deps.setPresentationActive(shouldPresent)
const view = this._deps.view
const action = decideSuggestionOverlayAction(view.isVisible(), shouldPresent)
if (action === 'hide') {
view.hide()
} else if (action === 'update') {
// 이미 떠 있으면 내용만 갱신한다 — setBounds/present 를 매 스트리밍 청크마다
// 다시 부르면 X 클릭이 재present 에 가로채이고 패널이 튄다(실측).
view.update(toSuggestionOverlayContent(state))
} else {
view.show(toSuggestionOverlayShowPayload(state))
}
} catch (error) {
this._deps.warn(`제안 오버레이 표시 실패: ${describe(error)}`)
}
}
/** 제안이 닫혔다. 텔레메트리 플래그는 항상 내리고, 숨기기 실패만 삼킨다. */
onCleared(): void {
this._deps.setPresentationActive(false)
try {
this._deps.view.hide()
} catch (error) {
this._deps.warn(`제안 오버레이 숨기기 실패: ${describe(error)}`)
}
}
}
/** WindowManager 의 제안 오버레이 창을 뷰 포트로 감싼다 */
export function createWindowSuggestionOverlayView(): SuggestionOverlayView {
return {
show: (payload) => showSuggestionOverlay(payload),
update: (payload) => updateSuggestionOverlay(payload),
hide: () => hideSuggestionOverlay(),
isVisible: () => isSuggestionOverlayVisible(),
}
}