fix(suggestion): place overlay in DIPs, size it to content, and harden suggestion sessions

This commit is contained in:
Yun Chan 2026-09-28 02:16:15 +09:00
parent 75e053d72f
commit 9e5b94ced2
18 changed files with 2178 additions and 558 deletions

View file

@ -54,7 +54,7 @@ import {
type KeyStrokeClass,
type PersonalPhrase,
type PhraseSource,
type UiRect
type PhysicalRect
} from '@d3ro/core/input-intelligence'
import { getDatabase } from '../db'
import { inputActivity, personalPhrases, suggestions as suggestionsTable, typingSamples } from '../db/schema'
@ -74,7 +74,11 @@ export interface TypingContext {
prefix: string
fullText: string
caretOffset: number | null
anchor: UiRect | null
/**
* 오버레이 앵커 — UIA 가 준 물리 픽셀 그대로다(사이드카가 DPI-aware 로 동작).
* DIP 변환은 창 배치(WindowManager → ScreenGeometryPort) 한 곳에서만 한다.
*/
anchor: PhysicalRect | null
/** anchor 가 케어렛인지 포커스 요소 전체인지 — 오버레이 배치 전략을 결정한다 */
anchorKind: AnchorKind
isPassword: boolean

View file

@ -28,6 +28,10 @@ export interface SuggestionHistoryEntry {
export interface SuggestionRecordInput {
appName: string | null
/**
* 입력 문맥(사용자가 친 글) — 호출자가 저장 정책(core shouldPersistSuggestionPrefix)을
* 적용한 값이다. 학습 동의가 없거나 학습 제외 앱이면 빈 문자열이 온다. 저장소는 판단하지 않는다.
*/
prefix: string
text: string
model: string

View file

@ -14,26 +14,29 @@ import { EventEmitter } from 'events'
import {
SUGGESTION_DEFAULTS,
SUGGESTION_MAX_OUTPUT_CHARS,
buildLocalSuggestionCandidates,
buildLocalSuggestionCandidateEntries,
decideInFlightGeneration,
decideSuggestion,
decideSuggestionRefresh,
decideSuggestionStep,
isAppExcluded,
joinSuggestion,
matchesSessionPrefix,
normalizeRequestPrefix,
normalizeSessionPrefix,
parseSuggestionCandidates,
sanitizeSuggestionLine,
selectPhraseHints,
shouldPersistSuggestionPrefix,
isTerminalApp,
extractTerminalPromptPrefix,
type AnchorKind,
type PhysicalRect,
type SuggestionCandidate,
type SuggestionPolicyInput,
type SuggestionProvenance,
type SuggestionSkipReason,
type SuggestionState,
type SuggestionStep,
type UiRect
type SuggestionStep
} from '@d3ro/core/input-intelligence'
import { configGet, configSet } from './ConfigService'
import { getLogger } from './LoggerService'
@ -143,7 +146,8 @@ const WARM_UP_FAILURE_BACKOFF_MS = SUGGESTION_DEFAULTS.minIntervalMs
export class SuggestionService extends EventEmitter {
private _candidates: SuggestionCandidate[] = []
private _activeIndex = 0
private _anchor: UiRect | null = null
/** 물리 픽셀 그대로 보관한다 — DIP 변환은 창 배치(WindowManager) 한 곳에서만 한다. */
private _anchor: PhysicalRect | null = null
/** anchor 가 케어렛인지 요소 전체인지 — 오버레이가 배치 전략을 고르는 근거 */
private _anchorKind: AnchorKind = null
private _appName: string | null = null
@ -472,7 +476,9 @@ export class SuggestionService extends EventEmitter {
userDismissedQuiet: now < this._userDismissedUntil,
lastRequestedPrefix: this._lastRequestedPrefix,
modelWarm: this._warmth.isWarm(model, now),
warmUpBackoff: this._warmth.isBackingOff(model, now, WARM_UP_FAILURE_BACKOFF_MS)
warmUpBackoff: this._warmth.isBackingOff(model, now, WARM_UP_FAILURE_BACKOFF_MS),
// 서버가 없을 때 "준비 중" 은 실제 준비 작업(워밍업 루프)이 돌 때만 보여준다.
modelStarting: this._warmUpPromise !== null
})
this._executeStep(step, context, config, now)
}
@ -541,8 +547,9 @@ export class SuggestionService extends EventEmitter {
this._showWarming(context)
break
case 'show-warming':
// 모델이 아직 안 떠 있으면 "준비 중" 을 보여준다 — 아무 반응이 없으면
// 사용자는 기능이 죽었다고 판단한다(첫 실행에서 실제로 그랬다).
// 워밍업 루프가 서버를 기다리는 중이면 "준비 중" 을 보여준다 — 아무 반응이 없으면
// 사용자는 기능이 죽었다고 판단한다(첫 실행에서 실제로 그랬다). 준비 작업이 없으면
// core 가 'record' 를 준다(끝나지 않는 스피너 방지).
this._showWarming(context)
break
}
@ -689,13 +696,16 @@ export class SuggestionService extends EventEmitter {
if (!this._isCurrentFallbackContext(prefix, context, token)) return false
const trimmedPrefix = normalizeSessionPrefix(prefix)
const localMemory = memory ?? this._collectMemoryHints(trimmedPrefix, context.appName)
const candidates = buildLocalSuggestionCandidates(trimmedPrefix, localMemory, maxCandidates, maxChars)
if (candidates.length === 0) return false
const entries = buildLocalSuggestionCandidateEntries(trimmedPrefix, localMemory, maxCandidates, maxChars)
if (entries.length === 0) return false
this._partialText = ''
this._generating = false
this._warmingUp = false
this._candidates = candidates.map((text, index) => ({ text, rank: index }))
// 단어 중간에서 이어지는 나머지(예: "Thank yo" + "u for…")는 수락할 때 공백 없이 붙인다.
this._candidates = entries.map((entry, index) =>
entry.attachToPrefix ? { text: entry.text, rank: index, joint: 'attach' } : { text: entry.text, rank: index }
)
this._activeIndex = 0
this._anchor = context.anchor
this._anchorKind = context.anchorKind
@ -703,7 +713,7 @@ export class SuggestionService extends EventEmitter {
this._windowTitle = context.windowTitle
this._windowHandle = context.windowHandle ?? null
this._generatedForPrefix = trimmedPrefix
this._targetTotal = candidates.length
this._targetTotal = entries.length
this._lastSkipReason = null
this._lastLatencyMs = latencyMs
this._provenance = {
@ -715,15 +725,15 @@ export class SuggestionService extends EventEmitter {
}
this._recordSuggestion({
prefix: trimmedPrefix,
text: candidates[0],
text: entries[0].text,
model: 'local-memory',
latencyMs,
candidateCount: candidates.length
candidateCount: entries.length
})
this._armVisibleTtl()
this.emit('updated', this.getState())
this.emit('state-changed', this.getState())
logger.info(`로컬 기억 제안 ${candidates.length}개 생성`)
logger.info(`로컬 기억 제안 ${entries.length}개 생성`)
return true
}
@ -929,6 +939,18 @@ export class SuggestionService extends EventEmitter {
return
}
// 요청 뒤 사용자가 더 쳤다면 이 결과는 옛 접두용이다 — 게시하면 다음 스냅샷에서 곧바로
// stale 로 닫히며(깜빡임) 채우기 요청만 낭비하고, 그사이 수락하면 방금 친 말이 빠진
// 문장이 들어간다. 세션과 같은 기준(matchesSessionPrefix)으로 최신 문맥과 맞춰 본다.
const latest = this._lastContext
if (latest && !matchesSessionPrefix(trimmedPrefix, latest.prefix)) {
logger.debug('제안 결과 폐기 (요청 뒤 접두가 바뀌었다)')
this.dismiss('stale')
this._budget.relaxInterval(Date.now(), SUGGESTION_DEFAULTS.minIntervalMs)
this.emit('state-changed', this.getState())
return
}
const candidates = parseSuggestionCandidates(raw, trimmedPrefix, 1, maxChars)
if (candidates.length === 0) {
this._lastSkipReason = 'generation-failed'
@ -1208,7 +1230,8 @@ export class SuggestionService extends EventEmitter {
private _abortStaleGeneration(currentPrefix: string): void {
if (!this._inFlight || !this._abort || this._abort.signal.aborted) return
const refresh = decideSuggestionRefresh(this._lastRequestedPrefix, currentPrefix)
// 세션 규칙과 같은 기준 — 요청 뒤 한 글자라도 자라면 결과는 도착하자마자 stale 이다.
const refresh = decideInFlightGeneration(this._lastRequestedPrefix, currentPrefix)
if (refresh === 'keep') return
this._generationToken += 1
@ -1327,7 +1350,9 @@ export class SuggestionService extends EventEmitter {
logger.info(`제안 수락 무시: 표시 중인 후보 없음 (index=${index ?? 'active'})`)
return { ok: false, reason: 'already-visible' }
}
const text = candidate.text
// 후보는 정제 과정에서 앞 공백을 잃는다 — 커서 앞 원문의 마지막 글자를 보고 구분 공백을
// 붙인다(joinSuggestion). 세션을 닫기 전에(dismiss 가 세션 접두를 지운다) 계산한다.
const text = joinSuggestion(this._acceptPrefix(), candidate.text, candidate.joint ?? 'separate')
const appName = this._appName
const windowTitle = this._windowTitle
const targetWindow = this._windowHandle
@ -1375,12 +1400,25 @@ export class SuggestionService extends EventEmitter {
}
logger.info(`제안 수락: ${text.length}자 삽입 (method=${method === 'keyboard' ? 'keyboard' : 'clipboard'}, app=${appName ?? '-'})`)
this._repository.markAccepted(text)
// 이력은 후보 원문으로 기록돼 있다 — 구분 공백이 붙은 삽입 문자열로는 찾지 못한다.
this._repository.markAccepted(candidate.text)
// 수락한 문장은 사용자 문체의 확실한 표본이다 (학습 동의 시에만 저장됨).
this._learning.recordAccepted(text, { appName, windowTitle })
this._learning.recordAccepted(candidate.text, { appName, windowTitle })
return { ok: true }
}
/**
* 수락 시 이음새 판단에 쓸 커서 앞 원문.
*
* 세션 접두는 뒤 공백을 걷어낸 값이라, 사용자가 멈추기 전에 공백을 쳤는지 알 수 없다.
* 최신 문맥이 여전히 이 세션의 것이면(세션 규칙) 그 원문을, 아니면 세션 접두를 쓴다.
*/
private _acceptPrefix(): string {
const latest = this._lastContext
if (latest && matchesSessionPrefix(this._generatedForPrefix, latest.prefix)) return latest.prefix
return this._generatedForPrefix
}
getHistory(limit = 50): SuggestionHistoryEntry[] {
return this._repository.list(limit)
}
@ -1408,8 +1446,16 @@ export class SuggestionService extends EventEmitter {
}
}
/**
* 제안 이력 기록 — 접두(사용자가 친 글 원문)는 학습 동의가 있고 학습 제외 앱이 아닐 때만
* 남긴다(core shouldPersistSuggestionPrefix). 그 밖에는 빈 문자열로 남겨 개수·수락률만 유지한다.
*/
private _recordSuggestion(input: Omit<SuggestionRecordInput, 'appName'>): void {
this._repository.record({ ...input, appName: this._appName })
const persistPrefix = shouldPersistSuggestionPrefix({
learnTypedText: configGet('inputLearnTypedText') === true,
appName: this._appName
})
this._repository.record({ ...input, prefix: persistPrefix ? input.prefix : '', appName: this._appName })
}
/** 테스트/진단 — 현재 앱이 제외 대상인지. */

View file

@ -5,7 +5,17 @@ import { app, BrowserWindow, screen, ipcMain, Menu, clipboard } from 'electron'
import { join } from 'path'
import { is } from '@electron-toolkit/utils'
import { WINDOW_SIZE } from '@d3ro/core/constants'
import { anchorFloatingPanel, type AnchorKind } from '@d3ro/core/input-intelligence'
import type { AnchorKind, PhysicalRect } from '@d3ro/core/input-intelligence'
import {
fitAnchoredPanel,
placeAnchoredPanel,
placeNearCursor,
type AnchoredPanelPlacement,
type DipPoint,
type ScreenGeometryPort,
type UiRect,
type UiSize
} from '@d3ro/core/popup-placement'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import {
formatBindingSegments,
@ -18,26 +28,22 @@ import { getLogger } from '../services/LoggerService'
import { getIsQuitting, setIsQuitting } from '../lifecycle'
import { hardenWebContents } from './web-contents-hardening'
import { configGet, configSet } from '../services/ConfigService'
import { buildPopupThemeCss } from '@d3ro/ui/theme-vars'
import { getI18n } from '@d3ro/i18n'
import {
createPopupWindow,
injectPopupTheme,
presentPopup,
sendToPopupWindow
} from './popup-window-factory'
import { createElectronScreenGeometry } from './screen-geometry'
import {
SUGGESTION_OVERLAY_MAX_HEIGHT,
SUGGESTION_OVERLAY_WIDTH,
suggestionPanelHeight
} from './suggestion-overlay-geometry'
const logger = getLogger('WindowManager')
// ── 팝업 테마 주입 ────────────────────────────────────────
/**
* 팝업 BrowserWindow에 현재 설정 테마의 CSS 변수를 insertCSS로 주입.
* did-finish-load 이후 호출해야 한다.
*/
function injectPopupTheme(win: BrowserWindow): void {
const theme = configGet('theme') as string
// 'system'은 'dark'로 폴백 (팝업은 시스템 다크모드 감지 불가)
const resolvedTheme = theme === 'system' ? 'dark' : theme
const css = buildPopupThemeCss(resolvedTheme)
win.webContents.insertCSS(css).catch((err: unknown) => {
logger.warn('팝업 테마 CSS 주입 실패', err)
})
}
// ── 팝업 i18n 문자열 가져오기 ─────────────────────────────
/**
* 팝업에 주입할 i18n 문자열 맵을 반환.
@ -161,97 +167,25 @@ function buildSuggestionKeyHints(): SuggestionKeyHints {
}
}
// ── Popup window lifecycle ────────────────────────────
// ── 화면 기하 (DIP) ───────────────────────────────────
// 팝업 창 생성·수명주기·표시는 popup-window-factory, 배치 정책은 core popup-placement 가 정본이다.
/** Popup windows whose renderer finished loading and can receive IPC */
const popupReady = new WeakSet<BrowserWindow>()
/** IPC messages held back until the popup renderer is ready */
const pendingPopupMessages = new WeakMap<BrowserWindow, Array<{ channel: string; data: unknown }>>()
let screenGeometry: ScreenGeometryPort | null = null
/**
* Promote popup renderer console output and load failures into the main log.
* Popups have no renderer logging otherwise, so a missing asset or a script
* exception stays invisible and the popup just renders static markup.
*/
function hookPopupDiagnostics(win: BrowserWindow, name: string): void {
win.webContents.on('console-message', (_event, level, message, line, sourceId) => {
logger.info(`[${name}] [Renderer] [${level}] ${message} (${sourceId}:${line})`)
})
win.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => {
logger.error(`[${name}] renderer load failed [${errorCode}] ${errorDescription} (${validatedURL})`)
})
win.webContents.on('render-process-gone', (_event, details) => {
logger.error(`[${name}] renderer process gone: ${details.reason} (exitCode=${details.exitCode})`)
})
/** Electron screen 은 app ready 뒤에만 쓸 수 있다 — 처음 배치할 때 만든다. */
function getScreenGeometry(): ScreenGeometryPort {
if (!screenGeometry) screenGeometry = createElectronScreenGeometry(screen)
return screenGeometry
}
/**
* Deliver IPC to a popup even while its renderer is still loading.
* webContents.send before load is silently dropped, which leaves popups stuck
* on their initial markup (for example a frozen 0:00 timer).
*/
function sendToPopupWindow(win: BrowserWindow, channel: string, data: unknown): void {
if (win.isDestroyed() || win.webContents.isDestroyed()) return
if (popupReady.has(win)) {
win.webContents.send(channel, data)
return
}
const queue = pendingPopupMessages.get(win) ?? []
queue.push({ channel, data })
if (queue.length > 32) {
queue.splice(0, queue.length - 32)
}
pendingPopupMessages.set(win, queue)
}
function flushPopupMessages(win: BrowserWindow): void {
popupReady.add(win)
const queue = pendingPopupMessages.get(win)
if (!queue || queue.length === 0) return
pendingPopupMessages.delete(win)
for (const message of queue) {
if (!win.isDestroyed() && !win.webContents.isDestroyed()) {
win.webContents.send(message.channel, message.data)
}
}
}
/** Shared popup lifecycle: diagnostics + load tracking + theme injection */
function attachPopupLifecycle(win: BrowserWindow, name: string): void {
hookPopupDiagnostics(win, name)
win.webContents.on('did-start-loading', () => {
popupReady.delete(win)
})
win.webContents.on('did-finish-load', () => {
injectPopupTheme(win)
flushPopupMessages(win)
})
}
/**
* Show a popup without stealing focus, reliably on every call.
* After hide(), showInactive() can lose z-order and repaint, so popups stopped
* appearing from the second show onward: re-assert topmost and force a repaint.
*/
function presentPopup(win: BrowserWindow, level?: 'floating' | 'screen-saver'): void {
if (!win.isVisible()) {
win.showInactive()
}
// Z-order/repaint adjustments must never break a capture session.
try {
win.setAlwaysOnTop(true, level)
win.moveTop()
win.webContents.invalidate()
} catch (err) {
logger.warn(`Popup present adjustment failed: ${err instanceof Error ? err.message : String(err)}`)
}
/** 커서 기준 배치 (녹음 팁 · 결과 · 기록 · 명령 팝업 공통) — 커서와 작업 영역 모두 DIP. */
function placePopupNearCursor(
win: BrowserWindow,
size: UiSize,
cursor: DipPoint = getScreenGeometry().cursor()
): void {
const position = placeNearCursor(cursor, size, getScreenGeometry().workAreaNear(cursor))
win.setBounds({ x: position.x, y: position.y, width: size.width, height: size.height })
}
// ── 윈도우 참조 ───────────────────────────────────────
@ -392,39 +326,13 @@ export function createMainWindow(): BrowserWindow {
// ── RecordingTip 팝업 ─────────────────────────────────
function createRecordingTipWindow(): BrowserWindow {
const win = new BrowserWindow({
width: WINDOW_SIZE.RECORDING_TIP.width,
height: WINDOW_SIZE.RECORDING_TIP.height,
show: false,
frame: false,
transparent: true,
resizable: false,
alwaysOnTop: true,
skipTaskbar: true,
focusable: false,
webPreferences: {
preload: join(__dirname, '../preload/popup.js'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false,
backgroundThrottling: false
return createPopupWindow({
name: 'recording-tip',
bounds: { width: WINDOW_SIZE.RECORDING_TIP.width, height: WINDOW_SIZE.RECORDING_TIP.height },
onClosed: () => {
recordingTipWindow = null
}
})
hardenWebContents(win)
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/popups/recording-tip/index.html`)
} else {
win.loadFile(join(__dirname, '../renderer/popups/recording-tip/index.html'))
}
attachPopupLifecycle(win, 'recording-tip')
win.on('closed', () => {
recordingTipWindow = null
})
return win
}
export function getRecordingTipWindow(): BrowserWindow {
@ -451,23 +359,8 @@ export function showRecordingTip(
): void {
const win = getRecordingTipWindow()
// 커서 위치에 배치
const cursorPos = screen.getCursorScreenPoint()
const display = screen.getDisplayNearestPoint(cursorPos)
let x = cursorPos.x - Math.round(TIP_WIDTH / 2)
let y = cursorPos.y - TIP_HEIGHT - 20
// 화면 밖 보정
x = Math.max(display.workArea.x, Math.min(x, display.workArea.x + display.workArea.width - TIP_WIDTH))
if (y < display.workArea.y) {
y = cursorPos.y + 20
}
if (y + TIP_HEIGHT > display.workArea.y + display.workArea.height) {
y = Math.max(display.workArea.y, display.workArea.y + display.workArea.height - TIP_HEIGHT)
}
win.setBounds({ x, y, width: TIP_WIDTH, height: TIP_HEIGHT })
// 커서 위치에 배치 (화면 밖 보정 포함)
placePopupNearCursor(win, { width: TIP_WIDTH, height: TIP_HEIGHT })
// 상태 전송 후 즉시 show (2-phase 제거 — 숨겨진 윈도우의 렌더러 비활성 문제 방지)
sendToPopupWindow(win, IPC_CHANNELS.WINDOW.TIP_STATE_CHANGED, { state, _i18n: getPopupI18nStrings(), ...params })
@ -506,38 +399,14 @@ export function sendPartialTranscriptToTip(text: string): void {
// ── ResultPopup 팝업 ──────────────────────────────────
function createResultPopupWindow(): BrowserWindow {
const win = new BrowserWindow({
width: WINDOW_SIZE.RESULT_POPUP.width,
height: WINDOW_SIZE.RESULT_POPUP.height,
show: false,
frame: false,
transparent: true,
resizable: false,
alwaysOnTop: true,
skipTaskbar: true,
focusable: false,
webPreferences: {
preload: join(__dirname, '../preload/popup.js'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false
return createPopupWindow({
name: 'result-popup',
bounds: { width: WINDOW_SIZE.RESULT_POPUP.width, height: WINDOW_SIZE.RESULT_POPUP.height },
onClosed: () => {
cancelPendingResultShow()
resultPopupWindow = null
}
})
hardenWebContents(win)
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/popups/result-popup/index.html`)
} else {
win.loadFile(join(__dirname, '../renderer/popups/result-popup/index.html'))
}
attachPopupLifecycle(win, 'result-popup')
win.on('closed', () => {
resultPopupWindow = null
})
return win
}
export function getResultPopupWindow(): BrowserWindow {
@ -548,39 +417,96 @@ export function getResultPopupWindow(): BrowserWindow {
return resultPopupWindow
}
/**
* 측정이 오지 않을 때 기다리는 한도.
*
* 숨긴 창에서는 렌더러의 requestAnimationFrame 이 멈출 수 있어 측정(result:measured)이 영영
* 오지 않았다 — 두 번째 삽입 실패부터 받아쓴 텍스트를 볼 방법이 없었다. 한도가 지나면 기본
* 크기로 그대로 보여준다.
*/
const RESULT_MEASURE_TIMEOUT_MS = 400
interface PendingResultShow {
autoHideMs: number
cursor: DipPoint
fallbackTimer: NodeJS.Timeout
}
let pendingResultShow: PendingResultShow | null = null
/** 마지막 표시의 커서 — 한도 뒤 늦게 도착한 측정으로 크기만 고칠 때 쓴다 */
let lastResultCursor: DipPoint | null = null
function cancelPendingResultShow(): void {
if (!pendingResultShow) return
clearTimeout(pendingResultShow.fallbackTimer)
pendingResultShow = null
}
function isMeasuredSize(data: unknown): data is UiSize {
if (data === null || typeof data !== 'object') return false
const { width, height } = data as Record<string, unknown>
return (
typeof width === 'number' &&
typeof height === 'number' &&
Number.isFinite(width) &&
Number.isFinite(height) &&
width > 0 &&
height > 0
)
}
/** Phase 2: 측정 크기(없으면 현재 크기)로 배치를 마무리하고 내용을 보인다. */
function finishResultShow(measured: UiSize | null): void {
const pending = pendingResultShow
if (!pending) return
cancelPendingResultShow()
const win = resultPopupWindow
if (!win || win.isDestroyed()) return
if (measured) placePopupNearCursor(win, measured, pending.cursor)
presentPopup(win)
sendToPopupWindow(win, IPC_CHANNELS.POPUP_RESULT.SHOW, { autoHideMs: pending.autoHideMs })
}
/**
* ResultPopup 표시 (2-phase 리사이즈)
*
* Phase 1: 기본 크기로 커서 곁에 먼저 띄운다 — 내용은 SHOW 전까지 투명하다. 창이 보여야
* 렌더러가 측정 프레임을 돈다(숨긴 창의 rAF 정지 방지).
* Phase 2: 측정이 오면(또는 한도가 지나면) 크기를 맞추고 내용을 보인다.
* 측정 응답은 모듈 단일 리스너가 받는다 — 호출마다 ipcMain.once 를 쌓지 않는다.
*/
export function showResultPopup(text: string, autoHideMs = 5000): void {
const win = getResultPopupWindow()
cancelPendingResultShow()
const cursor = getScreenGeometry().cursor()
lastResultCursor = cursor
placePopupNearCursor(
win,
{ width: WINDOW_SIZE.RESULT_POPUP.width, height: WINDOW_SIZE.RESULT_POPUP.height },
cursor
)
// Phase 1: prepare
sendToPopupWindow(win, IPC_CHANNELS.POPUP_RESULT.PREPARE, { text, _i18n: getPopupI18nStrings() })
presentPopup(win)
ipcMain.once(IPC_CHANNELS.POPUP_RESULT.MEASURED, (_event, data: { width: number; height: number }) => {
const cursorPos = screen.getCursorScreenPoint()
const display = screen.getDisplayNearestPoint(cursorPos)
let x = cursorPos.x - Math.round(data.width / 2)
let y = cursorPos.y - data.height - 20
x = Math.max(display.workArea.x, Math.min(x, display.workArea.x + display.workArea.width - data.width))
if (y < display.workArea.y) {
y = cursorPos.y + 20
}
if (y + data.height > display.workArea.y + display.workArea.height) {
y = Math.max(display.workArea.y, display.workArea.y + display.workArea.height - data.height)
}
win.setBounds({ x, y, width: data.width, height: data.height })
presentPopup(win)
// Phase 2: show
sendToPopupWindow(win, IPC_CHANNELS.POPUP_RESULT.SHOW, { autoHideMs })
})
const fallbackTimer = setTimeout(() => finishResultShow(null), RESULT_MEASURE_TIMEOUT_MS)
fallbackTimer.unref?.()
pendingResultShow = { autoHideMs, cursor, fallbackTimer }
}
ipcMain.on(IPC_CHANNELS.POPUP_RESULT.MEASURED, (event: Electron.IpcMainEvent, data: unknown) => {
const win = resultPopupWindow
if (!win || win.isDestroyed() || event.sender !== win.webContents) return
if (!isMeasuredSize(data)) return
if (pendingResultShow) {
finishResultShow(data)
return
}
// 한도 뒤에 늦게 도착한 측정 — 이미 보이는 팝업의 크기만 맞춘다.
if (win.isVisible() && lastResultCursor) placePopupNearCursor(win, data, lastResultCursor)
})
export function hideResultPopup(): void {
if (resultPopupWindow && !resultPopupWindow.isDestroyed()) {
resultPopupWindow.hide()
@ -590,38 +516,13 @@ export function hideResultPopup(): void {
// ── HistoryPopup 팝업 ─────────────────────────────────
function createHistoryPopupWindow(): BrowserWindow {
const win = new BrowserWindow({
width: 360,
height: 400,
show: false,
frame: false,
transparent: true,
resizable: false,
alwaysOnTop: true,
skipTaskbar: true,
focusable: false,
webPreferences: {
preload: join(__dirname, '../preload/popup.js'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false
return createPopupWindow({
name: 'history-popup',
bounds: { width: 360, height: 400 },
onClosed: () => {
historyPopupWindow = null
}
})
hardenWebContents(win)
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/popups/history-popup/index.html`)
} else {
win.loadFile(join(__dirname, '../renderer/popups/history-popup/index.html'))
}
attachPopupLifecycle(win, 'history-popup')
win.on('closed', () => {
historyPopupWindow = null
})
return win
}
export function getHistoryPopupWindow(): BrowserWindow {
@ -636,21 +537,7 @@ export function showHistoryPopup(entries: Array<Record<string, unknown>>): void
const win = getHistoryPopupWindow()
// 커서 위치에 즉시 배치+표시 (2-phase 제거 — 숨겨진 윈도우 렌더러 비활성 문제 방지)
const cursorPos = screen.getCursorScreenPoint()
const display = screen.getDisplayNearestPoint(cursorPos)
const popupWidth = 380
const popupHeight = Math.min(60 + entries.length * 44, 500)
let x = cursorPos.x - Math.round(popupWidth / 2)
let y = cursorPos.y - popupHeight - 20
x = Math.max(display.workArea.x, Math.min(x, display.workArea.x + display.workArea.width - popupWidth))
if (y < display.workArea.y) {
y = cursorPos.y + 20
}
win.setBounds({ x, y, width: popupWidth, height: popupHeight })
placePopupNearCursor(win, { width: 380, height: Math.min(60 + entries.length * 44, 500) })
sendToPopupWindow(win, IPC_CHANNELS.POPUP_HISTORY.SHOW_ITEMS, { entries, _i18n: getPopupI18nStrings() })
@ -683,35 +570,13 @@ export function isHistoryPopupVisible(): boolean {
// ── CommandPopup 팝업 ─────────────────────────────────
function createCommandPopupWindow(): BrowserWindow {
const win = new BrowserWindow({
width: 340,
height: 300,
show: false,
frame: false,
transparent: true,
resizable: false,
alwaysOnTop: true,
skipTaskbar: true,
focusable: false,
webPreferences: {
preload: join(__dirname, '../preload/popup.js'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false
return createPopupWindow({
name: 'command-popup',
bounds: { width: 340, height: 300 },
onClosed: () => {
commandPopupWindow = null
}
})
hardenWebContents(win)
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/popups/command-popup/index.html`)
} else {
win.loadFile(join(__dirname, '../renderer/popups/command-popup/index.html'))
}
attachPopupLifecycle(win, 'command-popup')
win.on('closed', () => { commandPopupWindow = null })
return win
}
export function getCommandPopupWindow(): BrowserWindow {
@ -723,19 +588,7 @@ export function getCommandPopupWindow(): BrowserWindow {
export function showCommandPopup(commands: Array<Record<string, unknown>>, activeId: string | null): void {
const win = getCommandPopupWindow()
const cursorPos = screen.getCursorScreenPoint()
const display = screen.getDisplayNearestPoint(cursorPos)
const popupWidth = 340
const popupHeight = Math.min(80 + commands.length * 48, 400)
let x = cursorPos.x - Math.round(popupWidth / 2)
let y = cursorPos.y - popupHeight - 20
x = Math.max(display.workArea.x, Math.min(x, display.workArea.x + display.workArea.width - popupWidth))
if (y < display.workArea.y) { y = cursorPos.y + 20 }
win.setBounds({ x, y, width: popupWidth, height: popupHeight })
placePopupNearCursor(win, { width: 340, height: Math.min(80 + commands.length * 48, 400) })
sendToPopupWindow(win, IPC_CHANNELS.POPUP_COMMAND.SHOW_ITEMS, { commands, activeId, _i18n: getPopupI18nStrings() })
presentPopup(win)
@ -793,42 +646,17 @@ function initialCaptionOverlayBounds(): Electron.Rectangle {
}
function createCaptionOverlayWindow(): BrowserWindow {
const bounds = initialCaptionOverlayBounds()
const win = new BrowserWindow({
...bounds,
show: false,
frame: false,
transparent: true,
resizable: false,
alwaysOnTop: true,
skipTaskbar: true,
focusable: false,
webPreferences: {
preload: join(__dirname, '../preload/popup.js'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false
return createPopupWindow({
name: 'caption-overlay',
bounds: initialCaptionOverlayBounds(),
// 표시 중에는 항상 보이는 창이다 — 기존 기본값(늦춤)을 유지한다.
backgroundThrottling: true,
// 클릭 통과: 마우스 이벤트를 무시하되, CSS hover 등을 위해 forward 활성화
beforeLoad: (win) => win.setIgnoreMouseEvents(true, { forward: true }),
onClosed: () => {
captionOverlayWindow = null
}
})
hardenWebContents(win)
// 클릭 통과: 마우스 이벤트를 무시하되, CSS hover 등을 위해 forward 활성화
win.setIgnoreMouseEvents(true, { forward: true })
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/popups/caption-overlay/index.html`)
} else {
win.loadFile(join(__dirname, '../renderer/popups/caption-overlay/index.html'))
}
attachPopupLifecycle(win, 'caption-overlay')
win.on('closed', () => {
captionOverlayWindow = null
})
return win
}
export function getCaptionOverlayWindow(): BrowserWindow {
@ -916,21 +744,18 @@ export function resetCaptionOverlayPosition(): void {
if (win && !win.isDestroyed()) win.setBounds(defaultCaptionOverlayBounds())
}
// ── SuggestionOverlay 업 (입력 인텔리전스) ──────────
// ── SuggestionOverlay (입력 인텔리전스) ──────────────
/**
* 다음 문장 제안 ghost text 오버레이.
*
* IME 후보창과 같은 관례를 따른다: 케어/포커스 요소 바로 아래에 붙고,
* 포커스를 막지 않으며(focusable:false) 기본은 클릭 통과다.
* IME 후보창과 같은 관례를 따른다: 캐럿/포커스 요소 바로 아래에 붙고,
* 포커스를 막지 않는다(focusable:false).
* (KeyType.Windows 의 WS_EX_TRANSPARENT|WS_EX_NOACTIVATE 오버레이와 동일한 전략)
*
* 창은 그려진 패널 높이만큼만 둔다(suggestionPanelHeight). 투명 영역도 클릭을 삼키므로,
* 고정 높이 창은 "준비 중" 한 줄만 보일 때 캐럿 아래 줄의 클릭을 가로챘다.
*/
const SUGGESTION_OVERLAY_WIDTH = 460
/**
* 한 페이지(3개 × 고정 44px) + 진행률 줄 + 키 안내 줄이 스크롤 없이 들어가는 높이.
* style.css 고정 치수로 렌더링해 잰 값(패널 하단 204 + root 패딩 4) — CSS 를 바꾸면 다시 잰다.
*/
const SUGGESTION_OVERLAY_HEIGHT = 208
function applySuggestionOverlayMouseMode(win: BrowserWindow): void {
const interactive = configGet('suggestionOverlayInteractive') !== false
@ -949,41 +774,16 @@ function applySuggestionOverlayMouseMode(win: BrowserWindow): void {
}
function createSuggestionOverlayWindow(): BrowserWindow {
const win = new BrowserWindow({
width: SUGGESTION_OVERLAY_WIDTH,
height: SUGGESTION_OVERLAY_HEIGHT,
show: false,
frame: false,
transparent: true,
resizable: false,
alwaysOnTop: true,
skipTaskbar: true,
focusable: false,
webPreferences: {
preload: join(__dirname, '../preload/popup.js'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false,
backgroundThrottling: false
return createPopupWindow({
name: 'suggestion-overlay',
bounds: { width: SUGGESTION_OVERLAY_WIDTH, height: SUGGESTION_OVERLAY_MAX_HEIGHT },
beforeLoad: applySuggestionOverlayMouseMode,
onClosed: () => {
suggestionOverlayPlacement = null
suggestionOverlayBounds = null
suggestionOverlayWindow = null
}
})
hardenWebContents(win)
applySuggestionOverlayMouseMode(win)
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/popups/suggestion-overlay/index.html`)
} else {
win.loadFile(join(__dirname, '../renderer/popups/suggestion-overlay/index.html'))
}
attachPopupLifecycle(win, 'suggestion-overlay')
win.on('closed', () => {
suggestionOverlayWindow = null
})
return win
}
export function getSuggestionOverlayWindow(): BrowserWindow {
@ -1016,37 +816,72 @@ interface SuggestionOverlayContent {
provenance?: SuggestionOverlayProvenance | null
}
/** 제안 오버레이 표시 — 커(케어 → 요소 → 커서) 기준 배치. */
/** 현재 세션의 배치(최대 높이 기준) — 내용이 바뀌면 이 자리에서 높이만 맞춘다. */
let suggestionOverlayPlacement: AnchoredPanelPlacement | null = null
let suggestionOverlayBounds: UiRect | null = null
function sameRect(a: UiRect | null, b: UiRect): boolean {
return !!a && a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height
}
/**
* 내용 높이에 맞춰 창 크기를 고친다. 늘어날 때는 내용보다 먼저(잘림 방지),
* 줄어들 때는 내용 다음에(`deliver` 뒤) 적용한다.
*/
function fitSuggestionOverlay(win: BrowserWindow, content: SuggestionOverlayContent, deliver: () => void): void {
const placement = suggestionOverlayPlacement
if (!placement) {
deliver()
return
}
const bounds = fitAnchoredPanel(
placement,
suggestionPanelHeight({
candidateCount: content.candidates.length,
activeIndex: content.activeIndex,
generating: content.generating === true,
warmingUp: content.warmingUp === true,
hasPartialText: !!content.partialText
})
)
const previous = suggestionOverlayBounds
const apply = (): void => {
if (sameRect(suggestionOverlayBounds, bounds)) return
suggestionOverlayBounds = bounds
win.setBounds(bounds)
}
if (!previous || bounds.height >= previous.height) {
apply()
deliver()
} else {
deliver()
apply()
}
}
/**
* 제안 오버레이 표시 — 캐럿 → 요소 → 커서 기준 배치.
*
* anchor 는 UIA 가 준 물리 픽셀이다. 화면 기하 포트로 한 번만 DIP 로 바꾼 뒤 모니터를 고르고
* 배치한다(서비스에는 DIP 를 저장하지 않는다 — 이중 변환 방지).
*/
export function showSuggestionOverlay(
payload: SuggestionOverlayContent & {
anchor: { x: number; y: number; width: number; height: number } | null
anchor: PhysicalRect | null
anchorKind?: AnchorKind
appName: string | null
}
): void {
const win = getSuggestionOverlayWindow()
const cursor = screen.getCursorScreenPoint()
const anchorPoint = payload.anchor
? { x: payload.anchor.x, y: payload.anchor.y + payload.anchor.height }
: cursor
const display = screen.getDisplayNearestPoint(anchorPoint)
const position = anchorFloatingPanel(
suggestionOverlayPlacement = placeAnchoredPanel(
getScreenGeometry(),
payload.anchor,
payload.anchorKind ?? null,
cursor,
{ width: SUGGESTION_OVERLAY_WIDTH, height: SUGGESTION_OVERLAY_HEIGHT },
display.workArea
{ width: SUGGESTION_OVERLAY_WIDTH, height: SUGGESTION_OVERLAY_MAX_HEIGHT }
)
suggestionOverlayBounds = null
win.setBounds({
x: position.x,
y: position.y,
width: SUGGESTION_OVERLAY_WIDTH,
height: SUGGESTION_OVERLAY_HEIGHT
})
sendToPopupWindow(win, IPC_CHANNELS.POPUP_SUGGESTION.SHOW, {
fitSuggestionOverlay(win, payload, () => sendToPopupWindow(win, IPC_CHANNELS.POPUP_SUGGESTION.SHOW, {
candidates: payload.candidates,
activeIndex: payload.activeIndex,
targetTotal: payload.targetTotal ?? payload.candidates.length,
@ -1057,22 +892,27 @@ export function showSuggestionOverlay(
provenance: payload.provenance ?? null,
_i18n: getPopupI18nStrings(),
_keyHints: buildSuggestionKeyHints()
})
}))
presentPopup(win, 'screen-saver')
}
export function updateSuggestionOverlay(payload: SuggestionOverlayContent): void {
if (suggestionOverlayWindow && !suggestionOverlayWindow.isDestroyed()) {
sendToPopupWindow(suggestionOverlayWindow, IPC_CHANNELS.POPUP_SUGGESTION.UPDATE, {
...payload,
targetTotal: payload.targetTotal ?? payload.candidates.length,
_keyHints: buildSuggestionKeyHints()
})
const win = suggestionOverlayWindow
if (win && !win.isDestroyed()) {
fitSuggestionOverlay(win, payload, () =>
sendToPopupWindow(win, IPC_CHANNELS.POPUP_SUGGESTION.UPDATE, {
...payload,
targetTotal: payload.targetTotal ?? payload.candidates.length,
_keyHints: buildSuggestionKeyHints()
})
)
}
}
export function hideSuggestionOverlay(): void {
suggestionOverlayPlacement = null
suggestionOverlayBounds = null
if (suggestionOverlayWindow && !suggestionOverlayWindow.isDestroyed()) {
sendToPopupWindow(suggestionOverlayWindow, IPC_CHANNELS.POPUP_SUGGESTION.HIDE, {})
suggestionOverlayWindow.hide()

View file

@ -0,0 +1,191 @@
// src/main/windows/popup-window-factory.ts
//
// 팝업 창 인프라 정본 — 생성(공통 옵션·보안·로드) · 수명주기(진단·로드 추적·테마 주입) ·
// 로드 전 IPC 보류 · 표시(present).
//
// 팝업 6종이 BrowserWindow 옵션/webPreferences/hardenWebContents/로드 분기/수명주기/closed
// 처리를 각자 복붙하던 시절 옵션이 조용히 갈라졌다 — ResultPopup 에만 backgroundThrottling
// 이 빠져 숨김 상태에서 2-phase 측정이 멈췄다. 창 종류는 이제 PopupWindowSpec 선언만 다르다.
import { BrowserWindow } from 'electron'
import { join } from 'path'
import { is } from '@electron-toolkit/utils'
import { buildPopupThemeCss } from '@d3ro/ui/theme-vars'
import { getLogger } from '../services/LoggerService'
import { configGet } from '../services/ConfigService'
import { hardenWebContents } from './web-contents-hardening'
const logger = getLogger('PopupWindow')
/** 팝업 창 한 종류의 선언. */
export interface PopupWindowSpec {
/** 진단 로그 이름이자 renderer/popups/<name>/index.html 경로 */
name: string
/** 초기 크기(와 선택적 위치) */
bounds: { width: number; height: number; x?: number; y?: number }
/**
* 숨김 상태에서 렌더러를 늦출지.
*
* 기본 false — 팝업은 숨김(show:false) 상태로 미리 만들어 두고 내용을 채운 뒤 보인다.
* 늦추면 숨김 창의 requestAnimationFrame·타이머가 멈춰 측정/첫 그리기가 오지 않는다.
* 항상 보이는 창(자막 오버레이)만 true 로 둔다.
*/
backgroundThrottling?: boolean
/** 로드 전에 적용할 창 설정 (클릭 통과 등) */
beforeLoad?: (win: BrowserWindow) => void
/** 창이 닫혔다 — 소유자가 참조를 지운다 */
onClosed: () => void
}
/** 스펙 → BrowserWindow 생성자 옵션 (순수 — 테스트가 옵션 동등성을 검증한다). */
export function buildPopupWindowOptions(
spec: PopupWindowSpec,
preloadPath: string
): Electron.BrowserWindowConstructorOptions {
return {
...spec.bounds,
show: false,
frame: false,
transparent: true,
resizable: false,
alwaysOnTop: true,
skipTaskbar: true,
focusable: false,
webPreferences: {
preload: preloadPath,
sandbox: false,
contextIsolation: true,
nodeIntegration: false,
backgroundThrottling: spec.backgroundThrottling ?? false
}
}
}
/** 팝업 창을 만들고 로드한다 — 보안 강화 · 수명주기 · closed 처리를 한 번에 붙인다. */
export function createPopupWindow(spec: PopupWindowSpec): BrowserWindow {
const win = new BrowserWindow(buildPopupWindowOptions(spec, join(__dirname, '../preload/popup.js')))
hardenWebContents(win)
spec.beforeLoad?.(win)
const page = `popups/${spec.name}/index.html`
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
void win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/${page}`)
} else {
void win.loadFile(join(__dirname, '../renderer', page))
}
attachPopupLifecycle(win, spec.name)
win.on('closed', spec.onClosed)
return win
}
// ── 테마 주입 ──────────────────────────────────────────────
/**
* 팝업 BrowserWindow에 현재 설정 테마의 CSS 변수를 insertCSS로 주입.
* did-finish-load 이후 호출해야 한다.
*/
export function injectPopupTheme(win: BrowserWindow): void {
const theme = configGet('theme') as string
// 'system'은 'dark'로 폴백 (팝업은 시스템 다크모드 감지 불가)
const resolvedTheme = theme === 'system' ? 'dark' : theme
const css = buildPopupThemeCss(resolvedTheme)
win.webContents.insertCSS(css).catch((err: unknown) => {
logger.warn('팝업 테마 CSS 주입 실패', err)
})
}
// ── 수명주기 · 로드 전 IPC 보류 ─────────────────────────────
/** Popup windows whose renderer finished loading and can receive IPC */
const popupReady = new WeakSet<BrowserWindow>()
/** IPC messages held back until the popup renderer is ready */
const pendingPopupMessages = new WeakMap<BrowserWindow, Array<{ channel: string; data: unknown }>>()
/**
* Promote popup renderer console output and load failures into the main log.
* Popups have no renderer logging otherwise, so a missing asset or a script
* exception stays invisible and the popup just renders static markup.
*/
function hookPopupDiagnostics(win: BrowserWindow, name: string): void {
win.webContents.on('console-message', (_event, level, message, line, sourceId) => {
logger.info(`[${name}] [Renderer] [${level}] ${message} (${sourceId}:${line})`)
})
win.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => {
logger.error(`[${name}] renderer load failed [${errorCode}] ${errorDescription} (${validatedURL})`)
})
win.webContents.on('render-process-gone', (_event, details) => {
logger.error(`[${name}] renderer process gone: ${details.reason} (exitCode=${details.exitCode})`)
})
}
/**
* Deliver IPC to a popup even while its renderer is still loading.
* webContents.send before load is silently dropped, which leaves popups stuck
* on their initial markup (for example a frozen 0:00 timer).
*/
export function sendToPopupWindow(win: BrowserWindow, channel: string, data: unknown): void {
if (win.isDestroyed() || win.webContents.isDestroyed()) return
if (popupReady.has(win)) {
win.webContents.send(channel, data)
return
}
const queue = pendingPopupMessages.get(win) ?? []
queue.push({ channel, data })
if (queue.length > 32) {
queue.splice(0, queue.length - 32)
}
pendingPopupMessages.set(win, queue)
}
function flushPopupMessages(win: BrowserWindow): void {
popupReady.add(win)
const queue = pendingPopupMessages.get(win)
if (!queue || queue.length === 0) return
pendingPopupMessages.delete(win)
for (const message of queue) {
if (!win.isDestroyed() && !win.webContents.isDestroyed()) {
win.webContents.send(message.channel, message.data)
}
}
}
/** Shared popup lifecycle: diagnostics + load tracking + theme injection */
function attachPopupLifecycle(win: BrowserWindow, name: string): void {
hookPopupDiagnostics(win, name)
win.webContents.on('did-start-loading', () => {
popupReady.delete(win)
})
win.webContents.on('did-finish-load', () => {
injectPopupTheme(win)
flushPopupMessages(win)
})
}
// ── 표시 ──────────────────────────────────────────────────
/**
* Show a popup without stealing focus, reliably on every call.
* After hide(), showInactive() can lose z-order and repaint, so popups stopped
* appearing from the second show onward: re-assert topmost and force a repaint.
*/
export function presentPopup(win: BrowserWindow, level?: 'floating' | 'screen-saver'): void {
if (!win.isVisible()) {
win.showInactive()
}
// Z-order/repaint adjustments must never break a capture session.
try {
win.setAlwaysOnTop(true, level)
win.moveTop()
win.webContents.invalidate()
} catch (err) {
logger.warn(`Popup present adjustment failed: ${err instanceof Error ? err.message : String(err)}`)
}
}

View file

@ -0,0 +1,46 @@
// src/main/windows/screen-geometry.ts
//
// ScreenGeometryPort 의 Electron 어댑터 — 좌표 공간 변환의 유일한 자리.
//
// UIA 사이드카는 DPI-aware(PerMonitor)로 동작해 캐럿/요소 rect 를 물리 픽셀로 준다.
// Electron screen API(커서 · 작업 영역 · setBounds)는 DIP 를 쓴다. 125%/150% 배율에서
// 둘을 섞으면 오버레이가 캐럿에서 멀리 떨어지거나 작업 영역 모서리에 붙는다.
// win32 는 screen.screenToDipRect 로 (rect 가 걸친 모니터의 배율로) 변환하고,
// 그 밖의 플랫폼은 네이티브 좌표가 곧 DIP 라 항등 변환이다.
import type { Screen } from 'electron'
import {
asDipPoint,
asDipRect,
type DipPoint,
type DipRect,
type PhysicalRect,
type ScreenGeometryPort
} from '@d3ro/core/popup-placement'
/** 어댑터가 쓰는 Electron screen 의 부분 집합 (테스트 대역이 이 모양만 흉내 내면 된다). */
export type ScreenApi = Pick<Screen, 'getCursorScreenPoint' | 'getDisplayNearestPoint'> &
Partial<Pick<Screen, 'screenToDipRect'>>
export function createElectronScreenGeometry(
screenApi: ScreenApi,
platform: NodeJS.Platform = process.platform
): ScreenGeometryPort {
const convert =
platform === 'win32' && typeof screenApi.screenToDipRect === 'function'
? screenApi.screenToDipRect.bind(screenApi)
: null
return {
toDip(rect: PhysicalRect): DipRect {
const plain = { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
return asDipRect(convert ? convert(null, plain) : plain)
},
workAreaNear(point: DipPoint): DipRect {
return asDipRect(screenApi.getDisplayNearestPoint({ x: point.x, y: point.y }).workArea)
},
cursor(): DipPoint {
return asDipPoint(screenApi.getCursorScreenPoint())
}
}
}

View file

@ -0,0 +1,60 @@
// src/main/windows/suggestion-overlay-geometry.ts
//
// 제안 오버레이 패널의 그려진 높이 — renderer/popups/suggestion-overlay/style.css 의 고정 치수와
// script.js 의 표시 규칙에서 나온다. 둘 중 하나를 바꾸면 이 파일도 함께 고친다.
//
// 창을 이 높이로 맞추는 이유: 투명 창이라도 창 영역은 클릭을 삼킨다. 고정 460x208 창은
// "준비 중" 한 줄(약 54px)만 보일 때도 캐럿 아래 150px 을 가려 그 아래 줄·버튼 클릭을 막았다.
export const SUGGESTION_OVERLAY_WIDTH = 460
/** script.js PAGE_SIZE — 한 페이지에 보이는 후보 수 */
const PAGE_SIZE = 3
// style.css 고정 치수 (px)
const ROOT_PADDING = 4 // #root padding
const PANEL_BORDER = 1 // .suggestion-panel border
const PANEL_PADDING = 6 // .suggestion-panel padding
const STATUS_HEIGHT = 32 // .status height
const ITEM_HEIGHT = 44 // .suggestion-item height
const ITEM_GAP = 2 // .candidates gap
/** .footer: margin-top 6 + border-top 1 + padding-top 6 + 행 14 + gap 5 + 키 안내 16 + padding-bottom 2 */
const FOOTER_HEIGHT = 6 + 1 + 6 + 14 + 5 + 16 + 2
const CHROME_HEIGHT = 2 * (ROOT_PADDING + PANEL_BORDER + PANEL_PADDING)
/** 한 페이지(3개) + 진행률/키 안내 — 가장 큰 패널이자 창 최대 높이 (= 208). */
export const SUGGESTION_OVERLAY_MAX_HEIGHT =
CHROME_HEIGHT + PAGE_SIZE * ITEM_HEIGHT + (PAGE_SIZE - 1) * ITEM_GAP + FOOTER_HEIGHT
export interface SuggestionPanelLayoutInput {
candidateCount: number
activeIndex: number
generating: boolean
warmingUp: boolean
/** 스트리밍 중인 부분 텍스트가 있는가 */
hasPartialText: boolean
}
/** 지금 그려지는 패널(+root 패딩) 높이 — script.js render() 와 같은 규칙으로 센다. */
export function suggestionPanelHeight(input: SuggestionPanelLayoutInput): number {
const count = Math.max(0, Math.floor(input.candidateCount))
const busy = input.warmingUp || (input.generating && count === 0)
let items = 0
if (count === 0) {
items = input.hasPartialText ? 1 : 0
} else {
const active = Math.min(Math.max(0, Math.floor(input.activeIndex)), count - 1)
const pageStart = Math.floor(active / PAGE_SIZE) * PAGE_SIZE
items = Math.min(PAGE_SIZE, count - pageStart)
}
const height =
CHROME_HEIGHT +
(busy ? STATUS_HEIGHT : 0) +
items * ITEM_HEIGHT +
Math.max(0, items - 1) * ITEM_GAP +
(count > 0 ? FOOTER_HEIGHT : 0)
return Math.min(height, SUGGESTION_OVERLAY_MAX_HEIGHT)
}

View file

@ -4,7 +4,10 @@
* 토큰이 주입되지 않는 상황(개발 초기 로드)을 위해 :root 폴백을 둔다.
*
* 구조: [후보 3개 — 고정 높이] / 가는 선 / [진행률 · 출처] / [키 안내]
* 창 높이(WindowManager SUGGESTION_OVERLAY_HEIGHT)는 이 고정 치수에서 나온다.
* 창 높이는 이 고정 치수에서 계산한다(main/windows/suggestion-overlay-geometry.ts) — 창을
* 그려진 패널 높이로 맞춰 투명 여백이 클릭을 삼키지 않게 한다. 높이에 영향을 주는 치수
* (#root/패널 패딩·테두리, .status, .suggestion-item, .candidates gap, .footer)를 바꾸면
* 그 파일도 함께 고친다. 각 줄은 내용과 무관하게 높이를 고정한다.
*/
:root {
@ -93,7 +96,7 @@ body {
display: flex;
align-items: center;
gap: 8px;
min-height: 32px;
height: 32px;
padding: 0 28px 0 8px;
}
@ -219,6 +222,7 @@ body {
.footer-row {
display: flex;
height: 14px;
align-items: center;
justify-content: space-between;
gap: 12px;
@ -252,6 +256,7 @@ body {
.key-hints {
display: flex;
height: 16px;
align-items: center;
gap: 10px;
white-space: nowrap;