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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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()
})

View file

@ -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> = {}): 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<typeof vi.fn>
markAccepted: ReturnType<typeof vi.fn>
expectInsert: ReturnType<typeof vi.fn>
recordAccepted: ReturnType<typeof vi.fn>
}
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<string> {
return (async function* () {
yield text
})()
}
/** 호출자가 풀어 줄 때까지 기다리는 스트림 — abort 되면 예외로 끝난다. */
function gatedStream(signal: AbortSignal | undefined, gate: Promise<string>): AsyncGenerator<string> {
return (async function* () {
const text = await new Promise<string>((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<void>
_lastContext: TypingContext | null
_inFlight: boolean
_warmUpPromise: Promise<void> | 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<string>((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<string>((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<SuggestionService> {
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<void> {
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 }))
})
})

View file

@ -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<string, Listener[]>
sent: Array<{ channel: string; data: unknown }>
on: (event: string, listener: Listener) => void
send: (channel: string, data: unknown) => void
isDestroyed: () => boolean
insertCSS: () => Promise<string>
invalidate: () => void
setWindowOpenHandler: () => void
}
interface FakeWindow {
options: Record<string, unknown>
webContents: FakeWebContents
bounds: Array<{ x: number; y: number; width: number; height: number }>
visible: boolean
showInactive: ReturnType<typeof vi.fn>
setIgnoreMouseEvents: ReturnType<typeof vi.fn>
}
const state = vi.hoisted(() => ({
windows: [] as FakeWindow[],
ipcOn: new Map<string, Listener[]>(),
scale: 1.5
}))
vi.unmock('../../../src/main/windows/WindowManager')
vi.mock('electron', () => {
class FakeBrowserWindow {
options: Record<string, unknown>
bounds: Array<{ x: number; y: number; width: number; height: number }> = []
visible = false
destroyed = false
windowListeners = new Map<string, Listener>()
webContents: FakeWebContents = {
listeners: new Map<string, Listener[]>(),
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<string, unknown>) {
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<string, unknown> = {
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<string, Listener> }
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 })
})
})

View file

@ -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
})
})
})

View file

@ -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> = {}): 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<SuggestionStepInput> = {}, policyOverrides: Partial<SuggestionPolicyInput> = {}) {
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)
})
})

View file

@ -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

View file

@ -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 엣지의 근거). */

View file

@ -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 }
}

View file

@ -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}`
}

View file

@ -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
}