// 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(), } }