From 9e5b94ced2f187b198908c2ec6adf1bdac638683 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Mon, 28 Sep 2026 02:16:15 +0900 Subject: [PATCH] fix(suggestion): place overlay in DIPs, size it to content, and harden suggestion sessions --- .../main/services/InputTelemetryService.ts | 8 +- .../src/main/services/SuggestionRepository.ts | 4 + .../src/main/services/SuggestionService.ts | 86 ++- .../desktop/src/main/windows/WindowManager.ts | 626 +++++++----------- .../src/main/windows/popup-window-factory.ts | 191 ++++++ .../src/main/windows/screen-geometry.ts | 46 ++ .../windows/suggestion-overlay-geometry.ts | 60 ++ .../popups/suggestion-overlay/style.css | 9 +- .../services/suggestion-redteam-r1-1.test.ts | 5 +- .../services/suggestion-redteam-r2-3.test.ts | 335 ++++++++++ .../main/windows/popup-redteam-r2-3.test.ts | 382 +++++++++++ .../core/__tests__/popup-placement.test.ts | 150 +++++ .../__tests__/suggestion-redteam-r2-3.test.ts | 202 ++++++ packages/core/src/input-intelligence.ts | 189 ++---- packages/core/src/personal-graph.ts | 23 +- packages/core/src/popup-placement.ts | 269 ++++++++ packages/core/src/suggestion-text.ts | 86 ++- packages/core/src/text-segmentation.ts | 65 ++ 18 files changed, 2178 insertions(+), 558 deletions(-) create mode 100644 apps/desktop/src/main/windows/popup-window-factory.ts create mode 100644 apps/desktop/src/main/windows/screen-geometry.ts create mode 100644 apps/desktop/src/main/windows/suggestion-overlay-geometry.ts create mode 100644 apps/desktop/tests/main/services/suggestion-redteam-r2-3.test.ts create mode 100644 apps/desktop/tests/main/windows/popup-redteam-r2-3.test.ts create mode 100644 packages/core/__tests__/popup-placement.test.ts create mode 100644 packages/core/__tests__/suggestion-redteam-r2-3.test.ts create mode 100644 packages/core/src/popup-placement.ts create mode 100644 packages/core/src/text-segmentation.ts diff --git a/apps/desktop/src/main/services/InputTelemetryService.ts b/apps/desktop/src/main/services/InputTelemetryService.ts index f5b742f..03b7f91 100644 --- a/apps/desktop/src/main/services/InputTelemetryService.ts +++ b/apps/desktop/src/main/services/InputTelemetryService.ts @@ -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 diff --git a/apps/desktop/src/main/services/SuggestionRepository.ts b/apps/desktop/src/main/services/SuggestionRepository.ts index 683c689..e8812b9 100644 --- a/apps/desktop/src/main/services/SuggestionRepository.ts +++ b/apps/desktop/src/main/services/SuggestionRepository.ts @@ -28,6 +28,10 @@ export interface SuggestionHistoryEntry { export interface SuggestionRecordInput { appName: string | null + /** + * 입력 문맥(사용자가 친 글) — 호출자가 저장 정책(core shouldPersistSuggestionPrefix)을 + * 적용한 값이다. 학습 동의가 없거나 학습 제외 앱이면 빈 문자열이 온다. 저장소는 판단하지 않는다. + */ prefix: string text: string model: string diff --git a/apps/desktop/src/main/services/SuggestionService.ts b/apps/desktop/src/main/services/SuggestionService.ts index c16c6e0..ab89c3b 100644 --- a/apps/desktop/src/main/services/SuggestionService.ts +++ b/apps/desktop/src/main/services/SuggestionService.ts @@ -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): 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 }) } /** 테스트/진단 — 현재 앱이 제외 대상인지. */ diff --git a/apps/desktop/src/main/windows/WindowManager.ts b/apps/desktop/src/main/windows/WindowManager.ts index 9082240..736680c 100644 --- a/apps/desktop/src/main/windows/WindowManager.ts +++ b/apps/desktop/src/main/windows/WindowManager.ts @@ -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() -/** IPC messages held back until the popup renderer is ready */ -const pendingPopupMessages = new WeakMap>() +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 + 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>): 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>, 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() diff --git a/apps/desktop/src/main/windows/popup-window-factory.ts b/apps/desktop/src/main/windows/popup-window-factory.ts new file mode 100644 index 0000000..7c61cc9 --- /dev/null +++ b/apps/desktop/src/main/windows/popup-window-factory.ts @@ -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//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() +/** IPC messages held back until the popup renderer is ready */ +const pendingPopupMessages = new WeakMap>() + +/** + * 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)}`) + } +} diff --git a/apps/desktop/src/main/windows/screen-geometry.ts b/apps/desktop/src/main/windows/screen-geometry.ts new file mode 100644 index 0000000..b9e2aa7 --- /dev/null +++ b/apps/desktop/src/main/windows/screen-geometry.ts @@ -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 & + Partial> + +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()) + } + } +} diff --git a/apps/desktop/src/main/windows/suggestion-overlay-geometry.ts b/apps/desktop/src/main/windows/suggestion-overlay-geometry.ts new file mode 100644 index 0000000..3fba2c1 --- /dev/null +++ b/apps/desktop/src/main/windows/suggestion-overlay-geometry.ts @@ -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) +} diff --git a/apps/desktop/src/renderer/popups/suggestion-overlay/style.css b/apps/desktop/src/renderer/popups/suggestion-overlay/style.css index 42d8d8e..5361d4d 100644 --- a/apps/desktop/src/renderer/popups/suggestion-overlay/style.css +++ b/apps/desktop/src/renderer/popups/suggestion-overlay/style.css @@ -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; diff --git a/apps/desktop/tests/main/services/suggestion-redteam-r1-1.test.ts b/apps/desktop/tests/main/services/suggestion-redteam-r1-1.test.ts index 36f29cd..9030556 100644 --- a/apps/desktop/tests/main/services/suggestion-redteam-r1-1.test.ts +++ b/apps/desktop/tests/main/services/suggestion-redteam-r1-1.test.ts @@ -324,8 +324,9 @@ describe('버그 6: 수락 직전 대상 창 확인', () => { const result = await service.accept() expect(result).toEqual({ ok: true }) - expect(harness.insertText).toHaveBeenCalledWith('다음 단계도 확인하겠습니다.', 'clipboard') - expect(fake.expectInsert).toHaveBeenCalledWith('다음 단계도 확인하겠습니다.') + // 접두('…정리해서')와 후보 사이에 구분 공백을 붙여 넣는다(joinSuggestion). + expect(harness.insertText).toHaveBeenCalledWith(' 다음 단계도 확인하겠습니다.', 'clipboard') + expect(fake.expectInsert).toHaveBeenCalledWith(' 다음 단계도 확인하겠습니다.') expect(fake.recordAccepted).toHaveBeenCalledTimes(1) expect(fake.cancelInsert).not.toHaveBeenCalled() }) diff --git a/apps/desktop/tests/main/services/suggestion-redteam-r2-3.test.ts b/apps/desktop/tests/main/services/suggestion-redteam-r2-3.test.ts new file mode 100644 index 0000000..dad13b8 --- /dev/null +++ b/apps/desktop/tests/main/services/suggestion-redteam-r2-3.test.ts @@ -0,0 +1,335 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { PersonalPhrase } from '@d3ro/core/input-intelligence' + +const config = vi.hoisted(() => ({ + suggestionEnabled: true as boolean, + suggestionModelId: 'model-a' as string | null, + llmModelId: 'model-a' as string | null, + inputExcludedApps: [] as string[], + suggestionTriggerDelayMs: 600, + suggestionMinPrefixChars: 8, + suggestionMaxRequestsPerMinute: 12, + suggestionDailyBudget: 500, + suggestionRequestTimeoutMs: 8000, + inputLearnTypedText: false as boolean, + inputTelemetryEnabled: false, + suggestionOverlayInteractive: true, + insertMethod: 'clipboard' +})) +const harness = vi.hoisted(() => ({ + isAvailable: vi.fn(() => true), + streamGenerate: vi.fn(), + insertText: vi.fn(async (text: string) => ({ success: true, method: 'clipboard', textLength: text.length })), + phrases: [] as PersonalPhrase[] +})) + +vi.mock('../../../src/main/services/ConfigService', () => ({ + configGet: vi.fn((key: keyof typeof config) => config[key]), + configSet: vi.fn() +})) +vi.mock('../../../src/main/services/LoggerService', () => ({ + getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }) +})) +vi.mock('../../../src/main/services/LocalLLMService', () => ({ + getLocalLLMService: () => ({ isAvailable: harness.isAvailable, streamGenerate: harness.streamGenerate }) +})) +vi.mock('../../../src/main/services/InputTelemetryService', () => ({ + getInputTelemetryService: () => ({ listPhrases: () => harness.phrases }) +})) +vi.mock('../../../src/main/services/PersonalGraphService', () => ({ + getPersonalGraphService: () => ({ retrieveContext: () => ({ continuations: [], related: [] }) }) +})) +vi.mock('../../../src/main/services/TextInsertService', () => ({ + getTextInsertService: () => ({ insertText: harness.insertText }) +})) +vi.mock('../../../src/main/services/modifier-state', () => ({ waitForModifiersReleased: async () => true })) +vi.mock('../../../src/main/utils/win32-foreground', () => ({ getForegroundWindowInfo: () => null })) +vi.mock('../../../src/main/db', () => ({ getDatabase: vi.fn() })) +vi.mock('../../../src/main/db/schema', () => ({ suggestions: {} })) + +import { SuggestionService, type SuggestionServiceDeps } from '../../../src/main/services/SuggestionService' +import { SuggestionBudget } from '../../../src/main/services/suggestion/SuggestionBudget' +import { ModelWarmTracker } from '../../../src/main/services/suggestion/ModelWarmTracker' +import type { TypingContext } from '../../../src/main/services/InputTelemetryService' + +const PREFIX = '오늘 회의에서 논의한 내용을 정리해서' +const SUGGESTION = '다음 단계도 확인하겠습니다.' + +function typingContext(overrides: Partial = {}): TypingContext { + const prefix = overrides.prefix ?? PREFIX + return { + prefix, + fullText: prefix, + caretOffset: prefix.length, + anchor: { x: 100, y: 100, width: 2, height: 20 }, + anchorKind: 'caret', + isPassword: false, + isEditable: true, + isComposing: false, + hasSelection: false, + available: true, + appName: 'Slack.exe', + windowTitle: 'general', + windowHandle: 101, + idleMs: 2000, + capturedAt: Date.now(), + editedSinceFocus: true, + typedRecently: true, + ...overrides + } +} + +interface Fakes { + deps: SuggestionServiceDeps + record: ReturnType + markAccepted: ReturnType + expectInsert: ReturnType + recordAccepted: ReturnType +} + +function fakes(): Fakes { + const record = vi.fn() + const markAccepted = vi.fn() + const expectInsert = vi.fn(() => vi.fn()) + const recordAccepted = vi.fn() + return { + record, + markAccepted, + expectInsert, + recordAccepted, + deps: { + repository: { record, markAccepted, list: vi.fn(() => []) }, + budget: new SuggestionBudget(), + warmth: new ModelWarmTracker(), + foreground: { currentWindowHandle: () => 101 }, + learning: { expectProgrammaticInsert: expectInsert, recordAccepted } + } + } +} + +function streamOf(text: string): AsyncGenerator { + return (async function* () { + yield text + })() +} + +/** 호출자가 풀어 줄 때까지 기다리는 스트림 — abort 되면 예외로 끝난다. */ +function gatedStream(signal: AbortSignal | undefined, gate: Promise): AsyncGenerator { + return (async function* () { + const text = await new Promise((resolve, reject) => { + signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true }) + void gate.then(resolve) + }) + yield text + })() +} + +interface Internal { + _generate(prefix: string, context: TypingContext, maxCandidates: number, maxChars: number): Promise + _lastContext: TypingContext | null + _inFlight: boolean + _warmUpPromise: Promise | null +} + +let services: SuggestionService[] = [] +function create(deps: SuggestionServiceDeps, warm = true): SuggestionService { + if (warm) deps.warmth.noteWarm('model-a', Date.now() + 600_000) + const service = new SuggestionService(deps) + services.push(service) + return service +} + +beforeEach(() => { + config.suggestionEnabled = true + config.inputLearnTypedText = false + harness.isAvailable.mockReset() + harness.isAvailable.mockReturnValue(true) + harness.streamGenerate.mockReset() + harness.insertText.mockClear() + harness.phrases = [] +}) + +afterEach(() => { + for (const service of services) service.dispose() + services = [] +}) + +describe('진행 중 생성 — 요청 뒤 이어 치면 즉시 취소한다 (세션 규칙과 동일)', () => { + it('12자 미만 성장도 진행 중 요청을 취소하고 예산을 환급한다', async () => { + let signal: AbortSignal | undefined + let release: (text: string) => void = () => undefined + const gate = new Promise((resolve) => { + release = resolve + }) + harness.streamGenerate.mockImplementation((_text: string, options: { signal?: AbortSignal }) => { + signal = options.signal + return gatedStream(options.signal, gate) + }) + const fake = fakes() + const service = create(fake.deps) + const internal = service as unknown as Internal + + service.handleTypingContext(typingContext({ prefix: '회의 자료는 오늘' })) + expect(internal._inFlight).toBe(true) + + service.handleTypingContext(typingContext({ prefix: '회의 자료는 오늘 저녁까지', idleMs: 100 })) + release(SUGGESTION) + await Promise.resolve() + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(signal?.aborted).toBe(true) + expect(service.isVisible).toBe(false) + expect(fake.deps.budget.minuteCount).toBe(0) + expect(fake.record).not.toHaveBeenCalled() + }) + + it('결과 게시 직전 최신 문맥이 자랐으면 옛 접두용 결과를 게시하지 않는다', async () => { + let release: (text: string) => void = () => undefined + const gate = new Promise((resolve) => { + release = resolve + }) + harness.streamGenerate.mockImplementation((_text: string, options: { signal?: AbortSignal }) => + gatedStream(options.signal, gate) + ) + const fake = fakes() + const service = create(fake.deps) + const internal = service as unknown as Internal + const context = typingContext({ prefix: '회의 자료는 오늘' }) + + const generation = internal._generate('회의 자료는 오늘', context, 3, 160) + // 취소 경로를 거치지 않고 최신 문맥만 바뀐 경우 (게시 가드만 검증) + internal._lastContext = typingContext({ prefix: '회의 자료는 오늘 저녁까지' }) + release(SUGGESTION) + await generation + + expect(service.isVisible).toBe(false) + expect(service.getState().lastSkipReason).toBe('stale') + expect(fake.record).not.toHaveBeenCalled() + }) + + it('같은 접두(주기 스냅샷)면 그대로 게시한다', async () => { + harness.streamGenerate.mockImplementation(() => streamOf(SUGGESTION)) + const fake = fakes() + const service = create(fake.deps) + const internal = service as unknown as Internal + internal._lastContext = typingContext() + await internal._generate(PREFIX, typingContext(), 3, 160) + expect(service.isVisible).toBe(true) + }) +}) + +describe('수락 — 구분 공백', () => { + async function visibleModelSession(fake: Fakes): Promise { + harness.streamGenerate.mockImplementation(() => streamOf(SUGGESTION)) + const service = create(fake.deps) + await (service as unknown as Internal)._generate(PREFIX, typingContext(), 3, 160) + expect(service.isVisible).toBe(true) + return service + } + + it('단어 바로 뒤에서 멈췄으면 공백 하나를 앞에 붙여 삽입한다', async () => { + const fake = fakes() + const service = await visibleModelSession(fake) + + expect(await service.accept()).toEqual({ ok: true }) + + expect(harness.insertText).toHaveBeenCalledWith(` ${SUGGESTION}`, 'clipboard') + expect(fake.expectInsert).toHaveBeenCalledWith(` ${SUGGESTION}`) + // 이력·학습은 후보 원문으로 — 이력 행을 찾을 수 있어야 한다. + expect(fake.markAccepted).toHaveBeenCalledWith(SUGGESTION) + expect(fake.recordAccepted).toHaveBeenCalledWith(SUGGESTION, expect.anything()) + }) + + it('영어 문장 끝 마침표 뒤에도 떼어 쓴다', async () => { + harness.streamGenerate.mockImplementation(() => streamOf('Let me know if you have questions.')) + const fake = fakes() + const service = create(fake.deps) + const context = typingContext({ prefix: 'I sent the report.' }) + await (service as unknown as Internal)._generate('I sent the report.', context, 3, 160) + + await service.accept() + expect(harness.insertText).toHaveBeenCalledWith(' Let me know if you have questions.', 'clipboard') + }) + + it('제안 뒤 사용자가 이미 공백을 쳤으면(최신 문맥) 공백을 더하지 않는다', async () => { + const fake = fakes() + const service = await visibleModelSession(fake) + service.handleTypingContext(typingContext({ prefix: `${PREFIX} `, idleMs: 100 })) + expect(service.isVisible).toBe(true) + + await service.accept() + expect(harness.insertText).toHaveBeenCalledWith(SUGGESTION, 'clipboard') + }) + + it('단어 중간에서 이어지는 로컬 기억 후보는 공백 없이 붙인다', async () => { + harness.phrases = [ + { + id: 'p1', + phrase: 'Thank you for the update', + count: 3, + source: 'typed', + appName: 'Slack.exe', + lastUsedAt: Date.now(), + createdAt: Date.now() + } + ] + const fake = fakes() + // 쿨다운 중에는 로컬 기억만 게시한다. + fake.deps.budget.noteFailure(Date.now()) + fake.deps.budget.noteFailure(Date.now()) + const service = create(fake.deps) + service.handleTypingContext(typingContext({ prefix: 'Thank yo' })) + expect(service.getState().candidates[0]?.text).toBe('u for the update') + + await service.accept() + expect(harness.insertText).toHaveBeenCalledWith('u for the update', 'clipboard') + }) +}) + +describe('모델 서버가 없을 때 — 끝나지 않는 "준비 중" 을 띄우지 않는다', () => { + it('준비 작업(워밍업)이 없으면 오버레이 없이 사유만 남긴다', () => { + harness.isAvailable.mockReturnValue(false) + const fake = fakes() + const service = create(fake.deps) + const updates: boolean[] = [] + service.on('updated', (state) => updates.push(state.warmingUp)) + + service.handleTypingContext(typingContext()) + + expect(service.getState()).toMatchObject({ warmingUp: false, lastSkipReason: 'model-unavailable' }) + expect(service.isPresentationActive).toBe(false) + expect(updates).not.toContain(true) + // 이어 쳐도 already-visible 로 굳지 않는다. + service.handleTypingContext(typingContext({ prefix: `${PREFIX} 공유`, idleMs: 2000 })) + expect(service.isPresentationActive).toBe(false) + }) +}) + +describe('제안 이력 — 입력 원문은 학습 동의 규칙을 따른다', () => { + async function publish(fake: Fakes, context: TypingContext): Promise { + harness.streamGenerate.mockImplementation(() => streamOf(SUGGESTION)) + const service = create(fake.deps) + await (service as unknown as Internal)._generate(context.prefix, context, 3, 160) + expect(service.isVisible).toBe(true) + } + + it('학습 동의가 없으면 접두 원문을 저장하지 않는다', async () => { + const fake = fakes() + await publish(fake, typingContext()) + expect(fake.record).toHaveBeenCalledWith(expect.objectContaining({ prefix: '', text: SUGGESTION, appName: 'Slack.exe' })) + }) + + it('동의가 있어도 터미널(학습 제외 앱)의 명령 줄은 저장하지 않는다', async () => { + config.inputLearnTypedText = true + const fake = fakes() + await publish(fake, typingContext({ prefix: 'mysql -u root -pS3cret prod', appName: 'WindowsTerminal.exe' })) + expect(fake.record).toHaveBeenCalledWith(expect.objectContaining({ prefix: '' })) + }) + + it('동의가 있고 일반 앱이면 접두를 저장한다', async () => { + config.inputLearnTypedText = true + const fake = fakes() + await publish(fake, typingContext()) + expect(fake.record).toHaveBeenCalledWith(expect.objectContaining({ prefix: PREFIX })) + }) +}) diff --git a/apps/desktop/tests/main/windows/popup-redteam-r2-3.test.ts b/apps/desktop/tests/main/windows/popup-redteam-r2-3.test.ts new file mode 100644 index 0000000..fe6f83e --- /dev/null +++ b/apps/desktop/tests/main/windows/popup-redteam-r2-3.test.ts @@ -0,0 +1,382 @@ +// 팝업 창 배치·수명주기 회귀 (redteam r2-3) +// - 제안 오버레이: UIA 물리 픽셀 앵커를 DIP 로 바꿔 배치한다 (150% 배율) +// - 제안 오버레이: 창을 그려진 패널 높이로 줄여 투명 여백이 클릭을 삼키지 않는다 +// - 결과 팝업: 숨긴 창의 측정(rAF)이 오지 않아도 뜨고, 측정 리스너를 쌓지 않는다 +// - 팝업 팩토리: 측정/숨김 렌더가 필요한 팝업은 backgroundThrottling:false + +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +type Listener = (...args: unknown[]) => void + +interface FakeWebContents { + listeners: Map + sent: Array<{ channel: string; data: unknown }> + on: (event: string, listener: Listener) => void + send: (channel: string, data: unknown) => void + isDestroyed: () => boolean + insertCSS: () => Promise + invalidate: () => void + setWindowOpenHandler: () => void +} + +interface FakeWindow { + options: Record + webContents: FakeWebContents + bounds: Array<{ x: number; y: number; width: number; height: number }> + visible: boolean + showInactive: ReturnType + setIgnoreMouseEvents: ReturnType +} + +const state = vi.hoisted(() => ({ + windows: [] as FakeWindow[], + ipcOn: new Map(), + scale: 1.5 +})) + +vi.unmock('../../../src/main/windows/WindowManager') + +vi.mock('electron', () => { + class FakeBrowserWindow { + options: Record + bounds: Array<{ x: number; y: number; width: number; height: number }> = [] + visible = false + destroyed = false + windowListeners = new Map() + webContents: FakeWebContents = { + listeners: new Map(), + sent: [], + on(event: string, listener: Listener) { + const list = this.listeners.get(event) ?? [] + list.push(listener) + this.listeners.set(event, list) + }, + send(channel: string, data: unknown) { + this.sent.push({ channel, data }) + }, + isDestroyed: () => false, + insertCSS: async () => '', + invalidate: () => undefined, + setWindowOpenHandler: () => undefined + } + showInactive = vi.fn(() => { + this.visible = true + }) + setIgnoreMouseEvents = vi.fn() + constructor(options: Record) { + this.options = options + state.windows.push(this as unknown as FakeWindow) + } + on(event: string, listener: Listener): this { + this.windowListeners.set(event, listener) + return this + } + setBounds(bounds: { x: number; y: number; width: number; height: number }): void { + this.bounds.push(bounds) + } + isVisible(): boolean { + return this.visible + } + isDestroyed(): boolean { + return this.destroyed + } + hide(): void { + this.visible = false + } + setAlwaysOnTop = vi.fn() + moveTop = vi.fn() + loadURL = vi.fn(async () => undefined) + loadFile = vi.fn(async () => undefined) + } + + const scaled = (value: number): number => Math.round(value / state.scale) + return { + app: { quit: vi.fn(), getPath: vi.fn(() => '.'), on: vi.fn(), getVersion: vi.fn(() => '1.0.0'), isPackaged: false }, + BrowserWindow: Object.assign(FakeBrowserWindow, { getAllWindows: vi.fn(() => []) }), + screen: { + getPrimaryDisplay: vi.fn(() => ({ + workAreaSize: { width: 1280, height: 680 }, + workArea: { x: 0, y: 0, width: 1280, height: 680 } + })), + getAllDisplays: vi.fn(() => [{ workArea: { x: 0, y: 0, width: 1280, height: 680 } }]), + getCursorScreenPoint: vi.fn(() => ({ x: 640, y: 400 })), + // 150% 모니터 하나: 물리 1920x1080 → DIP 1280x720 (작업 영역 높이 680) + getDisplayNearestPoint: vi.fn(() => ({ workArea: { x: 0, y: 0, width: 1280, height: 680 } })), + screenToDipRect: vi.fn((_win: unknown, rect: { x: number; y: number; width: number; height: number }) => ({ + x: scaled(rect.x), + y: scaled(rect.y), + width: scaled(rect.width), + height: scaled(rect.height) + })) + }, + ipcMain: { + on: vi.fn((channel: string, listener: Listener) => { + const list = state.ipcOn.get(channel) ?? [] + list.push(listener) + state.ipcOn.set(channel, list) + }), + once: vi.fn(), + handle: vi.fn(), + removeHandler: vi.fn() + }, + Menu: { setApplicationMenu: vi.fn(), buildFromTemplate: vi.fn() }, + clipboard: { writeText: vi.fn() }, + shell: { openExternal: vi.fn(async () => undefined) }, + safeStorage: { isEncryptionAvailable: vi.fn(() => false) }, + nativeImage: { createFromPath: vi.fn(), createEmpty: vi.fn() }, + Tray: vi.fn(), + dialog: { showErrorBox: vi.fn() } + } +}) + +vi.mock('@electron-toolkit/utils', () => ({ is: { dev: false } })) + +const config: Record = { + theme: 'dark', + language: 'ko', + suggestionOverlayInteractive: true, + keyBindings: {} +} +vi.mock('../../../src/main/services/ConfigService', () => ({ + configGet: (key: string) => config[key], + configSet: (key: string, value: unknown) => { + config[key] = value + } +})) + +const originalPlatform = process.platform + +beforeAll(() => { + // 어댑터는 win32 에서만 screenToDipRect 를 쓴다 — CI(리눅스)에서도 같은 경로를 검증한다. + Object.defineProperty(process, 'platform', { value: 'win32' }) +}) + +afterAll(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }) +}) + +beforeEach(() => { + vi.resetModules() + state.windows.length = 0 + state.ipcOn.clear() + state.scale = 1.5 +}) + +afterEach(() => { + vi.useRealTimers() +}) + +function finishLoad(win: FakeWindow): void { + for (const listener of win.webContents.listeners.get('did-finish-load') ?? []) listener() +} + +function sentOn(win: FakeWindow, channel: string): unknown[] { + return win.webContents.sent.filter((message) => message.channel === channel).map((message) => message.data) +} + +describe('제안 오버레이 — 물리 픽셀 앵커를 DIP 로 배치한다', () => { + const content = { candidates: [], activeIndex: 0, generating: true, warmingUp: false, partialText: null } + + it('150% 에서 캐럿 물리 (600,400) → DIP (400,267) 바로 아래, 창은 "생성 중" 한 줄 높이(54)', async () => { + const wm = await import('../../../src/main/windows/WindowManager') + wm.showSuggestionOverlay({ + ...content, + anchor: { x: 600, y: 400, width: 2, height: 30 }, + anchorKind: 'caret', + appName: 'notepad.exe' + }) + const win = state.windows[0] + expect(win.bounds.at(-1)).toEqual({ x: 400, y: 267 + 20 + 6, width: 460, height: 54 }) + }) + + it('150% 에서 캐럿 물리 (1500,900) 은 작업 영역 구석에 박히지 않고 캐럿 위로 뒤집혀 캐럿 곁에 붙는다', async () => { + const wm = await import('../../../src/main/windows/WindowManager') + wm.showSuggestionOverlay({ + ...content, + anchor: { x: 1500, y: 900, width: 2, height: 30 }, + anchorKind: 'caret', + appName: 'notepad.exe' + }) + const bounds = state.windows[0].bounds.at(-1) + // DIP 캐럿 (1000,600) — 창 아래 모서리가 캐럿 위 6px 에 붙는다 + expect(bounds).toEqual({ x: 820, y: 600 - 6 - 54, width: 460, height: 54 }) + }) + + it('후보가 도착하면 같은 자리에서 높이만 늘고, 창이 그려진 패널보다 크지 않다', async () => { + const wm = await import('../../../src/main/windows/WindowManager') + wm.showSuggestionOverlay({ + ...content, + anchor: { x: 600, y: 400, width: 2, height: 30 }, + anchorKind: 'caret', + appName: 'notepad.exe' + }) + const win = state.windows[0] + win.visible = true + wm.updateSuggestionOverlay({ + candidates: [{ text: '다음 문장', rank: 0 }], + activeIndex: 0, + generating: true, + warmingUp: false, + partialText: null + }) + expect(win.bounds.at(-1)).toEqual({ x: 400, y: 293, width: 460, height: 116 }) + + // 같은 높이의 스트리밍 갱신은 창을 다시 옮기지 않는다 + const count = win.bounds.length + wm.updateSuggestionOverlay({ + candidates: [{ text: '다음 문장', rank: 0 }], + activeIndex: 0, + generating: true, + warmingUp: false, + partialText: null + }) + expect(win.bounds.length).toBe(count) + }) +}) + +describe('결과 팝업 — 숨긴 창에서 측정이 오지 않아도 뜬다', () => { + it('측정 리스너는 모듈에 하나뿐이고, 표시 호출마다 쌓이지 않는다', async () => { + const wm = await import('../../../src/main/windows/WindowManager') + const { IPC_CHANNELS } = await import('@d3ro/core/ipc-channels') + wm.showResultPopup('첫 번째') + wm.showResultPopup('두 번째') + wm.showResultPopup('세 번째') + expect(state.ipcOn.get(IPC_CHANNELS.POPUP_RESULT.MEASURED)).toHaveLength(1) + }) + + it('측정 없이도 창을 먼저 띄우고, 한도가 지나면 내용을 보인다 (두 번째 표시 포함)', async () => { + vi.useFakeTimers() + const wm = await import('../../../src/main/windows/WindowManager') + const { IPC_CHANNELS } = await import('@d3ro/core/ipc-channels') + + wm.showResultPopup('받아쓴 텍스트') + const win = state.windows[0] + finishLoad(win) + expect(win.visible).toBe(true) + vi.advanceTimersByTime(400) + expect(sentOn(win, IPC_CHANNELS.POPUP_RESULT.SHOW)).toHaveLength(1) + + // 자동 닫힘 뒤 두 번째 실패 — 렌더러가 측정을 보내지 않아도 다시 뜬다. + wm.hideResultPopup() + expect(win.visible).toBe(false) + wm.showResultPopup('다시 받아쓴 텍스트') + expect(win.visible).toBe(true) + vi.advanceTimersByTime(400) + expect(sentOn(win, IPC_CHANNELS.POPUP_RESULT.SHOW)).toHaveLength(2) + }) + + it('측정이 오면 그 크기로 커서 곁에 맞추고 바로 보인다', async () => { + vi.useFakeTimers() + const wm = await import('../../../src/main/windows/WindowManager') + const { IPC_CHANNELS } = await import('@d3ro/core/ipc-channels') + + wm.showResultPopup('받아쓴 텍스트') + const win = state.windows[0] + finishLoad(win) + const measured = state.ipcOn.get(IPC_CHANNELS.POPUP_RESULT.MEASURED)?.[0] + measured?.({ sender: win.webContents }, { width: 300, height: 120 }) + + // 커서 (640,400): 가로 가운데, 위 20px + expect(win.bounds.at(-1)).toEqual({ x: 490, y: 260, width: 300, height: 120 }) + expect(sentOn(win, IPC_CHANNELS.POPUP_RESULT.SHOW)).toEqual([{ autoHideMs: 5000 }]) + // 한도가 지나도 두 번 보이지 않는다 + vi.advanceTimersByTime(400) + expect(sentOn(win, IPC_CHANNELS.POPUP_RESULT.SHOW)).toHaveLength(1) + }) + + it('다른 창이 보낸 측정은 무시한다', async () => { + vi.useFakeTimers() + const wm = await import('../../../src/main/windows/WindowManager') + const { IPC_CHANNELS } = await import('@d3ro/core/ipc-channels') + + wm.showResultPopup('받아쓴 텍스트') + const win = state.windows[0] + finishLoad(win) + state.ipcOn.get(IPC_CHANNELS.POPUP_RESULT.MEASURED)?.[0]?.({ sender: {} }, { width: 300, height: 120 }) + expect(sentOn(win, IPC_CHANNELS.POPUP_RESULT.SHOW)).toHaveLength(0) + }) +}) + +describe('팝업 팩토리 — 공통 옵션', () => { + it('측정/숨김 렌더가 필요한 팝업은 backgroundThrottling:false, 항상 보이는 자막 창만 기존값', async () => { + const wm = await import('../../../src/main/windows/WindowManager') + wm.getResultPopupWindow() + wm.getHistoryPopupWindow() + wm.getCommandPopupWindow() + wm.getRecordingTipWindow() + wm.getSuggestionOverlayWindow() + wm.getCaptionOverlayWindow() + + const throttling = state.windows.map( + (win) => (win.options.webPreferences as { backgroundThrottling?: boolean }).backgroundThrottling + ) + expect(throttling).toEqual([false, false, false, false, false, true]) + for (const win of state.windows) { + expect(win.options).toMatchObject({ show: false, frame: false, transparent: true, focusable: false, alwaysOnTop: true }) + expect(win.options.webPreferences).toMatchObject({ contextIsolation: true, nodeIntegration: false }) + } + // 자막 창은 로드 전에 클릭 통과 + expect(state.windows[5].setIgnoreMouseEvents).toHaveBeenCalledWith(true, { forward: true }) + }) + + it('closed 이벤트면 창 참조를 지워 다음 호출에서 다시 만든다', async () => { + const wm = await import('../../../src/main/windows/WindowManager') + const first = wm.getResultPopupWindow() as unknown as { destroyed: boolean; windowListeners: Map } + first.destroyed = true + first.windowListeners.get('closed')?.() + wm.getResultPopupWindow() + expect(state.windows).toHaveLength(2) + }) +}) + +describe('suggestionPanelHeight — style.css 고정 치수와 같은 규칙', () => { + it('상태별 패널 높이', async () => { + const { suggestionPanelHeight, SUGGESTION_OVERLAY_MAX_HEIGHT } = await import( + '../../../src/main/windows/suggestion-overlay-geometry' + ) + const base = { candidateCount: 0, activeIndex: 0, generating: false, warmingUp: false, hasPartialText: false } + expect(SUGGESTION_OVERLAY_MAX_HEIGHT).toBe(208) + expect(suggestionPanelHeight({ ...base, warmingUp: true })).toBe(54) + expect(suggestionPanelHeight({ ...base, generating: true })).toBe(54) + expect(suggestionPanelHeight({ ...base, generating: true, hasPartialText: true })).toBe(98) + expect(suggestionPanelHeight({ ...base, candidateCount: 1, generating: true })).toBe(116) + expect(suggestionPanelHeight({ ...base, candidateCount: 2 })).toBe(162) + expect(suggestionPanelHeight({ ...base, candidateCount: 3 })).toBe(208) + // 두 번째 페이지에 후보 1개 + expect(suggestionPanelHeight({ ...base, candidateCount: 4, activeIndex: 3 })).toBe(116) + expect(suggestionPanelHeight({ ...base, candidateCount: 12, activeIndex: 5 })).toBe(208) + }) +}) + +describe('createElectronScreenGeometry — 좌표 공간 변환은 어댑터 한 곳에서', () => { + function fakeScreen() { + return { + getCursorScreenPoint: vi.fn(() => ({ x: 10, y: 20 })), + getDisplayNearestPoint: vi.fn(() => ({ workArea: { x: 0, y: 0, width: 1280, height: 680 } })), + screenToDipRect: vi.fn((_win: unknown, rect: { x: number; y: number; width: number; height: number }) => ({ + x: rect.x / 1.5, + y: rect.y / 1.5, + width: rect.width / 1.5, + height: rect.height / 1.5 + })) + } + } + + it('win32 은 screenToDipRect 로 변환한다', async () => { + const { createElectronScreenGeometry } = await import('../../../src/main/windows/screen-geometry') + const screenApi = fakeScreen() + const port = createElectronScreenGeometry(screenApi as never, 'win32') + expect(port.toDip({ x: 600, y: 300, width: 3, height: 30 })).toEqual({ x: 400, y: 200, width: 2, height: 20 }) + expect(screenApi.screenToDipRect).toHaveBeenCalledWith(null, { x: 600, y: 300, width: 3, height: 30 }) + }) + + it('그 밖의 플랫폼은 항등 변환이다 (네이티브 좌표 = DIP)', async () => { + const { createElectronScreenGeometry } = await import('../../../src/main/windows/screen-geometry') + const screenApi = fakeScreen() + const port = createElectronScreenGeometry(screenApi as never, 'darwin') + expect(port.toDip({ x: 600, y: 300, width: 3, height: 30 })).toEqual({ x: 600, y: 300, width: 3, height: 30 }) + expect(screenApi.screenToDipRect).not.toHaveBeenCalled() + expect(port.cursor()).toEqual({ x: 10, y: 20 }) + expect(port.workAreaNear(port.cursor())).toEqual({ x: 0, y: 0, width: 1280, height: 680 }) + }) +}) diff --git a/packages/core/__tests__/popup-placement.test.ts b/packages/core/__tests__/popup-placement.test.ts new file mode 100644 index 0000000..778038d --- /dev/null +++ b/packages/core/__tests__/popup-placement.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from 'vitest' +import { + anchorFloatingPanel, + asDipPoint, + asDipRect, + fitAnchoredPanel, + placeAnchoredPanel, + placeNearCursor, + type DipPoint, + type DipRect, + type PhysicalRect, + type ScreenGeometryPort, + type UiRect +} from '../src/popup-placement' + +/** + * 두 모니터 가짜 포트 — 물리 픽셀 좌표계에서 모니터마다 배율이 다르다. + * A: 물리 (0,0) 1920x1080 @150% → DIP (0,0) 1280x720 (작업 영역 높이 680) + * B: 물리 (1920,0) 1920x1080 @100% → DIP (1280,0) 1920x1080 (작업 영역 높이 1040) + */ +function twoMonitorPort(cursor: { x: number; y: number } = { x: 10, y: 10 }): ScreenGeometryPort { + const monitors = [ + { physical: { x: 0, y: 0, width: 1920, height: 1080 }, dip: { x: 0, y: 0, width: 1280, height: 720 }, scale: 1.5, workHeight: 680 }, + { physical: { x: 1920, y: 0, width: 1920, height: 1080 }, dip: { x: 1280, y: 0, width: 1920, height: 1080 }, scale: 1, workHeight: 1040 } + ] + const monitorOfPhysical = (rect: UiRect) => + monitors.find((m) => rect.x >= m.physical.x && rect.x < m.physical.x + m.physical.width) ?? monitors[0] + const monitorOfDip = (point: { x: number; y: number }) => + monitors.find((m) => point.x >= m.dip.x && point.x < m.dip.x + m.dip.width) ?? monitors[0] + return { + toDip(rect: PhysicalRect): DipRect { + const m = monitorOfPhysical(rect) + return asDipRect({ + x: Math.round(m.dip.x + (rect.x - m.physical.x) / m.scale), + y: Math.round(m.dip.y + (rect.y - m.physical.y) / m.scale), + width: Math.round(rect.width / m.scale), + height: Math.round(rect.height / m.scale) + }) + }, + workAreaNear(point: DipPoint): DipRect { + const m = monitorOfDip(point) + return asDipRect({ x: m.dip.x, y: m.dip.y, width: m.dip.width, height: m.workHeight }) + }, + cursor: () => asDipPoint(cursor) + } +} + +const SIZE = { width: 460, height: 208 } + +describe('placeAnchoredPanel — 물리 픽셀 앵커를 DIP 로 한 번만 바꾼다', () => { + it('150% 모니터에서 캐럿 (600,400) 물리 → DIP (400,267) 바로 아래에 붙는다', () => { + const caret: PhysicalRect = { x: 600, y: 400, width: 2, height: 30 } + const placement = placeAnchoredPanel(twoMonitorPort(), caret, 'caret', SIZE) + // DIP 캐럿: x=400, y=267, h=20 → 아래 6px + expect(placement).toMatchObject({ x: 400, y: 267 + 20 + 6, grow: 'down', maxHeight: 208, width: 460 }) + }) + + it('150% 모니터에서 캐럿 (1500,900) 물리는 작업 영역 밖으로 읽히지 않고 캐럿 위로 뒤집힌다', () => { + const caret: PhysicalRect = { x: 1500, y: 900, width: 2, height: 30 } + const placement = placeAnchoredPanel(twoMonitorPort(), caret, 'caret', SIZE) + // DIP 캐럿: x=1000, y=600, h=20 → 아래는 680 을 넘으므로 위(600-6-208=386) + expect(placement.y).toBe(386) + expect(placement.grow).toBe('up') + // 가로는 작업 영역 오른쪽(1280-460=820)으로 클램프될 뿐 모서리 구석에 박히지 않는다 + expect(placement.x).toBe(820) + }) + + it('두 번째(100%) 모니터의 캐럿은 그 모니터의 작업 영역을 쓴다', () => { + const caret: PhysicalRect = { x: 2000, y: 900, width: 2, height: 20 } + const placement = placeAnchoredPanel(twoMonitorPort(), caret, 'caret', SIZE) + // DIP: x = 1280 + 80 = 1360, y = 900 → 아래(926+208=1134 > 1040) 불가 → 위 686 + expect(placement).toMatchObject({ x: 1360, y: 900 - 6 - 208, grow: 'up' }) + }) + + it('앵커가 없으면 DIP 커서 기준이다', () => { + const placement = placeAnchoredPanel(twoMonitorPort({ x: 100, y: 100 }), null, null, SIZE) + expect(placement).toMatchObject({ x: 100, y: 106, grow: 'down' }) + }) + + it('100% 배율(항등 포트)에서는 기존 anchorFloatingPanel 결과와 같다', () => { + const identity: ScreenGeometryPort = { + toDip: (rect) => asDipRect(rect), + workAreaNear: () => asDipRect({ x: 0, y: 0, width: 1920, height: 1040 }), + cursor: () => asDipPoint({ x: 5, y: 5 }) + } + const caret = { x: 700, y: 500, width: 2, height: 18 } + const placement = placeAnchoredPanel(identity, caret, 'caret', SIZE) + const legacy = anchorFloatingPanel(caret, 'caret', { x: 5, y: 5 }, SIZE, { x: 0, y: 0, width: 1920, height: 1040 }) + expect({ x: placement.x, y: placement.y }).toEqual(legacy) + }) +}) + +describe('fitAnchoredPanel — 창을 그려진 패널 높이로 줄인다', () => { + it('아래로 붙은 패널은 위 모서리를 고정한다', () => { + expect(fitAnchoredPanel({ x: 10, y: 100, width: 460, maxHeight: 208, grow: 'down' }, 54)).toEqual({ + x: 10, + y: 100, + width: 460, + height: 54 + }) + }) + + it('위로 뒤집힌 패널은 앵커 쪽(아래) 모서리를 고정한다', () => { + expect(fitAnchoredPanel({ x: 10, y: 386, width: 460, maxHeight: 208, grow: 'up' }, 54)).toEqual({ + x: 10, + y: 386 + 208 - 54, + width: 460, + height: 54 + }) + }) + + it('최대 높이를 넘지 않는다', () => { + expect(fitAnchoredPanel({ x: 0, y: 0, width: 460, maxHeight: 208, grow: 'down' }, 500).height).toBe(208) + }) +}) + +describe('placeNearCursor — 커서 기준 팝업 배치', () => { + const workArea = { x: 0, y: 0, width: 1366, height: 728 } + + it('커서 위 20px, 가로 가운데', () => { + expect(placeNearCursor({ x: 600, y: 500 }, { width: 340, height: 200 }, workArea)).toEqual({ x: 430, y: 280 }) + }) + + it('위 공간이 없으면 아래로 뒤집는다', () => { + expect(placeNearCursor({ x: 600, y: 100 }, { width: 340, height: 200 }, workArea)).toEqual({ x: 430, y: 120 }) + }) + + it('아래로 뒤집힌 큰 팝업도 작업 영역 아래로 잘리지 않는다 (기록/명령 팝업 회귀)', () => { + const position = placeNearCursor({ x: 600, y: 300 }, { width: 380, height: 500 }, workArea) + expect(position.y + 500).toBeLessThanOrEqual(728) + expect(position.y).toBe(228) + }) + + it('왼쪽/오른쪽 가장자리에서 클램프한다', () => { + expect(placeNearCursor({ x: 5, y: 500 }, { width: 340, height: 100 }, workArea).x).toBe(0) + expect(placeNearCursor({ x: 1360, y: 500 }, { width: 340, height: 100 }, workArea).x).toBe(1366 - 340) + }) + + it('음수 좌표 모니터(주 모니터 왼쪽)에서도 그 작업 영역 안에 둔다', () => { + const left = { x: -1920, y: -200, width: 1920, height: 1040 } + expect(placeNearCursor({ x: -1910, y: -150 }, { width: 320, height: 110 }, left)).toEqual({ x: -1920, y: -130 }) + }) + + it('팝업이 작업 영역보다 크면 왼쪽 위 모서리에 둔다', () => { + expect(placeNearCursor({ x: 50, y: 50 }, { width: 400, height: 300 }, { x: 0, y: 0, width: 300, height: 200 })).toEqual({ + x: 0, + y: 0 + }) + }) +}) diff --git a/packages/core/__tests__/suggestion-redteam-r2-3.test.ts b/packages/core/__tests__/suggestion-redteam-r2-3.test.ts new file mode 100644 index 0000000..47ed09b --- /dev/null +++ b/packages/core/__tests__/suggestion-redteam-r2-3.test.ts @@ -0,0 +1,202 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + buildLocalSuggestionCandidateEntries, + buildLocalSuggestionCandidates, + decideInFlightGeneration, + decideSuggestionStep, + extractPhrases, + joinSuggestion, + shouldPersistSuggestionPrefix, + type SuggestionPolicyInput, + type SuggestionStepInput +} from '../src/input-intelligence' +import { splitSentences } from '../src/personal-graph' +import { countLexicalWords } from '../src/text-segmentation' + +function policy(overrides: Partial = {}): SuggestionPolicyInput { + return { + enabled: true, + modelAvailable: true, + overlayVisible: false, + composing: false, + hasSelection: false, + isPassword: false, + isEditable: true, + appName: 'Slack.exe', + excludedApps: [], + editedSinceFocus: true, + typedRecently: true, + prefix: '오늘 회의 결과를 정리해서', + idleMs: 2000, + triggerDelayMs: 600, + minPrefixChars: 8, + sinceLastRequestMs: 60_000, + minIntervalMs: 5000, + requestsThisMinute: 0, + maxRequestsPerMinute: 6, + requestsToday: 0, + dailyBudget: 500, + ...overrides + } +} + +function step(overrides: Partial = {}, policyOverrides: Partial = {}) { + return decideSuggestionStep({ + policy: policy(policyOverrides), + sessionVisible: false, + sessionPrefix: '', + coolingDown: false, + userDismissedQuiet: false, + lastRequestedPrefix: '', + modelWarm: true, + warmUpBackoff: false, + ...overrides + }) +} + +describe('joinSuggestion — 수락한 후보가 앞 글자에 들러붙지 않는다', () => { + it.each([ + ['I sent the report.', 'Let me know if you have questions.', ' Let me know if you have questions.'], + ['Let us meet at', '3 pm in the lobby', ' 3 pm in the lobby'], + ['오늘 회의에서', '논의한 내용을 공유드립니다', ' 논의한 내용을 공유드립니다'], + ['회의 끝났습니다.', '다음 주에 뵙겠습니다', ' 다음 주에 뵙겠습니다'] + ])('%s + %s', (prefix, candidate, expected) => { + expect(joinSuggestion(prefix, candidate)).toBe(expected) + }) + + it.each([ + ['이미 공백을 쳤으면 그대로', 'I sent the report. ', 'Let me know'], + ['개행 뒤도 그대로', 'Hello\n', 'World'], + ['빈 접두', '', 'Hello there'], + ['여는 괄호 뒤', 'see (', 'attached file)'], + ['경로 구분자 뒤', 'cd src/', 'main'], + ['닫는 구두점으로 시작', 'Thanks', ', see you soon'], + ['일본어 (띄어 쓰지 않음)', '今日は会議があります。', '明日の予定を確認してください'], + ['중국어', '我们明天', '开会讨论项目进度'], + ['태국어', 'สวัสดี', 'ครับ'] + ])('%s — 공백을 넣지 않는다', (_case, prefix, candidate) => { + expect(joinSuggestion(prefix, candidate)).toBe(candidate) + }) + + it('단어 중간에서 이어지는 나머지(attach)는 붙인다', () => { + expect(joinSuggestion('Thank yo', 'u for the update', 'attach')).toBe('u for the update') + }) +}) + +describe('buildLocalSuggestionCandidateEntries — 이음새 정보를 잃지 않는다', () => { + const hints = (phraseHints: string[]) => ({ continuationHints: [], relatedHints: [], phraseHints }) + + it('단어 경계에서 겹친 나머지는 separate, 단어 중간이면 attach', () => { + expect(buildLocalSuggestionCandidateEntries('Thank you', hints(['Thank you for the update']))).toEqual([ + { text: 'for the update', attachToPrefix: false } + ]) + expect(buildLocalSuggestionCandidateEntries('Thank yo', hints(['Thank you for the update']))).toEqual([ + { text: 'u for the update', attachToPrefix: true } + ]) + }) + + it('기존 문자열 API 는 그대로다', () => { + expect(buildLocalSuggestionCandidates('Thank you', hints(['Thank you for the update']))).toEqual(['for the update']) + }) + + it('이음새까지 합치면 올바른 문장이 된다', () => { + const [separate] = buildLocalSuggestionCandidateEntries('Thank you', hints(['Thank you for the update'])) + expect(`Thank you${joinSuggestion('Thank you', separate.text, separate.attachToPrefix ? 'attach' : 'separate')}`).toBe( + 'Thank you for the update' + ) + const [attach] = buildLocalSuggestionCandidateEntries('Thank yo', hints(['Thank you for the update'])) + expect(`Thank yo${joinSuggestion('Thank yo', attach.text, attach.attachToPrefix ? 'attach' : 'separate')}`).toBe( + 'Thank you for the update' + ) + }) +}) + +describe('decideInFlightGeneration — 진행 중 생성도 세션 규칙을 따른다', () => { + it('요청 뒤 몇 글자라도 자라면 stale (12자 미만 성장도)', () => { + expect(decideInFlightGeneration('회의 자료는 오늘', '회의 자료는 오늘 저녁까지')).toBe('stale') + }) + + it('같은 접두 · 뒤 공백만 추가 · IME 마지막 글자 조합은 keep', () => { + expect(decideInFlightGeneration('회의 자료는 오늘', '회의 자료는 오늘')).toBe('keep') + expect(decideInFlightGeneration('회의 자료는 오늘', '회의 자료는 오늘 ')).toBe('keep') + expect(decideInFlightGeneration('회의 자료는 하', '회의 자료는 한')).toBe('keep') + }) +}) + +describe('decideSuggestionStep — 서버가 없을 때 끝나지 않는 "준비 중" 을 띄우지 않는다', () => { + it('준비 작업이 없으면(modelStarting=false) record', () => { + expect(step({ modelStarting: false }, { modelAvailable: false })).toMatchObject({ + reason: 'model-unavailable', + tryLocalMemory: true, + then: 'record' + }) + }) + + it('워밍업 루프가 서버를 기다리는 중이면 "준비 중" 을 보여준다', () => { + expect(step({ modelStarting: true }, { modelAvailable: false })).toMatchObject({ + reason: 'model-unavailable', + then: 'show-warming' + }) + }) +}) + +describe('shouldPersistSuggestionPrefix — 입력 원문 저장은 학습 동의 규칙을 따른다', () => { + it('학습 동의가 없으면 저장하지 않는다', () => { + expect(shouldPersistSuggestionPrefix({ learnTypedText: false, appName: 'Slack.exe' })).toBe(false) + }) + + it('학습 제외 앱(터미널·에디터)은 동의가 있어도 저장하지 않는다', () => { + expect(shouldPersistSuggestionPrefix({ learnTypedText: true, appName: 'WindowsTerminal.exe' })).toBe(false) + expect(shouldPersistSuggestionPrefix({ learnTypedText: true, appName: 'Code.exe' })).toBe(false) + }) + + it('동의가 있고 일반 앱이면 저장한다', () => { + expect(shouldPersistSuggestionPrefix({ learnTypedText: true, appName: 'Slack.exe' })).toBe(true) + expect(shouldPersistSuggestionPrefix({ learnTypedText: true, appName: null })).toBe(true) + }) +}) + +describe('띄어쓰기 없는 언어도 학습된다', () => { + const ja = '今日は会議があります。明日の予定を確認してください。' + const zh = '我们明天开会讨论项目进度。请确认你的日程安排。' + const th = 'วันนี้เรามีประชุมที่สำนักงาน\nพรุ่งนี้ต้องส่งรายงานให้หัวหน้า' + + it('splitSentences 가 일본어·중국어·태국어 문장을 버리지 않는다', () => { + expect(splitSentences(ja)).toEqual(['今日は会議があります', '明日の予定を確認してください']) + expect(splitSentences(zh)).toEqual(['我们明天开会讨论项目进度', '请确认你的日程安排']) + expect(splitSentences(th)).toHaveLength(2) + }) + + it('extractPhrases 도 같은 문장을 문구로 뽑는다', () => { + expect(extractPhrases(ja)).toEqual(['今日は会議があります', '明日の予定を確認してください']) + expect(extractPhrases(zh, 4, 6)).toEqual(['我们明天开会讨论项目进度', '请确认你的日程安排']) + }) + + it('띄어 쓰는 언어의 기존 단어 수는 그대로다', () => { + expect(countLexicalWords('Hello there friend')).toBe(3) + expect(countLexicalWords('안녕 하세요')).toBe(2) + expect(countLexicalWords('짧음')).toBe(1) + expect(splitSentences('오늘 회의는 여기서 끝. 짧음. 내일 일정을 공유드릴게요!')).toEqual([ + '오늘 회의는 여기서 끝', + '내일 일정을 공유드릴게요' + ]) + }) +}) + +describe('Intl.Segmenter 가 없는 런타임(Hermes 등)', () => { + afterEach(() => { + vi.unstubAllGlobals() + vi.resetModules() + }) + + it('글자 수로 어림해 문장을 버리지 않는다', async () => { + vi.resetModules() + vi.stubGlobal('Intl', { ...Intl, Segmenter: undefined }) + const segmentation = await import('../src/text-segmentation') + const graph = await import('../src/personal-graph') + expect(segmentation.getWordSegmenter()).toBeNull() + expect(segmentation.countLexicalWords('今日は会議があります')).toBeGreaterThanOrEqual(2) + expect(segmentation.countLexicalWords('短い')).toBe(1) + expect(graph.splitSentences('今日は会議があります。明日の予定を確認してください。')).toHaveLength(2) + }) +}) diff --git a/packages/core/src/input-intelligence.ts b/packages/core/src/input-intelligence.ts index 8bd678d..b42ad63 100644 --- a/packages/core/src/input-intelligence.ts +++ b/packages/core/src/input-intelligence.ts @@ -22,15 +22,20 @@ import { SENTENCE_TERMINATORS, SUGGESTION_MAX_OUTPUT_CHARS, normalizeRequestPrefix, - normalizeSessionPrefix + normalizeSessionPrefix, + type SuggestionJoint } from './suggestion-text' import { NON_PROSE_GLYPH_PATTERN } from './terminal-prompt' +import type { AnchorKind, PhysicalRect } from './popup-placement' +import { countLexicalWords } from './text-segmentation' export { SUGGESTION_CONTEXT_MAX_CHARS, SUGGESTION_MAX_OUTPUT_CHARS, + buildLocalSuggestionCandidateEntries, buildLocalSuggestionCandidates, endsSentence, + joinSuggestion, normalizeRequestPrefix, normalizeSessionPrefix, normalizeSuggestionText, @@ -38,29 +43,27 @@ export { sanitizeSuggestionLine, stripModelArtifacts } from './suggestion-text' -export type { LocalSuggestionHints } from './suggestion-text' +export type { LocalSuggestionCandidate, LocalSuggestionHints, SuggestionJoint } from './suggestion-text' export { extractTerminalPromptPrefix } from './terminal-prompt' // ============================================================ -// 기하 +// 기하 — 정본은 popup-placement.ts (좌표 공간 · 배치 정책). 기존 import 경로를 유지한다. // ============================================================ -export interface UiRect { - x: number - y: number - width: number - height: number -} - -/** - * 앵커 rect 가 케어렛(한 줄 위치)인지 포커스 요소 전체인지. - * - * 케어렛을 못 주는 제공자가 많아(Chrome, Windows Terminal 등 caret=-1) 그때는 - * elementRect 로 폴백하는데, 두 경우는 배치 전략이 달라야 한다 — 케어렛은 "그 - * 줄 아래" 에 붙이면 되지만, elementRect(멀티라인/큰 입력창 전체)는 그 규칙을 - * 그대로 쓰면 텍스트와 멀리 떨어지거나 텍스트 위에 겹친다. - */ -export type AnchorKind = 'caret' | 'element' | null +export { + anchorFloatingPanel, + anchorFloatingPanelInDips, + asDipPoint, + asDipRect +} from './popup-placement' +export type { + AnchorKind, + DipPoint, + DipRect, + PhysicalRect, + ScreenGeometryPort, + UiRect +} from './popup-placement' /** 마우스 이동 누적 전에 무시할 최소 이동량(px). 미세抖动 노이즈 제거. */ export const MOUSE_MOVE_NOISE_FLOOR_PX = 2 @@ -70,99 +73,6 @@ export function manhattanDistance(from: { x: number; y: number }, to: { x: numbe return Math.abs(to.x - from.x) + Math.abs(to.y - from.y) } -/** - * 오버레이 커 좌표 계산. - * - * 케어렛 앵커(또는 앵커 없음 → 커서)는 앵커 아래에 붙이는 것이 기본 — IME 후보창과 - * 같은 관례다. 아래 공간이 없으면 위로 뒤집는다. - * 요소 앵커(케어렛을 못 얻어 elementRect 로 폴백한 경우)는 요소 "바깥" 에 붙인다 — - * 아래/위/오른쪽/왼쪽 순으로 작업영역에 들어맞는 첫 방향을 쓴다. - * 마지막으로 항상 작업영역 안으로 클램프한다. - */ -export function anchorFloatingPanel( - anchor: UiRect | null, - anchorKind: AnchorKind, - cursor: { x: number; y: number }, - size: { width: number; height: number }, - workArea: UiRect, - gap = 6 -): { x: number; y: number } { - if (anchorKind === 'element' && anchor && anchor.width >= 0 && anchor.height >= 0) { - return anchorOutsideElement(anchor, size, workArea, gap) - } - - const rect: UiRect = - anchor && anchor.width >= 0 && anchor.height >= 0 - ? anchor - : { x: cursor.x, y: cursor.y, width: 0, height: 0 } - - const anchorBottom = rect.y + Math.max(rect.height, 0) - const desiredY = anchorBottom + gap - const fitsBelow = desiredY + size.height <= workArea.y + workArea.height - const above = rect.y - gap - size.height - const y = fitsBelow ? desiredY : above >= workArea.y ? above : desiredY - - return { - x: clamp(rect.x, workArea.x, workArea.x + workArea.width - size.width), - y: clamp(y, workArea.y, workArea.y + workArea.height - size.height) - } -} - -function fitsInWorkArea( - x: number, - y: number, - size: { width: number; height: number }, - workArea: UiRect -): boolean { - return ( - x >= workArea.x && - y >= workArea.y && - x + size.width <= workArea.x + workArea.width && - y + size.height <= workArea.y + workArea.height - ) -} - -/** - * 요소 앵커를 요소 "바깥" 에 배치한다. - * - * 케어렛을 못 얻어 elementRect(입력창 전체)로 폴백했을 때, 기존 "앵커 아래/위" 규칙을 - * 그대로 쓰면 큰/여러 줄 요소에서는 텍스트와 멀리 떨어지거나 텍스트 위에 겹친다 - * (실측: 2·3번째 생성에서 패널이 튐). 아래→위→오른쪽→왼쪽 순으로 작업영역에 - * 맞는 첫 방향을 쓰고, 전부 안 맞으면 요소 안쪽 우하단 모서리로 물러난다. - */ -function anchorOutsideElement( - element: UiRect, - size: { width: number; height: number }, - workArea: UiRect, - gap: number -): { x: number; y: number } { - const candidates: Array<{ x: number; y: number }> = [ - { x: element.x, y: element.y + element.height + gap }, // 아래 - { x: element.x, y: element.y - gap - size.height }, // 위 - { x: element.x + element.width + gap, y: element.y }, // 오른쪽 - { x: element.x - gap - size.width, y: element.y } // 왼쪽 - ] - - for (const candidate of candidates) { - if (fitsInWorkArea(candidate.x, candidate.y, size, workArea)) return candidate - } - - const fallback = { - x: element.x + element.width - size.width - gap, - y: element.y + element.height - size.height - gap - } - - return { - x: clamp(fallback.x, workArea.x, workArea.x + workArea.width - size.width), - y: clamp(fallback.y, workArea.y, workArea.y + workArea.height - size.height) - } -} - -function clamp(value: number, min: number, max: number): number { - if (max < min) return min - return Math.max(min, Math.min(value, max)) -} - // ============================================================ // 키스트로크 분류 (내용이 아니라 '종류'만 남긴다) // ============================================================ @@ -224,7 +134,8 @@ export function isWordBoundaryKey(keyClass: KeyStrokeClass): boolean { * 단어 수. * * 공백 분리 토큰 중 문자/숫자/CJK 를 포함한 것만 센다. - * (중국어처럼 공백이 없는 언어는 과소 집계된다 — GAP-SUGGEST-03 에 기록) + * (중국어처럼 공백이 없는 언어는 과소 집계된다 — 활동 통계 전용. 학습 필터는 + * text-segmentation.ts 의 countLexicalWords 로 센다) */ export function countWords(text: string): number { if (!text) return 0 @@ -348,8 +259,9 @@ export interface FocusSnapshot { textSource: FocusTextSource text: string caretOffset: number | null - caretRect: UiRect | null - elementRect: UiRect | null + /** 물리 픽셀 (UIA 는 DPI-aware 로 동작한다) — 배치 전에 ScreenGeometryPort 로 DIP 변환 */ + caretRect: PhysicalRect | null + elementRect: PhysicalRect | null windowTitle: string | null appName: string | null processId: number | null @@ -617,6 +529,18 @@ export function matchesSessionPrefix(generatedPrefix: string, currentPrefix: str return current.length === generated.length && current.slice(0, -1) === generated.slice(0, -1) } +/** + * 진행 중인 생성(아직 후보 없음)을 계속 둘지 결정한다 — 세션 규칙과 같은 기준이다. + * + * 결과는 요청한 접두로 게시되고, 게시된 세션은 성장을 허용하지 않는다(matchesSessionPrefix). + * 그래서 요청 뒤 사용자가 몇 글자라도 더 쳤다면 결과는 도착하자마자 stale 로 닫힌다 — + * 깜빡임 · 버려지는 모델/채우기 요청 · 옛 접두용 문장 수락을 막으려면 그 즉시 취소한다. + * IME 마지막 글자 조합 변화만 예외다. + */ +export function decideInFlightGeneration(requestedPrefix: string, currentPrefix: string): 'keep' | 'stale' { + return matchesSessionPrefix(requestedPrefix, currentPrefix) ? 'keep' : 'stale' +} + /** * 표시 중인 제안을 계속 둘지, 새로 만들지, 즉시 버릴지 결정한다. * @@ -675,6 +599,14 @@ export interface SuggestionStepInput { modelWarm: boolean /** 직전 워밍업이 실패해 재시도를 잠시 미루는 중인가 */ warmUpBackoff: boolean + /** + * 모델 서버를 실제로 준비하는 작업(워밍업 루프)이 진행 중인가. + * + * 서버가 없을 때(model-unavailable) "준비 중" 은 이 작업이 있을 때만 보여준다 — 아무것도 + * 준비하지 않는데 스피너를 띄우면 끝나지 않는 "준비 중" 이 옛 캐럿 위에 20초씩 반복된다 + * (Ollama 미설치/중지). 생략하면 예전처럼 "준비 중" 을 보여준다(호환). 서비스는 항상 넘긴다. + */ + modelStarting?: boolean } /** @@ -723,7 +655,8 @@ export function decideSuggestionStep(input: SuggestionStepInput): SuggestionStep action: 'settle', reason: 'model-unavailable', tryLocalMemory: localMemoryAllowed, - then: 'show-warming' + // 준비 작업이 없으면 기능은 꺼진 것처럼 조용해야 한다 (설정 문구: "제안이 꺼진 상태로 동작"). + then: input.modelStarting === false ? 'record' : 'show-warming' } } return { action: 'settle', reason: decision.reason, tryLocalMemory: false, then: 'record' } @@ -816,6 +749,21 @@ export const LEARNING_EXCLUDED_APPS: readonly string[] = Object.freeze([ 'Agent Switchboard' ]) +/** + * 제안 이력에 입력 문맥(접두) 원문을 남겨도 되는가. + * + * 접두는 사용자가 친 글 그 자체다 — 학습 동의(inputLearnTypedText)가 있고, 학습 제외 앱 + * (터미널·에디터·에이전트 허브)이 아닐 때만 저장한다. 텔레메트리 학습과 같은 규칙을 쓴다. + * 그 밖에는 원문 대신 빈 문자열을 남겨 이력 개수·수락률 통계만 유지한다. + */ +export function shouldPersistSuggestionPrefix(input: { + learnTypedText: boolean + appName: string | null +}): boolean { + if (!input.learnTypedText) return false + return !(input.appName && isAppExcluded(input.appName, LEARNING_EXCLUDED_APPS)) +} + /** 공백을 뺀 글자 중 문자(모든 언어)가 이 비율 이상이어야 문장으로 본다. */ const MIN_LETTER_RATIO = 0.6 @@ -1119,7 +1067,8 @@ export function extractPhrases(text: string, maxPhrases = 4, minChars = 6): stri const phrase = segment.replace(/\s+/gu, ' ').trim() if (phrase.length < minChars) continue if (phrase.length > SUGGESTION_MAX_OUTPUT_CHARS) continue - if (countWords(phrase) < 2) continue + // 띄어쓰기 없는 문자 체계(중·일·태)도 문구로 인정한다 — 공백 단어 수로는 항상 1이다. + if (countLexicalWords(phrase) < 2) continue if (out.includes(phrase)) continue out.push(phrase) if (out.length >= maxPhrases) break @@ -1170,6 +1119,8 @@ export interface SuggestionCandidate { text: string /** 0 = 최상위 */ rank: number + /** 접두와의 이음새 — 생략하면 'separate' (joinSuggestion 참고) */ + joint?: SuggestionJoint } /** 현재 제안이 어느 로컬 근거에서 나왔는지 보여 주는 비식별 설명. */ @@ -1200,8 +1151,8 @@ export interface SuggestionState { * UI 가 "4–6 / 9" 같은 진행률을 보여주는 근거. */ targetTotal: number - /** 앵커 rect (케어렛 → 요소 → 마우스 폴백은 메인이 계산) */ - anchor: UiRect | null + /** 앵커 rect — 물리 픽셀 (케어렛 → 요소 → 마우스 폴백은 메인이 계산, DIP 변환은 창 배치 한 곳에서) */ + anchor: PhysicalRect | null /** 앵커가 케어렛인지 요소 전체인지 — 배치 전략(caret vs element)을 결정한다 */ anchorKind: AnchorKind appName: string | null diff --git a/packages/core/src/personal-graph.ts b/packages/core/src/personal-graph.ts index c04d4f1..00163eb 100644 --- a/packages/core/src/personal-graph.ts +++ b/packages/core/src/personal-graph.ts @@ -8,8 +8,8 @@ // // 전부 순수 함수로 두어 LLM/DB 없이 검증 가능하게 한다. -import { countWords } from './input-intelligence' import type { PhraseSource } from './input-intelligence' +import { countLexicalWords, getWordSegmenter } from './text-segmentation' // 렌더러/프리로드도 같은 계약을 쓴다 (main 모듈을 import 할 수 없다). export type { PhraseSource } @@ -104,18 +104,6 @@ const TERM_SEPARATOR = /[^\p{L}\p{N}\p{M}]+/u /** 띄어쓰기 없이 쓰는 문자 체계 — 경계 분리만으로는 문장 전체가 한 토큰이 된다. */ const UNSPACED_SCRIPT = /[\p{Script=Thai}\p{Script=Lao}\p{Script=Khmer}\p{Script=Myanmar}]/u -let wordSegmenter: Intl.Segmenter | null | undefined - -/** 런타임이 지원할 때만 단어 분할기를 만든다 (Hermes 등에는 없을 수 있다). */ -function getWordSegmenter(): Intl.Segmenter | null { - if (wordSegmenter !== undefined) return wordSegmenter - wordSegmenter = - typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function' - ? new Intl.Segmenter(undefined, { granularity: 'word' }) - : null - return wordSegmenter -} - /** 띄어쓰기 없는 문자 체계의 토큰을 사전 기반 단어로 나눈다. 분할기가 없으면 그대로 둔다. */ function segmentUnspacedToken(token: string): string[] { if (!UNSPACED_SCRIPT.test(token)) return [token] @@ -164,12 +152,17 @@ export function extractTerms(text: string, limit = 8): string[] { .map(([token]) => token) } -/** 문장 단위로 쪼갠다 (종결 부호 + 개행). 2단어 이상만 남긴다. */ +/** + * 문장 단위로 쪼갠다 (종결 부호 + 개행). 2단어 이상만 남긴다. + * + * 단어 수는 countLexicalWords 로 센다 — 공백으로만 세면 띄어쓰기 없는 중국어·일본어·태국어 + * 문장이 모두 1단어가 되어 그 언어 사용자의 문장이 하나도 학습되지 않는다. + */ export function splitSentences(text: string): string[] { return text .split(/[.!?。!?…\n]+/u) .map((segment) => segment.replace(/\s+/gu, ' ').trim()) - .filter((segment) => segment.length >= 4 && countWords(segment) >= 2) + .filter((segment) => segment.length >= 4 && countLexicalWords(segment) >= 2) } /** 한 텍스트 안에서 인접한 문장 쌍 (follows 엣지의 근거). */ diff --git a/packages/core/src/popup-placement.ts b/packages/core/src/popup-placement.ts new file mode 100644 index 0000000..8921187 --- /dev/null +++ b/packages/core/src/popup-placement.ts @@ -0,0 +1,269 @@ +// packages/core — 팝업/오버레이 배치 기하 정본. +// +// 두 가지를 담는다: +// 1) 좌표 공간 — UIA(사이드카)는 물리 픽셀, Electron screen API 는 DIP 를 쓴다. 둘을 같은 +// `UiRect` 로 섞으면 125%/150% 배율에서 오버레이가 캐럿에서 멀리 떨어지거나 작업 영역 +// 모서리에 붙는다. 그래서 공간을 타입으로 표기하고(PhysicalRect/DipRect), 변환은 +// ScreenGeometryPort 한 곳(Electron 어댑터)에서만 한다 — core 는 좌표를 만든 OS API 를 모른다. +// 2) 배치 정책 — 캐럿/요소 앵커 배치(anchorFloatingPanel), 커서 기준 팝업 배치 +// (placeNearCursor), 내용 높이에 맞춘 창 줄이기(fitAnchoredPanel). +// +// 순수 함수만 둔다 — Electron/Node 의존 금지. + +// ============================================================ +// 좌표 공간 +// ============================================================ + +export interface UiRect { + x: number + y: number + width: number + height: number +} + +export interface UiPoint { + x: number + y: number +} + +export interface UiSize { + width: number + height: number +} + +/** 타입 전용 표지 — 런타임 값은 없다. */ +declare const COORDINATE_SPACE: unique symbol + +/** + * 물리 픽셀 사각형 (UIA BoundingRectangle, GetWindowRect 등 DPI-aware 네이티브 API 가 주는 값). + * + * 표지는 선택적이다 — 네이티브 경계(UIA 페이로드)에서 들어온 평범한 UiRect 도 받아들이되, + * DipRect 는 받지 않는다(표지 값이 'dip' 이라 충돌한다). + */ +export type PhysicalRect = UiRect & { readonly [COORDINATE_SPACE]?: 'physical' } + +/** Electron DIP 사각형 — 반드시 ScreenGeometryPort(또는 asDipRect)로만 만든다. */ +export type DipRect = UiRect & { readonly [COORDINATE_SPACE]: 'dip' } + +/** Electron DIP 점. */ +export type DipPoint = UiPoint & { readonly [COORDINATE_SPACE]: 'dip' } + +/** 이미 DIP 인 값(Electron screen API 결과)에 표지를 붙인다. 어댑터 경계에서만 쓴다. */ +export function asDipRect(rect: UiRect): DipRect { + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height } as DipRect +} + +/** 이미 DIP 인 점(Electron screen API 결과)에 표지를 붙인다. 어댑터 경계에서만 쓴다. */ +export function asDipPoint(point: UiPoint): DipPoint { + return { x: point.x, y: point.y } as DipPoint +} + +/** + * 화면 기하 포트 — 좌표 공간 변환과 작업 영역 조회를 한 곳에 모은다 (DIP). + * + * Electron 어댑터는 win32 에서 screen.screenToDipRect 를, 그 밖에서는 항등 변환을 쓴다. + * 배치 정책은 이 포트만 보고 DIP 로만 계산한다. + */ +export interface ScreenGeometryPort { + /** 물리 픽셀 사각형 → DIP (배율이 다른 모니터에서도 그 모니터 기준으로 변환한다) */ + toDip(rect: PhysicalRect): DipRect + /** 이 점에서 가장 가까운 모니터의 작업 영역 (DIP) */ + workAreaNear(point: DipPoint): DipRect + /** 마우스 커서 위치 (DIP) */ + cursor(): DipPoint +} + +// ============================================================ +// 캐럿/요소 앵커 배치 +// ============================================================ + +/** + * 앵커 rect 가 케어렛(한 줄 위치)인지 포커스 요소 전체인지. + * + * 케어렛을 못 주는 제공자가 많아(Chrome, Windows Terminal 등 caret=-1) 그때는 + * elementRect 로 폴백하는데, 두 경우는 배치 전략이 달라야 한다 — 케어렛은 "그 + * 줄 아래" 에 붙이면 되지만, elementRect(멀티라인/큰 입력창 전체)는 그 규칙을 + * 그대로 쓰면 텍스트와 멀리 떨어지거나 텍스트 위에 겹친다. + */ +export type AnchorKind = 'caret' | 'element' | null + +function clamp(value: number, min: number, max: number): number { + if (max < min) return min + return Math.max(min, Math.min(value, max)) +} + +function fitsInWorkArea(x: number, y: number, size: UiSize, workArea: UiRect): boolean { + return ( + x >= workArea.x && + y >= workArea.y && + x + size.width <= workArea.x + workArea.width && + y + size.height <= workArea.y + workArea.height + ) +} + +function isUsableRect(rect: UiRect | null): rect is UiRect { + return rect !== null && rect.width >= 0 && rect.height >= 0 +} + +/** + * 오버레이 좌표 계산. + * + * 모든 인자는 같은 좌표 공간이어야 한다 — 공간을 타입으로 강제하려면 + * anchorFloatingPanelInDips 를 쓴다. + * + * 케어렛 앵커(또는 앵커 없음 → 커서)는 앵커 아래에 붙이는 것이 기본 — IME 후보창과 + * 같은 관례다. 아래 공간이 없으면 위로 뒤집는다. + * 요소 앵커(케어렛을 못 얻어 elementRect 로 폴백한 경우)는 요소 "바깥" 에 붙인다 — + * 아래/위/오른쪽/왼쪽 순으로 작업영역에 들어맞는 첫 방향을 쓴다. + * 마지막으로 항상 작업영역 안으로 클램프한다. + */ +export function anchorFloatingPanel( + anchor: UiRect | null, + anchorKind: AnchorKind, + cursor: UiPoint, + size: UiSize, + workArea: UiRect, + gap = 6 +): UiPoint { + if (anchorKind === 'element' && isUsableRect(anchor)) { + return anchorOutsideElement(anchor, size, workArea, gap) + } + + const rect: UiRect = isUsableRect(anchor) ? anchor : { x: cursor.x, y: cursor.y, width: 0, height: 0 } + + const anchorBottom = rect.y + Math.max(rect.height, 0) + const desiredY = anchorBottom + gap + const fitsBelow = desiredY + size.height <= workArea.y + workArea.height + const above = rect.y - gap - size.height + const y = fitsBelow ? desiredY : above >= workArea.y ? above : desiredY + + return { + x: clamp(rect.x, workArea.x, workArea.x + workArea.width - size.width), + y: clamp(y, workArea.y, workArea.y + workArea.height - size.height) + } +} + +/** anchorFloatingPanel 의 DIP 전용 형태 — 물리 픽셀 값이 섞이면 컴파일되지 않는다. */ +export function anchorFloatingPanelInDips( + anchor: DipRect | null, + anchorKind: AnchorKind, + cursor: DipPoint, + size: UiSize, + workArea: DipRect, + gap = 6 +): DipPoint { + return asDipPoint(anchorFloatingPanel(anchor, anchorKind, cursor, size, workArea, gap)) +} + +/** + * 요소 앵커를 요소 "바깥" 에 배치한다. + * + * 케어렛을 못 얻어 elementRect(입력창 전체)로 폴백했을 때, 기존 "앵커 아래/위" 규칙을 + * 그대로 쓰면 큰/여러 줄 요소에서는 텍스트와 멀리 떨어지거나 텍스트 위에 겹친다 + * (실측: 2·3번째 생성에서 패널이 튐). 아래→위→오른쪽→왼쪽 순으로 작업영역에 + * 맞는 첫 방향을 쓰고, 전부 안 맞으면 요소 안쪽 우하단 모서리로 물러난다. + */ +function anchorOutsideElement(element: UiRect, size: UiSize, workArea: UiRect, gap: number): UiPoint { + const candidates: UiPoint[] = [ + { x: element.x, y: element.y + element.height + gap }, // 아래 + { x: element.x, y: element.y - gap - size.height }, // 위 + { x: element.x + element.width + gap, y: element.y }, // 오른쪽 + { x: element.x - gap - size.width, y: element.y } // 왼쪽 + ] + + for (const candidate of candidates) { + if (fitsInWorkArea(candidate.x, candidate.y, size, workArea)) return candidate + } + + const fallback = { + x: element.x + element.width - size.width - gap, + y: element.y + element.height - size.height - gap + } + + return { + x: clamp(fallback.x, workArea.x, workArea.x + workArea.width - size.width), + y: clamp(fallback.y, workArea.y, workArea.y + workArea.height - size.height) + } +} + +/** + * 앵커 기준으로 배치한 패널 자리. 창 높이는 내용에 따라 줄어들 수 있다(fitAnchoredPanel). + * + * `grow` 는 내용이 늘 때 창이 자라는 방향이다 — 앵커 위로 뒤집혀 배치됐으면 아래쪽 모서리를 + * 앵커 곁에 고정하고 위로 자란다. + */ +export interface AnchoredPanelPlacement { + x: number + y: number + width: number + /** 배치 계산에 쓴 최대 높이 */ + maxHeight: number + grow: 'down' | 'up' +} + +/** + * 물리 픽셀 앵커로 오버레이 자리를 잡는다 — 변환은 포트로 한 번만 한다. + * + * 모니터 선택도 변환된 DIP 점으로 한다 (물리 좌표로 고르면 배율이 다른 모니터가 여럿일 때 + * 엉뚱한 모니터의 작업 영역을 고른다). + */ +export function placeAnchoredPanel( + port: ScreenGeometryPort, + anchor: PhysicalRect | null, + anchorKind: AnchorKind, + size: UiSize, + gap = 6 +): AnchoredPanelPlacement { + const cursor = port.cursor() + const dipAnchor = isUsableRect(anchor) ? port.toDip(anchor) : null + const anchorPoint = dipAnchor + ? asDipPoint({ x: dipAnchor.x, y: dipAnchor.y + dipAnchor.height }) + : cursor + const workArea = port.workAreaNear(anchorPoint) + const position = anchorFloatingPanelInDips(dipAnchor, anchorKind, cursor, size, workArea, gap) + const referenceTop = dipAnchor ? dipAnchor.y : cursor.y + return { + x: position.x, + y: position.y, + width: size.width, + maxHeight: size.height, + grow: position.y + size.height <= referenceTop ? 'up' : 'down' + } +} + +/** + * 배치된 패널 창을 실제 내용 높이로 줄인다. + * + * 투명 창이라도 창 영역은 클릭을 삼킨다 — 보이지 않는 여백이 캐럿 아래 줄의 클릭을 막지 + * 않도록 창을 그려진 패널만큼만 둔다. 위로 뒤집힌 배치는 아래 모서리(앵커 쪽)를 고정한다. + */ +export function fitAnchoredPanel(placement: AnchoredPanelPlacement, contentHeight: number): UiRect { + const height = clamp(Math.round(contentHeight), 1, placement.maxHeight) + const y = placement.grow === 'up' ? placement.y + placement.maxHeight - height : placement.y + return { x: placement.x, y, width: placement.width, height } +} + +// ============================================================ +// 커서 기준 팝업 배치 +// ============================================================ + +/** + * 커서 기준 팝업 배치 (녹음 팁 · 결과 · 기록 · 명령 팝업 공통). + * + * 커서 위 `gap` 에 가로 가운데로 두고, 위 공간이 없으면 커서 아래로 뒤집는다. + * 그다음 작업 영역 안으로 네 방향 모두 클램프한다 (아래로 뒤집힌 큰 팝업이 화면 밖으로 + * 잘리지 않게). 음수 좌표(주 모니터 왼쪽/위쪽 모니터)도 그대로 다룬다. + */ +export function placeNearCursor(cursor: UiPoint, size: UiSize, workArea: UiRect, gap = 20): UiPoint { + const x = clamp( + cursor.x - Math.round(size.width / 2), + workArea.x, + workArea.x + workArea.width - size.width + ) + let y = cursor.y - size.height - gap + if (y < workArea.y) y = cursor.y + gap + if (y + size.height > workArea.y + workArea.height) { + y = Math.max(workArea.y, workArea.y + workArea.height - size.height) + } + return { x, y } +} diff --git a/packages/core/src/suggestion-text.ts b/packages/core/src/suggestion-text.ts index ed64604..379ebc4 100644 --- a/packages/core/src/suggestion-text.ts +++ b/packages/core/src/suggestion-text.ts @@ -162,6 +162,16 @@ function suffixPrefixOverlap(prefix: string, candidate: string): number { return 0 } +/** 로컬 기억 후보 하나 — 삽입할 텍스트와, 접두에 붙여 써야 하는지(단어 중간에서 이어짐). */ +export interface LocalSuggestionCandidate { + text: string + /** + * true 면 접두의 단어 중간에서 이어지는 나머지다(예: "Thank yo" + "u for the update") — + * 수락할 때 구분 공백을 넣지 않는다. joinSuggestion 의 joint 'attach' 에 해당한다. + */ + attachToPrefix: boolean +} + /** * 로컬 기억만으로 삽입 가능한 다음 문자열을 만든다. * @@ -175,6 +185,21 @@ export function buildLocalSuggestionCandidates( limit = 3, maxChars = SUGGESTION_MAX_OUTPUT_CHARS ): string[] { + return buildLocalSuggestionCandidateEntries(prefix, hints, limit, maxChars).map((entry) => entry.text) +} + +/** + * buildLocalSuggestionCandidates 와 같은 후보를, 접두와의 이음새 정보와 함께 돌려준다. + * + * 문구 겹침(접두 꼬리 = 문구 머리)으로 잘라 낸 나머지가 공백으로 시작하지 않으면 단어 + * 중간에서 이어지는 것이다 — 정규화(trim)가 그 정보를 지우기 전에 여기서 기록한다. + */ +export function buildLocalSuggestionCandidateEntries( + prefix: string, + hints: LocalSuggestionHints, + limit = 3, + maxChars = SUGGESTION_MAX_OUTPUT_CHARS +): LocalSuggestionCandidate[] { const safeLimit = Math.max(0, Math.floor(limit)) const safeMaxChars = Math.max(0, Math.floor(maxChars)) if (safeLimit === 0 || safeMaxChars < 2) return [] @@ -182,13 +207,13 @@ export function buildLocalSuggestionCandidates( const normalizedPrefix = normalizeRequestPrefix(prefix) const normalizedPrefixLower = normalizedPrefix.toLowerCase() const acceptsWholePhrase = endsSentence(normalizedPrefix) - const out: string[] = [] + const out: LocalSuggestionCandidate[] = [] - const append = (candidate: string | null): void => { + const append = (candidate: string | null, attachToPrefix = false): void => { if (candidate === null || candidate.length === 0) return if (candidate.toLowerCase() === normalizedPrefixLower) return - if (out.some((existing) => existing.toLowerCase() === candidate.toLowerCase())) return - out.push(candidate) + if (out.some((existing) => existing.text.toLowerCase() === candidate.toLowerCase())) return + out.push({ text: candidate, attachToPrefix }) } for (const raw of hints.continuationHints) { @@ -206,7 +231,8 @@ export function buildLocalSuggestionCandidates( if (candidate === null) continue const overlap = suffixPrefixOverlap(normalizedPrefix, candidate) if (overlap >= 2) { - append(normalizeSuggestionText(candidate.slice(overlap), safeMaxChars)) + const remainder = candidate.slice(overlap) + append(normalizeSuggestionText(remainder, safeMaxChars), !/^\s/u.test(remainder)) } else if (acceptsWholePhrase) { append(candidate) } @@ -215,3 +241,53 @@ export function buildLocalSuggestionCandidates( return out } + +// ============================================================ +// 수락 시 이음새 +// ============================================================ + +/** + * 후보가 접두와 어떻게 이어지는가. + * - separate: 새 단어/문장이다 — 띄어 쓰는 문자 체계면 공백 하나로 떼어 쓴다. + * - attach: 접두의 단어 중간에서 이어지는 나머지다 — 그대로 붙인다. + */ +export type SuggestionJoint = 'separate' | 'attach' + +/** 이 문자 뒤에는 떼어 쓰지 않는다 (여는 괄호·따옴표, 경로·멘션·해시 기호). */ +const NO_SPACE_AFTER = /[([{<“‘«‹「『【〔〈《([{/\\@#_-]/u +/** 이 문자로 시작하는 후보는 앞 글자에 붙는다 (닫는 괄호·구두점). */ +const NO_SPACE_BEFORE = /[.,;:!?)\]}>…%’”»›」』】〕〉》、。,.!?:;)]}]/u +/** 단어 사이를 띄어 쓰지 않는 문자 체계 + CJK 구두점·전각 기호. */ +const UNSPACED_JOIN_SCRIPT = + /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Thai}\p{Script=Lao}\p{Script=Khmer}\p{Script=Myanmar}\u3000-\u303f\uff00-\uffef]/u + +function lastCodePoint(text: string): string { + const chars = Array.from(text) + return chars[chars.length - 1] ?? '' +} + +function firstCodePoint(text: string): string { + return Array.from(text)[0] ?? '' +} + +/** + * 수락한 후보를 커서 위치에 넣을 문자열로 만든다 (필요하면 앞에 공백 하나). + * + * 후보는 정제 과정에서 앞 공백이 모두 사라진다. 사용자는 대개 마지막 글자(단어 끝이나 + * 마침표) 바로 뒤에서 멈추므로, 그대로 붙이면 "report.Let me…", "회의에서논의한…" 처럼 + * 들러붙는다. 다음 경우에만 공백을 넣는다: + * - 커서 앞 원문(rawPrefix)이 비어 있지 않고 공백으로 끝나지 않는다. + * - 후보가 접두의 단어 중간에서 이어지는 나머지(joint 'attach')가 아니다. + * - 앞 글자가 여는 괄호/따옴표·경로 기호가 아니고, 후보가 닫는 구두점으로 시작하지 않는다. + * - 이음새 양쪽 어느 쪽도 띄어 쓰지 않는 문자 체계(중·일·태 등, CJK 구두점)가 아니다. + */ +export function joinSuggestion(rawPrefix: string, candidate: string, joint: SuggestionJoint = 'separate'): string { + if (!candidate || joint === 'attach') return candidate + const before = lastCodePoint(rawPrefix) + const after = firstCodePoint(candidate) + if (!before || !after) return candidate + if (/\s/u.test(before) || /\s/u.test(after)) return candidate + if (NO_SPACE_AFTER.test(before) || NO_SPACE_BEFORE.test(after)) return candidate + if (UNSPACED_JOIN_SCRIPT.test(before) || UNSPACED_JOIN_SCRIPT.test(after)) return candidate + return ` ${candidate}` +} diff --git a/packages/core/src/text-segmentation.ts b/packages/core/src/text-segmentation.ts new file mode 100644 index 0000000..d2caa32 --- /dev/null +++ b/packages/core/src/text-segmentation.ts @@ -0,0 +1,65 @@ +// packages/core — 단어 분할 정본 (학습용 단어 수 · 사전 기반 분할기). +// +// 공백 분리만으로는 띄어쓰기 없이 쓰는 문자 체계(중국어·일본어·태국어 등)의 문장 전체가 +// 한 "단어" 가 된다. 학습 필터("2단어 이상")가 그 언어의 문장을 전부 버리지 않도록 +// 런타임의 Intl.Segmenter 로 나누고, 없으면(Hermes 등) 글자 수로 어림한다. +// +// 순수 함수만 둔다 — Electron/Node 의존 금지. 다른 core 모듈을 import 하지 않는다(순환 방지). + +let wordSegmenter: Intl.Segmenter | null | undefined + +/** 런타임이 지원할 때만 단어 분할기를 만든다 (Hermes 등에는 없을 수 있다). */ +export function getWordSegmenter(): Intl.Segmenter | null { + if (wordSegmenter !== undefined) return wordSegmenter + wordSegmenter = + typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function' + ? new Intl.Segmenter(undefined, { granularity: 'word' }) + : null + return wordSegmenter +} + +/** 단어 사이를 띄어 쓰지 않는 문자 체계 (한자 · 가나 · 태국/라오/크메르/미얀마 문자). */ +const UNSPACED_WORD_SCRIPT = + /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Thai}\p{Script=Lao}\p{Script=Khmer}\p{Script=Myanmar}]/u +const UNSPACED_WORD_SCRIPT_GLOBAL = new RegExp(UNSPACED_WORD_SCRIPT.source, 'gu') +const LETTER_OR_DIGIT = /[\p{L}\p{N}]/u + +/** 분할기가 없을 때 띄어쓰기 없는 문자 몇 자를 한 단어로 어림할지 (한·중·일 평균 단어 길이) */ +const UNSPACED_CHARS_PER_WORD = 2 + +function countSpacedWords(text: string): number { + let count = 0 + for (const token of text.split(/\s+/u)) { + if (LETTER_OR_DIGIT.test(token)) count += 1 + } + return count +} + +/** + * 학습 필터용 단어 수. + * + * 띄어쓰기 없는 문자 체계가 없으면 공백 분리 결과(countWords 와 같다)를 그대로 쓴다 — + * 한국어·라틴 문자의 기존 동작은 바뀌지 않는다. 있으면 Intl.Segmenter 의 단어(isWordLike) + * 수를 쓰고, 분할기가 없으면 그 문자들을 UNSPACED_CHARS_PER_WORD 자당 한 단어로 어림한다. + */ +export function countLexicalWords(text: string): number { + if (!text) return 0 + if (!UNSPACED_WORD_SCRIPT.test(text)) return countSpacedWords(text) + + const segmenter = getWordSegmenter() + if (segmenter) { + let count = 0 + for (const part of segmenter.segment(text)) { + if (part.isWordLike) count += 1 + } + return count + } + + let count = 0 + for (const token of text.split(/\s+/u)) { + if (!LETTER_OR_DIGIT.test(token)) continue + const unspaced = token.match(UNSPACED_WORD_SCRIPT_GLOBAL)?.length ?? 0 + count += unspaced > 0 ? Math.max(1, Math.floor(unspaced / UNSPACED_CHARS_PER_WORD)) : 1 + } + return count +}