/** * Pure helpers for window zoom. The main process owns webContents.setZoomLevel, * so the menu items, the Ctrl/Cmd shortcuts, and the settings UI all funnel * through this one clamped scale. Percent is the user-facing unit (100 = the * Chromium actual-size baseline); Chromium's internal unit is the zoom level, * where factor = 1.2 ^ level. * * Our shipped default is the Appearance 90% preset — tight enough to feel * denser than Chromium 100%, and selected in the UI Scale control on first run. */ export const ZOOM_STORAGE_KEY = 'hermes:desktop:zoomLevel' const ZOOM_FACTOR_BASE = 1.2 const MIN_ZOOM_LEVEL = -9 const MAX_ZOOM_LEVEL = 9 /** Half Chromium's default step; matching the shortcuts and View menu. */ export const ZOOM_STEP = 0.1 /** Appearance 90% preset. Fresh installs + Actual Size / Ctrl+0. */ export const DEFAULT_ZOOM_LEVEL = Math.log(0.9) / Math.log(ZOOM_FACTOR_BASE) export function clampZoomLevel(value) { if (!Number.isFinite(value)) { return DEFAULT_ZOOM_LEVEL } return Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL) } export function zoomLevelToPercent(level) { return Math.round(Math.pow(ZOOM_FACTOR_BASE, clampZoomLevel(level)) * 100) } export function percentToZoomLevel(percent) { if (!Number.isFinite(percent) || percent <= 0) { return DEFAULT_ZOOM_LEVEL } return clampZoomLevel(Math.log(percent / 100) / Math.log(ZOOM_FACTOR_BASE)) } /** * Apply a clamped zoom level to a webContents AND notify the renderer, in that * order. Every path that changes zoom (user action, restore-on-load, lifecycle * re-assert) funnels through here so the settings UI Scale control can never * drift from the actually-applied level — the bug where restore set the level * but forgot to emit 'hermes:zoom:changed', leaving the control stuck at 100%. * Returns the clamped level so callers can persist it. */ export function applyZoomLevel(webContents, level) { const clamped = clampZoomLevel(level) webContents.setZoomLevel(clamped) webContents.send('hermes:zoom:changed', { level: clamped, percent: zoomLevelToPercent(clamped) }) return clamped } // Chromium can drop webContents zoom when a BrowserWindow is resized, minimized // and restored, or crosses onto a monitor with different display scaling. macOS // and Windows provide trailing `resized`/`moved` events; Linux only provides the // noisy `resize`/`move` pair, so debounce those fallbacks before re-applying the // persisted level. export const ZOOM_RESIZE_REASSERT_DELAY_MS = 100 export function zoomReassertWindowEvents(platform = process.platform) { return platform === 'linux' ? ['show', 'restore', 'resize', 'move'] : ['show', 'restore', 'resized', 'moved'] } export function installZoomReassertOnWindowEvents(win, reassert, platform = process.platform) { if (!win?.on) { return } let resizeTimer for (const event of zoomReassertWindowEvents(platform)) { win.on(event, () => { if (win.isDestroyed?.()) { return } if (event !== 'resize' && event !== 'move') { reassert() return } clearTimeout(resizeTimer) resizeTimer = setTimeout(() => { if (!win.isDestroyed?.()) { reassert() } }, ZOOM_RESIZE_REASSERT_DELAY_MS) }) } } /** * Zoom-wiring decision per window kind. Chat windows (main + session) keep * global UI zoom; the pet overlay and the Quick Entry composer opt out because * they size their own OS window and inheriting zoom would crop/overflow them. * * Extracted so the "helper windows opt out, everything else opts in" contract is * unit-testable without booting a BrowserWindow or reading source. */ export const ZOOM_WINDOW_CONFIG = { chat: { zoom: true }, petOverlay: { zoom: false }, quickEntry: { zoom: false }, wakeIndicator: { zoom: false } } as const export function zoomWiringForWindowKind(kind) { return ZOOM_WINDOW_CONFIG[kind] ?? ZOOM_WINDOW_CONFIG.chat }