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

View file

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

View file

@ -14,26 +14,29 @@ import { EventEmitter } from 'events'
import { import {
SUGGESTION_DEFAULTS, SUGGESTION_DEFAULTS,
SUGGESTION_MAX_OUTPUT_CHARS, SUGGESTION_MAX_OUTPUT_CHARS,
buildLocalSuggestionCandidates, buildLocalSuggestionCandidateEntries,
decideInFlightGeneration,
decideSuggestion, decideSuggestion,
decideSuggestionRefresh,
decideSuggestionStep, decideSuggestionStep,
isAppExcluded, isAppExcluded,
joinSuggestion,
matchesSessionPrefix,
normalizeRequestPrefix, normalizeRequestPrefix,
normalizeSessionPrefix, normalizeSessionPrefix,
parseSuggestionCandidates, parseSuggestionCandidates,
sanitizeSuggestionLine, sanitizeSuggestionLine,
selectPhraseHints, selectPhraseHints,
shouldPersistSuggestionPrefix,
isTerminalApp, isTerminalApp,
extractTerminalPromptPrefix, extractTerminalPromptPrefix,
type AnchorKind, type AnchorKind,
type PhysicalRect,
type SuggestionCandidate, type SuggestionCandidate,
type SuggestionPolicyInput, type SuggestionPolicyInput,
type SuggestionProvenance, type SuggestionProvenance,
type SuggestionSkipReason, type SuggestionSkipReason,
type SuggestionState, type SuggestionState,
type SuggestionStep, type SuggestionStep
type UiRect
} from '@d3ro/core/input-intelligence' } from '@d3ro/core/input-intelligence'
import { configGet, configSet } from './ConfigService' import { configGet, configSet } from './ConfigService'
import { getLogger } from './LoggerService' import { getLogger } from './LoggerService'
@ -143,7 +146,8 @@ const WARM_UP_FAILURE_BACKOFF_MS = SUGGESTION_DEFAULTS.minIntervalMs
export class SuggestionService extends EventEmitter { export class SuggestionService extends EventEmitter {
private _candidates: SuggestionCandidate[] = [] private _candidates: SuggestionCandidate[] = []
private _activeIndex = 0 private _activeIndex = 0
private _anchor: UiRect | null = null /** 물리 픽셀 그대로 보관한다 — DIP 변환은 창 배치(WindowManager) 한 곳에서만 한다. */
private _anchor: PhysicalRect | null = null
/** anchor 가 케어렛인지 요소 전체인지 — 오버레이가 배치 전략을 고르는 근거 */ /** anchor 가 케어렛인지 요소 전체인지 — 오버레이가 배치 전략을 고르는 근거 */
private _anchorKind: AnchorKind = null private _anchorKind: AnchorKind = null
private _appName: string | null = null private _appName: string | null = null
@ -472,7 +476,9 @@ export class SuggestionService extends EventEmitter {
userDismissedQuiet: now < this._userDismissedUntil, userDismissedQuiet: now < this._userDismissedUntil,
lastRequestedPrefix: this._lastRequestedPrefix, lastRequestedPrefix: this._lastRequestedPrefix,
modelWarm: this._warmth.isWarm(model, now), 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) this._executeStep(step, context, config, now)
} }
@ -541,8 +547,9 @@ export class SuggestionService extends EventEmitter {
this._showWarming(context) this._showWarming(context)
break break
case 'show-warming': case 'show-warming':
// 모델이 아직 안 떠 있으면 "준비 중" 을 보여준다 — 아무 반응이 없으면 // 워밍업 루프가 서버를 기다리는 중이면 "준비 중" 을 보여준다 — 아무 반응이 없으면
// 사용자는 기능이 죽었다고 판단한다(첫 실행에서 실제로 그랬다). // 사용자는 기능이 죽었다고 판단한다(첫 실행에서 실제로 그랬다). 준비 작업이 없으면
// core 가 'record' 를 준다(끝나지 않는 스피너 방지).
this._showWarming(context) this._showWarming(context)
break break
} }
@ -689,13 +696,16 @@ export class SuggestionService extends EventEmitter {
if (!this._isCurrentFallbackContext(prefix, context, token)) return false if (!this._isCurrentFallbackContext(prefix, context, token)) return false
const trimmedPrefix = normalizeSessionPrefix(prefix) const trimmedPrefix = normalizeSessionPrefix(prefix)
const localMemory = memory ?? this._collectMemoryHints(trimmedPrefix, context.appName) const localMemory = memory ?? this._collectMemoryHints(trimmedPrefix, context.appName)
const candidates = buildLocalSuggestionCandidates(trimmedPrefix, localMemory, maxCandidates, maxChars) const entries = buildLocalSuggestionCandidateEntries(trimmedPrefix, localMemory, maxCandidates, maxChars)
if (candidates.length === 0) return false if (entries.length === 0) return false
this._partialText = '' this._partialText = ''
this._generating = false this._generating = false
this._warmingUp = 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._activeIndex = 0
this._anchor = context.anchor this._anchor = context.anchor
this._anchorKind = context.anchorKind this._anchorKind = context.anchorKind
@ -703,7 +713,7 @@ export class SuggestionService extends EventEmitter {
this._windowTitle = context.windowTitle this._windowTitle = context.windowTitle
this._windowHandle = context.windowHandle ?? null this._windowHandle = context.windowHandle ?? null
this._generatedForPrefix = trimmedPrefix this._generatedForPrefix = trimmedPrefix
this._targetTotal = candidates.length this._targetTotal = entries.length
this._lastSkipReason = null this._lastSkipReason = null
this._lastLatencyMs = latencyMs this._lastLatencyMs = latencyMs
this._provenance = { this._provenance = {
@ -715,15 +725,15 @@ export class SuggestionService extends EventEmitter {
} }
this._recordSuggestion({ this._recordSuggestion({
prefix: trimmedPrefix, prefix: trimmedPrefix,
text: candidates[0], text: entries[0].text,
model: 'local-memory', model: 'local-memory',
latencyMs, latencyMs,
candidateCount: candidates.length candidateCount: entries.length
}) })
this._armVisibleTtl() this._armVisibleTtl()
this.emit('updated', this.getState()) this.emit('updated', this.getState())
this.emit('state-changed', this.getState()) this.emit('state-changed', this.getState())
logger.info(`로컬 기억 제안 ${candidates.length}개 생성`) logger.info(`로컬 기억 제안 ${entries.length}개 생성`)
return true return true
} }
@ -929,6 +939,18 @@ export class SuggestionService extends EventEmitter {
return 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) const candidates = parseSuggestionCandidates(raw, trimmedPrefix, 1, maxChars)
if (candidates.length === 0) { if (candidates.length === 0) {
this._lastSkipReason = 'generation-failed' this._lastSkipReason = 'generation-failed'
@ -1208,7 +1230,8 @@ export class SuggestionService extends EventEmitter {
private _abortStaleGeneration(currentPrefix: string): void { private _abortStaleGeneration(currentPrefix: string): void {
if (!this._inFlight || !this._abort || this._abort.signal.aborted) return 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 if (refresh === 'keep') return
this._generationToken += 1 this._generationToken += 1
@ -1327,7 +1350,9 @@ export class SuggestionService extends EventEmitter {
logger.info(`제안 수락 무시: 표시 중인 후보 없음 (index=${index ?? 'active'})`) logger.info(`제안 수락 무시: 표시 중인 후보 없음 (index=${index ?? 'active'})`)
return { ok: false, reason: 'already-visible' } 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 appName = this._appName
const windowTitle = this._windowTitle const windowTitle = this._windowTitle
const targetWindow = this._windowHandle const targetWindow = this._windowHandle
@ -1375,12 +1400,25 @@ export class SuggestionService extends EventEmitter {
} }
logger.info(`제안 수락: ${text.length}자 삽입 (method=${method === 'keyboard' ? 'keyboard' : 'clipboard'}, app=${appName ?? '-'})`) 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 } 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[] { getHistory(limit = 50): SuggestionHistoryEntry[] {
return this._repository.list(limit) return this._repository.list(limit)
} }
@ -1408,8 +1446,16 @@ export class SuggestionService extends EventEmitter {
} }
} }
/**
* 제안 이력 기록 — 접두(사용자가 친 글 원문)는 학습 동의가 있고 학습 제외 앱이 아닐 때만
* 남긴다(core shouldPersistSuggestionPrefix). 그 밖에는 빈 문자열로 남겨 개수·수락률만 유지한다.
*/
private _recordSuggestion(input: Omit<SuggestionRecordInput, 'appName'>): void { 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 { join } from 'path'
import { is } from '@electron-toolkit/utils' import { is } from '@electron-toolkit/utils'
import { WINDOW_SIZE } from '@d3ro/core/constants' 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 { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { import {
formatBindingSegments, formatBindingSegments,
@ -18,26 +28,22 @@ import { getLogger } from '../services/LoggerService'
import { getIsQuitting, setIsQuitting } from '../lifecycle' import { getIsQuitting, setIsQuitting } from '../lifecycle'
import { hardenWebContents } from './web-contents-hardening' import { hardenWebContents } from './web-contents-hardening'
import { configGet, configSet } from '../services/ConfigService' import { configGet, configSet } from '../services/ConfigService'
import { buildPopupThemeCss } from '@d3ro/ui/theme-vars'
import { getI18n } from '@d3ro/i18n' 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') 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 문자열 가져오기 ─────────────────────────────
/** /**
* 팝업에 주입할 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 */ let screenGeometry: ScreenGeometryPort | null = null
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 }>>()
/** /** Electron screen 은 app ready 뒤에만 쓸 수 있다 — 처음 배치할 때 만든다. */
* Promote popup renderer console output and load failures into the main log. function getScreenGeometry(): ScreenGeometryPort {
* Popups have no renderer logging otherwise, so a missing asset or a script if (!screenGeometry) screenGeometry = createElectronScreenGeometry(screen)
* exception stays invisible and the popup just renders static markup. return screenGeometry
*/
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})`)
})
} }
/** /** 커서 기준 배치 (녹음 팁 · 결과 · 기록 · 명령 팝업 공통) — 커서와 작업 영역 모두 DIP. */
* Deliver IPC to a popup even while its renderer is still loading. function placePopupNearCursor(
* webContents.send before load is silently dropped, which leaves popups stuck win: BrowserWindow,
* on their initial markup (for example a frozen 0:00 timer). size: UiSize,
*/ cursor: DipPoint = getScreenGeometry().cursor()
function sendToPopupWindow(win: BrowserWindow, channel: string, data: unknown): void { ): void {
if (win.isDestroyed() || win.webContents.isDestroyed()) return const position = placeNearCursor(cursor, size, getScreenGeometry().workAreaNear(cursor))
win.setBounds({ x: position.x, y: position.y, width: size.width, height: size.height })
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)}`)
}
} }
// ── 윈도우 참조 ─────────────────────────────────────── // ── 윈도우 참조 ───────────────────────────────────────
@ -392,39 +326,13 @@ export function createMainWindow(): BrowserWindow {
// ── RecordingTip 팝업 ───────────────────────────────── // ── RecordingTip 팝업 ─────────────────────────────────
function createRecordingTipWindow(): BrowserWindow { function createRecordingTipWindow(): BrowserWindow {
const win = new BrowserWindow({ return createPopupWindow({
width: WINDOW_SIZE.RECORDING_TIP.width, name: 'recording-tip',
height: WINDOW_SIZE.RECORDING_TIP.height, bounds: { width: WINDOW_SIZE.RECORDING_TIP.width, height: WINDOW_SIZE.RECORDING_TIP.height },
show: false, onClosed: () => {
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
}
})
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 recordingTipWindow = null
}
}) })
return win
} }
export function getRecordingTipWindow(): BrowserWindow { export function getRecordingTipWindow(): BrowserWindow {
@ -451,23 +359,8 @@ export function showRecordingTip(
): void { ): void {
const win = getRecordingTipWindow() const win = getRecordingTipWindow()
// 커서 위치에 배치 // 커서 위치에 배치 (화면 밖 보정 포함)
const cursorPos = screen.getCursorScreenPoint() placePopupNearCursor(win, { width: TIP_WIDTH, height: TIP_HEIGHT })
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 })
// 상태 전송 후 즉시 show (2-phase 제거 — 숨겨진 윈도우의 렌더러 비활성 문제 방지) // 상태 전송 후 즉시 show (2-phase 제거 — 숨겨진 윈도우의 렌더러 비활성 문제 방지)
sendToPopupWindow(win, IPC_CHANNELS.WINDOW.TIP_STATE_CHANGED, { state, _i18n: getPopupI18nStrings(), ...params }) sendToPopupWindow(win, IPC_CHANNELS.WINDOW.TIP_STATE_CHANGED, { state, _i18n: getPopupI18nStrings(), ...params })
@ -506,38 +399,14 @@ export function sendPartialTranscriptToTip(text: string): void {
// ── ResultPopup 팝업 ────────────────────────────────── // ── ResultPopup 팝업 ──────────────────────────────────
function createResultPopupWindow(): BrowserWindow { function createResultPopupWindow(): BrowserWindow {
const win = new BrowserWindow({ return createPopupWindow({
width: WINDOW_SIZE.RESULT_POPUP.width, name: 'result-popup',
height: WINDOW_SIZE.RESULT_POPUP.height, bounds: { width: WINDOW_SIZE.RESULT_POPUP.width, height: WINDOW_SIZE.RESULT_POPUP.height },
show: false, onClosed: () => {
frame: false, cancelPendingResultShow()
transparent: true,
resizable: false,
alwaysOnTop: true,
skipTaskbar: true,
focusable: false,
webPreferences: {
preload: join(__dirname, '../preload/popup.js'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false
}
})
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 resultPopupWindow = null
}
}) })
return win
} }
export function getResultPopupWindow(): BrowserWindow { export function getResultPopupWindow(): BrowserWindow {
@ -548,39 +417,96 @@ export function getResultPopupWindow(): BrowserWindow {
return resultPopupWindow 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 리사이즈) * ResultPopup 표시 (2-phase 리사이즈)
*
* Phase 1: 기본 크기로 커서 곁에 먼저 띄운다 — 내용은 SHOW 전까지 투명하다. 창이 보여야
* 렌더러가 측정 프레임을 돈다(숨긴 창의 rAF 정지 방지).
* Phase 2: 측정이 오면(또는 한도가 지나면) 크기를 맞추고 내용을 보인다.
* 측정 응답은 모듈 단일 리스너가 받는다 — 호출마다 ipcMain.once 를 쌓지 않는다.
*/ */
export function showResultPopup(text: string, autoHideMs = 5000): void { export function showResultPopup(text: string, autoHideMs = 5000): void {
const win = getResultPopupWindow() 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() }) sendToPopupWindow(win, IPC_CHANNELS.POPUP_RESULT.PREPARE, { text, _i18n: getPopupI18nStrings() })
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) presentPopup(win)
// Phase 2: show const fallbackTimer = setTimeout(() => finishResultShow(null), RESULT_MEASURE_TIMEOUT_MS)
sendToPopupWindow(win, IPC_CHANNELS.POPUP_RESULT.SHOW, { autoHideMs }) 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 { export function hideResultPopup(): void {
if (resultPopupWindow && !resultPopupWindow.isDestroyed()) { if (resultPopupWindow && !resultPopupWindow.isDestroyed()) {
resultPopupWindow.hide() resultPopupWindow.hide()
@ -590,38 +516,13 @@ export function hideResultPopup(): void {
// ── HistoryPopup 팝업 ───────────────────────────────── // ── HistoryPopup 팝업 ─────────────────────────────────
function createHistoryPopupWindow(): BrowserWindow { function createHistoryPopupWindow(): BrowserWindow {
const win = new BrowserWindow({ return createPopupWindow({
width: 360, name: 'history-popup',
height: 400, bounds: { width: 360, height: 400 },
show: false, onClosed: () => {
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
}
})
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 historyPopupWindow = null
}
}) })
return win
} }
export function getHistoryPopupWindow(): BrowserWindow { export function getHistoryPopupWindow(): BrowserWindow {
@ -636,21 +537,7 @@ export function showHistoryPopup(entries: Array<Record<string, unknown>>): void
const win = getHistoryPopupWindow() const win = getHistoryPopupWindow()
// 커서 위치에 즉시 배치+표시 (2-phase 제거 — 숨겨진 윈도우 렌더러 비활성 문제 방지) // 커서 위치에 즉시 배치+표시 (2-phase 제거 — 숨겨진 윈도우 렌더러 비활성 문제 방지)
const cursorPos = screen.getCursorScreenPoint() placePopupNearCursor(win, { width: 380, height: Math.min(60 + entries.length * 44, 500) })
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 })
sendToPopupWindow(win, IPC_CHANNELS.POPUP_HISTORY.SHOW_ITEMS, { entries, _i18n: getPopupI18nStrings() }) sendToPopupWindow(win, IPC_CHANNELS.POPUP_HISTORY.SHOW_ITEMS, { entries, _i18n: getPopupI18nStrings() })
@ -683,35 +570,13 @@ export function isHistoryPopupVisible(): boolean {
// ── CommandPopup 팝업 ───────────────────────────────── // ── CommandPopup 팝업 ─────────────────────────────────
function createCommandPopupWindow(): BrowserWindow { function createCommandPopupWindow(): BrowserWindow {
const win = new BrowserWindow({ return createPopupWindow({
width: 340, name: 'command-popup',
height: 300, bounds: { width: 340, height: 300 },
show: false, onClosed: () => {
frame: false, commandPopupWindow = null
transparent: true,
resizable: false,
alwaysOnTop: true,
skipTaskbar: true,
focusable: false,
webPreferences: {
preload: join(__dirname, '../preload/popup.js'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false
} }
}) })
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 { export function getCommandPopupWindow(): BrowserWindow {
@ -723,19 +588,7 @@ export function getCommandPopupWindow(): BrowserWindow {
export function showCommandPopup(commands: Array<Record<string, unknown>>, activeId: string | null): void { export function showCommandPopup(commands: Array<Record<string, unknown>>, activeId: string | null): void {
const win = getCommandPopupWindow() const win = getCommandPopupWindow()
const cursorPos = screen.getCursorScreenPoint() placePopupNearCursor(win, { width: 340, height: Math.min(80 + commands.length * 48, 400) })
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 })
sendToPopupWindow(win, IPC_CHANNELS.POPUP_COMMAND.SHOW_ITEMS, { commands, activeId, _i18n: getPopupI18nStrings() }) sendToPopupWindow(win, IPC_CHANNELS.POPUP_COMMAND.SHOW_ITEMS, { commands, activeId, _i18n: getPopupI18nStrings() })
presentPopup(win) presentPopup(win)
@ -793,42 +646,17 @@ function initialCaptionOverlayBounds(): Electron.Rectangle {
} }
function createCaptionOverlayWindow(): BrowserWindow { function createCaptionOverlayWindow(): BrowserWindow {
const bounds = initialCaptionOverlayBounds() return createPopupWindow({
name: 'caption-overlay',
const win = new BrowserWindow({ bounds: initialCaptionOverlayBounds(),
...bounds, // 표시 중에는 항상 보이는 창이다 — 기존 기본값(늦춤)을 유지한다.
show: false, backgroundThrottling: true,
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
}
})
hardenWebContents(win)
// 클릭 통과: 마우스 이벤트를 무시하되, CSS hover 등을 위해 forward 활성화 // 클릭 통과: 마우스 이벤트를 무시하되, CSS hover 등을 위해 forward 활성화
win.setIgnoreMouseEvents(true, { forward: true }) beforeLoad: (win) => win.setIgnoreMouseEvents(true, { forward: true }),
onClosed: () => {
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 captionOverlayWindow = null
}
}) })
return win
} }
export function getCaptionOverlayWindow(): BrowserWindow { export function getCaptionOverlayWindow(): BrowserWindow {
@ -916,21 +744,18 @@ export function resetCaptionOverlayPosition(): void {
if (win && !win.isDestroyed()) win.setBounds(defaultCaptionOverlayBounds()) if (win && !win.isDestroyed()) win.setBounds(defaultCaptionOverlayBounds())
} }
// ── SuggestionOverlay 업 (입력 인텔리전스) ────────── // ── SuggestionOverlay (입력 인텔리전스) ──────────────
/** /**
* 다음 문장 제안 ghost text 오버레이. * 다음 문장 제안 ghost text 오버레이.
* *
* IME 후보창과 같은 관례를 따른다: 케어/포커스 요소 바로 아래에 붙고, * IME 후보창과 같은 관례를 따른다: 캐럿/포커스 요소 바로 아래에 붙고,
* 포커스를 막지 않으며(focusable:false) 기본은 클릭 통과다. * 포커스를 막지 않는다(focusable:false).
* (KeyType.Windows 의 WS_EX_TRANSPARENT|WS_EX_NOACTIVATE 오버레이와 동일한 전략) * (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 { function applySuggestionOverlayMouseMode(win: BrowserWindow): void {
const interactive = configGet('suggestionOverlayInteractive') !== false const interactive = configGet('suggestionOverlayInteractive') !== false
@ -949,41 +774,16 @@ function applySuggestionOverlayMouseMode(win: BrowserWindow): void {
} }
function createSuggestionOverlayWindow(): BrowserWindow { function createSuggestionOverlayWindow(): BrowserWindow {
const win = new BrowserWindow({ return createPopupWindow({
width: SUGGESTION_OVERLAY_WIDTH, name: 'suggestion-overlay',
height: SUGGESTION_OVERLAY_HEIGHT, bounds: { width: SUGGESTION_OVERLAY_WIDTH, height: SUGGESTION_OVERLAY_MAX_HEIGHT },
show: false, beforeLoad: applySuggestionOverlayMouseMode,
frame: false, onClosed: () => {
transparent: true, suggestionOverlayPlacement = null
resizable: false, suggestionOverlayBounds = null
alwaysOnTop: true,
skipTaskbar: true,
focusable: false,
webPreferences: {
preload: join(__dirname, '../preload/popup.js'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false,
backgroundThrottling: false
}
})
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 suggestionOverlayWindow = null
}
}) })
return win
} }
export function getSuggestionOverlayWindow(): BrowserWindow { export function getSuggestionOverlayWindow(): BrowserWindow {
@ -1016,37 +816,72 @@ interface SuggestionOverlayContent {
provenance?: SuggestionOverlayProvenance | null 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( export function showSuggestionOverlay(
payload: SuggestionOverlayContent & { payload: SuggestionOverlayContent & {
anchor: { x: number; y: number; width: number; height: number } | null anchor: PhysicalRect | null
anchorKind?: AnchorKind anchorKind?: AnchorKind
appName: string | null appName: string | null
} }
): void { ): void {
const win = getSuggestionOverlayWindow() const win = getSuggestionOverlayWindow()
const cursor = screen.getCursorScreenPoint() suggestionOverlayPlacement = placeAnchoredPanel(
const anchorPoint = payload.anchor getScreenGeometry(),
? { x: payload.anchor.x, y: payload.anchor.y + payload.anchor.height }
: cursor
const display = screen.getDisplayNearestPoint(anchorPoint)
const position = anchorFloatingPanel(
payload.anchor, payload.anchor,
payload.anchorKind ?? null, payload.anchorKind ?? null,
cursor, { width: SUGGESTION_OVERLAY_WIDTH, height: SUGGESTION_OVERLAY_MAX_HEIGHT }
{ width: SUGGESTION_OVERLAY_WIDTH, height: SUGGESTION_OVERLAY_HEIGHT },
display.workArea
) )
suggestionOverlayBounds = null
win.setBounds({ fitSuggestionOverlay(win, payload, () => sendToPopupWindow(win, IPC_CHANNELS.POPUP_SUGGESTION.SHOW, {
x: position.x,
y: position.y,
width: SUGGESTION_OVERLAY_WIDTH,
height: SUGGESTION_OVERLAY_HEIGHT
})
sendToPopupWindow(win, IPC_CHANNELS.POPUP_SUGGESTION.SHOW, {
candidates: payload.candidates, candidates: payload.candidates,
activeIndex: payload.activeIndex, activeIndex: payload.activeIndex,
targetTotal: payload.targetTotal ?? payload.candidates.length, targetTotal: payload.targetTotal ?? payload.candidates.length,
@ -1057,22 +892,27 @@ export function showSuggestionOverlay(
provenance: payload.provenance ?? null, provenance: payload.provenance ?? null,
_i18n: getPopupI18nStrings(), _i18n: getPopupI18nStrings(),
_keyHints: buildSuggestionKeyHints() _keyHints: buildSuggestionKeyHints()
}) }))
presentPopup(win, 'screen-saver') presentPopup(win, 'screen-saver')
} }
export function updateSuggestionOverlay(payload: SuggestionOverlayContent): void { export function updateSuggestionOverlay(payload: SuggestionOverlayContent): void {
if (suggestionOverlayWindow && !suggestionOverlayWindow.isDestroyed()) { const win = suggestionOverlayWindow
sendToPopupWindow(suggestionOverlayWindow, IPC_CHANNELS.POPUP_SUGGESTION.UPDATE, { if (win && !win.isDestroyed()) {
fitSuggestionOverlay(win, payload, () =>
sendToPopupWindow(win, IPC_CHANNELS.POPUP_SUGGESTION.UPDATE, {
...payload, ...payload,
targetTotal: payload.targetTotal ?? payload.candidates.length, targetTotal: payload.targetTotal ?? payload.candidates.length,
_keyHints: buildSuggestionKeyHints() _keyHints: buildSuggestionKeyHints()
}) })
)
} }
} }
export function hideSuggestionOverlay(): void { export function hideSuggestionOverlay(): void {
suggestionOverlayPlacement = null
suggestionOverlayBounds = null
if (suggestionOverlayWindow && !suggestionOverlayWindow.isDestroyed()) { if (suggestionOverlayWindow && !suggestionOverlayWindow.isDestroyed()) {
sendToPopupWindow(suggestionOverlayWindow, IPC_CHANNELS.POPUP_SUGGESTION.HIDE, {}) sendToPopupWindow(suggestionOverlayWindow, IPC_CHANNELS.POPUP_SUGGESTION.HIDE, {})
suggestionOverlayWindow.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 폴백을 둔다. * 토큰이 주입되지 않는 상황(개발 초기 로드)을 위해 :root 폴백을 둔다.
* *
* 구조: [후보 3개 — 고정 높이] / 가는 선 / [진행률 · 출처] / [키 안내] * 구조: [후보 3개 — 고정 높이] / 가는 선 / [진행률 · 출처] / [키 안내]
* 창 높이(WindowManager SUGGESTION_OVERLAY_HEIGHT)는 이 고정 치수에서 나온다. * 창 높이는 이 고정 치수에서 계산한다(main/windows/suggestion-overlay-geometry.ts) — 창을
* 그려진 패널 높이로 맞춰 투명 여백이 클릭을 삼키지 않게 한다. 높이에 영향을 주는 치수
* (#root/패널 패딩·테두리, .status, .suggestion-item, .candidates gap, .footer)를 바꾸면
* 그 파일도 함께 고친다. 각 줄은 내용과 무관하게 높이를 고정한다.
*/ */
:root { :root {
@ -93,7 +96,7 @@ body {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
min-height: 32px; height: 32px;
padding: 0 28px 0 8px; padding: 0 28px 0 8px;
} }
@ -219,6 +222,7 @@ body {
.footer-row { .footer-row {
display: flex; display: flex;
height: 14px;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
@ -252,6 +256,7 @@ body {
.key-hints { .key-hints {
display: flex; display: flex;
height: 16px;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
white-space: nowrap; white-space: nowrap;

View file

@ -324,8 +324,9 @@ describe('버그 6: 수락 직전 대상 창 확인', () => {
const result = await service.accept() const result = await service.accept()
expect(result).toEqual({ ok: true }) expect(result).toEqual({ ok: true })
expect(harness.insertText).toHaveBeenCalledWith('다음 단계도 확인하겠습니다.', 'clipboard') // 접두('…정리해서')와 후보 사이에 구분 공백을 붙여 넣는다(joinSuggestion).
expect(fake.expectInsert).toHaveBeenCalledWith('다음 단계도 확인하겠습니다.') expect(harness.insertText).toHaveBeenCalledWith(' 다음 단계도 확인하겠습니다.', 'clipboard')
expect(fake.expectInsert).toHaveBeenCalledWith(' 다음 단계도 확인하겠습니다.')
expect(fake.recordAccepted).toHaveBeenCalledTimes(1) expect(fake.recordAccepted).toHaveBeenCalledTimes(1)
expect(fake.cancelInsert).not.toHaveBeenCalled() 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, SENTENCE_TERMINATORS,
SUGGESTION_MAX_OUTPUT_CHARS, SUGGESTION_MAX_OUTPUT_CHARS,
normalizeRequestPrefix, normalizeRequestPrefix,
normalizeSessionPrefix normalizeSessionPrefix,
type SuggestionJoint
} from './suggestion-text' } from './suggestion-text'
import { NON_PROSE_GLYPH_PATTERN } from './terminal-prompt' import { NON_PROSE_GLYPH_PATTERN } from './terminal-prompt'
import type { AnchorKind, PhysicalRect } from './popup-placement'
import { countLexicalWords } from './text-segmentation'
export { export {
SUGGESTION_CONTEXT_MAX_CHARS, SUGGESTION_CONTEXT_MAX_CHARS,
SUGGESTION_MAX_OUTPUT_CHARS, SUGGESTION_MAX_OUTPUT_CHARS,
buildLocalSuggestionCandidateEntries,
buildLocalSuggestionCandidates, buildLocalSuggestionCandidates,
endsSentence, endsSentence,
joinSuggestion,
normalizeRequestPrefix, normalizeRequestPrefix,
normalizeSessionPrefix, normalizeSessionPrefix,
normalizeSuggestionText, normalizeSuggestionText,
@ -38,29 +43,27 @@ export {
sanitizeSuggestionLine, sanitizeSuggestionLine,
stripModelArtifacts stripModelArtifacts
} from './suggestion-text' } from './suggestion-text'
export type { LocalSuggestionHints } from './suggestion-text' export type { LocalSuggestionCandidate, LocalSuggestionHints, SuggestionJoint } from './suggestion-text'
export { extractTerminalPromptPrefix } from './terminal-prompt' export { extractTerminalPromptPrefix } from './terminal-prompt'
// ============================================================ // ============================================================
// 기하 // 기하 — 정본은 popup-placement.ts (좌표 공간 · 배치 정책). 기존 import 경로를 유지한다.
// ============================================================ // ============================================================
export interface UiRect { export {
x: number anchorFloatingPanel,
y: number anchorFloatingPanelInDips,
width: number asDipPoint,
height: number asDipRect
} } from './popup-placement'
export type {
/** AnchorKind,
* 앵커 rect 가 케어렛(한 줄 위치)인지 포커스 요소 전체인지. DipPoint,
* DipRect,
* 케어렛을 못 주는 제공자가 많아(Chrome, Windows Terminal 등 caret=-1) 그때는 PhysicalRect,
* elementRect 로 폴백하는데, 두 경우는 배치 전략이 달라야 한다 — 케어렛은 "그 ScreenGeometryPort,
* 줄 아래" 에 붙이면 되지만, elementRect(멀티라인/큰 입력창 전체)는 그 규칙을 UiRect
* 그대로 쓰면 텍스트와 멀리 떨어지거나 텍스트 위에 겹친다. } from './popup-placement'
*/
export type AnchorKind = 'caret' | 'element' | null
/** 마우스 이동 누적 전에 무시할 최소 이동량(px). 미세抖动 노이즈 제거. */ /** 마우스 이동 누적 전에 무시할 최소 이동량(px). 미세抖动 노이즈 제거. */
export const MOUSE_MOVE_NOISE_FLOOR_PX = 2 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) 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 를 포함한 것만 센다. * 공백 분리 토큰 중 문자/숫자/CJK 를 포함한 것만 센다.
* (중국어처럼 공백이 없는 언어는 과소 집계된다 — GAP-SUGGEST-03 에 기록) * (중국어처럼 공백이 없는 언어는 과소 집계된다 — 활동 통계 전용. 학습 필터는
* text-segmentation.ts 의 countLexicalWords 로 센다)
*/ */
export function countWords(text: string): number { export function countWords(text: string): number {
if (!text) return 0 if (!text) return 0
@ -348,8 +259,9 @@ export interface FocusSnapshot {
textSource: FocusTextSource textSource: FocusTextSource
text: string text: string
caretOffset: number | null caretOffset: number | null
caretRect: UiRect | null /** 물리 픽셀 (UIA 는 DPI-aware 로 동작한다) — 배치 전에 ScreenGeometryPort 로 DIP 변환 */
elementRect: UiRect | null caretRect: PhysicalRect | null
elementRect: PhysicalRect | null
windowTitle: string | null windowTitle: string | null
appName: string | null appName: string | null
processId: number | 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) 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 modelWarm: boolean
/** 직전 워밍업이 실패해 재시도를 잠시 미루는 중인가 */ /** 직전 워밍업이 실패해 재시도를 잠시 미루는 중인가 */
warmUpBackoff: boolean warmUpBackoff: boolean
/**
* 모델 서버를 실제로 준비하는 작업(워밍업 루프)이 진행 중인가.
*
* 서버가 없을 때(model-unavailable) "준비 중" 은 이 작업이 있을 때만 보여준다 — 아무것도
* 준비하지 않는데 스피너를 띄우면 끝나지 않는 "준비 중" 이 옛 캐럿 위에 20초씩 반복된다
* (Ollama 미설치/중지). 생략하면 예전처럼 "준비 중" 을 보여준다(호환). 서비스는 항상 넘긴다.
*/
modelStarting?: boolean
} }
/** /**
@ -723,7 +655,8 @@ export function decideSuggestionStep(input: SuggestionStepInput): SuggestionStep
action: 'settle', action: 'settle',
reason: 'model-unavailable', reason: 'model-unavailable',
tryLocalMemory: localMemoryAllowed, tryLocalMemory: localMemoryAllowed,
then: 'show-warming' // 준비 작업이 없으면 기능은 꺼진 것처럼 조용해야 한다 (설정 문구: "제안이 꺼진 상태로 동작").
then: input.modelStarting === false ? 'record' : 'show-warming'
} }
} }
return { action: 'settle', reason: decision.reason, tryLocalMemory: false, then: 'record' } 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' '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 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() const phrase = segment.replace(/\s+/gu, ' ').trim()
if (phrase.length < minChars) continue if (phrase.length < minChars) continue
if (phrase.length > SUGGESTION_MAX_OUTPUT_CHARS) 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 if (out.includes(phrase)) continue
out.push(phrase) out.push(phrase)
if (out.length >= maxPhrases) break if (out.length >= maxPhrases) break
@ -1170,6 +1119,8 @@ export interface SuggestionCandidate {
text: string text: string
/** 0 = 최상위 */ /** 0 = 최상위 */
rank: number rank: number
/** 접두와의 이음새 — 생략하면 'separate' (joinSuggestion 참고) */
joint?: SuggestionJoint
} }
/** 현재 제안이 어느 로컬 근거에서 나왔는지 보여 주는 비식별 설명. */ /** 현재 제안이 어느 로컬 근거에서 나왔는지 보여 주는 비식별 설명. */
@ -1200,8 +1151,8 @@ export interface SuggestionState {
* UI 가 "4–6 / 9" 같은 진행률을 보여주는 근거. * UI 가 "4–6 / 9" 같은 진행률을 보여주는 근거.
*/ */
targetTotal: number targetTotal: number
/** 앵커 rect (케어렛 → 요소 → 마우스 폴백은 메인이 계산) */ /** 앵커 rect — 물리 픽셀 (케어렛 → 요소 → 마우스 폴백은 메인이 계산, DIP 변환은 창 배치 한 곳에서) */
anchor: UiRect | null anchor: PhysicalRect | null
/** 앵커가 케어렛인지 요소 전체인지 — 배치 전략(caret vs element)을 결정한다 */ /** 앵커가 케어렛인지 요소 전체인지 — 배치 전략(caret vs element)을 결정한다 */
anchorKind: AnchorKind anchorKind: AnchorKind
appName: string | null appName: string | null

View file

@ -8,8 +8,8 @@
// //
// 전부 순수 함수로 두어 LLM/DB 없이 검증 가능하게 한다. // 전부 순수 함수로 두어 LLM/DB 없이 검증 가능하게 한다.
import { countWords } from './input-intelligence'
import type { PhraseSource } from './input-intelligence' import type { PhraseSource } from './input-intelligence'
import { countLexicalWords, getWordSegmenter } from './text-segmentation'
// 렌더러/프리로드도 같은 계약을 쓴다 (main 모듈을 import 할 수 없다). // 렌더러/프리로드도 같은 계약을 쓴다 (main 모듈을 import 할 수 없다).
export type { PhraseSource } 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 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[] { function segmentUnspacedToken(token: string): string[] {
if (!UNSPACED_SCRIPT.test(token)) return [token] if (!UNSPACED_SCRIPT.test(token)) return [token]
@ -164,12 +152,17 @@ export function extractTerms(text: string, limit = 8): string[] {
.map(([token]) => token) .map(([token]) => token)
} }
/** 문장 단위로 쪼갠다 (종결 부호 + 개행). 2단어 이상만 남긴다. */ /**
* 문장 단위로 쪼갠다 (종결 부호 + 개행). 2단어 이상만 남긴다.
*
* 단어 수는 countLexicalWords 로 센다 — 공백으로만 세면 띄어쓰기 없는 중국어·일본어·태국어
* 문장이 모두 1단어가 되어 그 언어 사용자의 문장이 하나도 학습되지 않는다.
*/
export function splitSentences(text: string): string[] { export function splitSentences(text: string): string[] {
return text return text
.split(/[.!?。!?…\n]+/u) .split(/[.!?。!?…\n]+/u)
.map((segment) => segment.replace(/\s+/gu, ' ').trim()) .map((segment) => segment.replace(/\s+/gu, ' ').trim())
.filter((segment) => segment.length >= 4 && countWords(segment) >= 2) .filter((segment) => segment.length >= 4 && countLexicalWords(segment) >= 2)
} }
/** 한 텍스트 안에서 인접한 문장 쌍 (follows 엣지의 근거). */ /** 한 텍스트 안에서 인접한 문장 쌍 (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 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, limit = 3,
maxChars = SUGGESTION_MAX_OUTPUT_CHARS maxChars = SUGGESTION_MAX_OUTPUT_CHARS
): string[] { ): 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 safeLimit = Math.max(0, Math.floor(limit))
const safeMaxChars = Math.max(0, Math.floor(maxChars)) const safeMaxChars = Math.max(0, Math.floor(maxChars))
if (safeLimit === 0 || safeMaxChars < 2) return [] if (safeLimit === 0 || safeMaxChars < 2) return []
@ -182,13 +207,13 @@ export function buildLocalSuggestionCandidates(
const normalizedPrefix = normalizeRequestPrefix(prefix) const normalizedPrefix = normalizeRequestPrefix(prefix)
const normalizedPrefixLower = normalizedPrefix.toLowerCase() const normalizedPrefixLower = normalizedPrefix.toLowerCase()
const acceptsWholePhrase = endsSentence(normalizedPrefix) 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 === null || candidate.length === 0) return
if (candidate.toLowerCase() === normalizedPrefixLower) return if (candidate.toLowerCase() === normalizedPrefixLower) return
if (out.some((existing) => existing.toLowerCase() === candidate.toLowerCase())) return if (out.some((existing) => existing.text.toLowerCase() === candidate.toLowerCase())) return
out.push(candidate) out.push({ text: candidate, attachToPrefix })
} }
for (const raw of hints.continuationHints) { for (const raw of hints.continuationHints) {
@ -206,7 +231,8 @@ export function buildLocalSuggestionCandidates(
if (candidate === null) continue if (candidate === null) continue
const overlap = suffixPrefixOverlap(normalizedPrefix, candidate) const overlap = suffixPrefixOverlap(normalizedPrefix, candidate)
if (overlap >= 2) { 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) { } else if (acceptsWholePhrase) {
append(candidate) append(candidate)
} }
@ -215,3 +241,53 @@ export function buildLocalSuggestionCandidates(
return out 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
}