fix(suggestion): place overlay in DIPs, size it to content, and harden suggestion sessions
This commit is contained in:
parent
75e053d72f
commit
9e5b94ced2
18 changed files with 2178 additions and 558 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ export interface SuggestionHistoryEntry {
|
|||
|
||||
export interface SuggestionRecordInput {
|
||||
appName: string | null
|
||||
/**
|
||||
* 입력 문맥(사용자가 친 글) — 호출자가 저장 정책(core shouldPersistSuggestionPrefix)을
|
||||
* 적용한 값이다. 학습 동의가 없거나 학습 제외 앱이면 빈 문자열이 온다. 저장소는 판단하지 않는다.
|
||||
*/
|
||||
prefix: string
|
||||
text: string
|
||||
model: string
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
||||
/** 테스트/진단 — 현재 앱이 제외 대상인지. */
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
191
apps/desktop/src/main/windows/popup-window-factory.ts
Normal file
191
apps/desktop/src/main/windows/popup-window-factory.ts
Normal 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)}`)
|
||||
}
|
||||
}
|
||||
46
apps/desktop/src/main/windows/screen-geometry.ts
Normal file
46
apps/desktop/src/main/windows/screen-geometry.ts
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
60
apps/desktop/src/main/windows/suggestion-overlay-geometry.ts
Normal file
60
apps/desktop/src/main/windows/suggestion-overlay-geometry.ts
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue