diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx index cceea6da28b..e1facef49f5 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx @@ -129,7 +129,8 @@ export function AgentTerminalPreview({ const gridClaim = createPreviewGridClaim({ ptyId, container, - getTerminal: () => terminal + getTerminal: () => terminal, + getBaseFontSize: () => settingsRef.current?.terminalFontSize ?? 14 }) // Box growth/shrink (window resize) changes the reachable grid. const boxResizeObserver = diff --git a/src/renderer/src/components/dashboard-popout/preview-grid-claim.test.ts b/src/renderer/src/components/dashboard-popout/preview-grid-claim.test.ts index e933174e87d..6d64ebf08e3 100644 --- a/src/renderer/src/components/dashboard-popout/preview-grid-claim.test.ts +++ b/src/renderer/src/components/dashboard-popout/preview-grid-claim.test.ts @@ -30,7 +30,8 @@ describe('createPreviewGridClaim', () => { const claim = createPreviewGridClaim({ ptyId: 'pty-1', container, - getTerminal: () => ({ cols: 80, rows: 24 }) as never + getTerminal: () => ({ cols: 80, rows: 24, options: { fontSize: 14 } }) as never, + getBaseFontSize: () => 14 }) claim.schedule() @@ -63,7 +64,8 @@ describe('createPreviewGridClaim', () => { const claim = createPreviewGridClaim({ ptyId: 'pty-1', container, - getTerminal: () => ({ cols: 80, rows: 24 }) as never + getTerminal: () => ({ cols: 80, rows: 24, options: { fontSize: 14 } }) as never, + getBaseFontSize: () => 14 }) claim.schedule() @@ -79,4 +81,33 @@ describe('createPreviewGridClaim', () => { expect(fit).toHaveBeenCalledWith('pty-1', 100, 30) claim.dispose() }) + + it('claims from base metrics after fallback fitting shrinks the preview font', async () => { + vi.useFakeTimers() + const fit = vi.fn(async (_ptyId: string, cols: number, rows: number) => ({ cols, rows })) + Object.assign(window, { api: { terminalPreview: { fit } } }) + const box = document.createElement('div') + const container = document.createElement('div') + const screen = document.createElement('div') + screen.className = 'xterm-screen' + box.appendChild(container) + container.appendChild(screen) + dimension(box, 'clientWidth', 800) + dimension(box, 'clientHeight', 480) + // The 200x24 source grid was 1600x384 at 14px, then fitted to 800x192 at 7px. + dimension(screen, 'offsetWidth', 800) + dimension(screen, 'offsetHeight', 192) + const claim = createPreviewGridClaim({ + ptyId: 'pty-1', + container, + getTerminal: () => ({ cols: 200, rows: 24, options: { fontSize: 7 } }) as never, + getBaseFontSize: () => 14 + }) + + claim.schedule() + await vi.advanceTimersByTimeAsync(200) + + expect(fit).toHaveBeenCalledWith('pty-1', 100, 30) + claim.dispose() + }) }) diff --git a/src/renderer/src/components/dashboard-popout/preview-grid-claim.ts b/src/renderer/src/components/dashboard-popout/preview-grid-claim.ts index 6f145e13ea1..9fbf868c248 100644 --- a/src/renderer/src/components/dashboard-popout/preview-grid-claim.ts +++ b/src/renderer/src/components/dashboard-popout/preview-grid-claim.ts @@ -24,6 +24,7 @@ export function createPreviewGridClaim(args: { ptyId: string container: HTMLElement getTerminal: () => Terminal | null + getBaseFontSize: () => number }): { schedule: () => void; dispose: () => void } { let lastRequestedFit: string | null = null let timer: ReturnType | null = null @@ -40,8 +41,12 @@ export function createPreviewGridClaim(args: { return } // offsetWidth/Height are layout dims, unaffected by the scale transform. - const cellWidth = screen.offsetWidth / Math.max(1, terminal.cols) - const cellHeight = screen.offsetHeight / Math.max(1, terminal.rows) + // Font fitting must not make a later claim ask for the oversized source + // grid again; normalize the rendered cells back to settings font metrics. + const currentFontSize = terminal.options.fontSize ?? args.getBaseFontSize() + const fontScale = args.getBaseFontSize() / currentFontSize + const cellWidth = (screen.offsetWidth / Math.max(1, terminal.cols)) * fontScale + const cellHeight = (screen.offsetHeight / Math.max(1, terminal.rows)) * fontScale if ( !Number.isFinite(cellWidth) || !Number.isFinite(cellHeight) || diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-fit-scale.test.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-fit-scale.test.ts index 39b82d81ba4..c290bca6843 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-fit-scale.test.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-fit-scale.test.ts @@ -39,6 +39,55 @@ describe('planPreviewFitScale', () => { expect(plan.residualScale).toBeLessThan(1) }) + it('rounds down when the nearest step would exceed the residual blur budget', () => { + const plan = planPreviewFitScale({ + boxWidth: 518, + screenWidth: 1000, + currentFontSize: 14, + baseFontSize: 14 + }) + // ideal 7.252; rounding to 7.5 would require scale(0.967), so use 7px natively. + expect(plan.fontSize).toBe(7) + expect(plan.residualScale).toBe(1) + }) + + it('converges instead of alternating across rounded xterm measurements', () => { + let fontSize = 14 + const plannedSizes: number[] = [] + // These are the observed 84-column widths that previously produced the + // unbounded 14 -> 6 -> 6.5 -> 6 cycle in a 315px box. + const measuredWidths = new Map([ + [14, 706], + [6, 302], + [6.5, 328] + ]) + for (let step = 0; step < 5; step += 1) { + const plan = planPreviewFitScale({ + boxWidth: 315, + screenWidth: measuredWidths.get(fontSize)!, + currentFontSize: fontSize, + baseFontSize: 14, + maxFontSize: step === 0 ? 14 : fontSize + }) + fontSize = plan.fontSize + plannedSizes.push(fontSize) + } + + expect(plannedSizes).toEqual([6, 6, 6, 6, 6]) + }) + + it('holds a same-layout font when residual scaling alone suggests the adjacent step', () => { + const plan = planPreviewFitScale({ + boxWidth: 372, + screenWidth: 354, + currentFontSize: 7, + baseFontSize: 14, + maxFontSize: 7 + }) + expect(plan.fontSize).toBe(7) + expect(plan.residualScale).toBe(1) + }) + it('never scales the font above the settings base when the box grows back', () => { const plan = planPreviewFitScale({ boxWidth: 2000, @@ -50,6 +99,26 @@ describe('planPreviewFitScale', () => { expect(plan.residualScale).toBe(1) }) + it('restores the base font after the preview grid claim succeeds', () => { + const fallback = planPreviewFitScale({ + boxWidth: 800, + screenWidth: 1600, + currentFontSize: 14, + baseFontSize: 14 + }) + expect(fallback.fontSize).toBe(7) + + // The accepted claim halves the grid, so its 7px rendering is now 400px wide. + const claimed = planPreviewFitScale({ + boxWidth: 800, + screenWidth: 400, + currentFontSize: fallback.fontSize, + baseFontSize: 14 + }) + expect(claimed.fontSize).toBe(14) + expect(claimed.residualScale).toBe(1) + }) + it('holds steady within tolerance so measure-apply loops cannot oscillate', () => { // After the first shrink lands, the re-measure produces a near-identical // ideal; a repeated plan must return the current font unchanged. diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-fit-scale.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-fit-scale.ts index b4a1353c60a..505383ad152 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-fit-scale.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-fit-scale.ts @@ -28,8 +28,10 @@ export function planPreviewFitScale(args: { currentFontSize: number /** The settings-configured font size the preview must never exceed. */ baseFontSize: number + /** Same-layout remeasurement caps growth to prevent adjacent-step cycles. */ + maxFontSize?: number }): PreviewFitScalePlan { - const { boxWidth, screenWidth, currentFontSize, baseFontSize } = args + const { boxWidth, screenWidth, currentFontSize, baseFontSize, maxFontSize = baseFontSize } = args if (boxWidth <= 0 || screenWidth <= 0 || currentFontSize <= 0 || baseFontSize <= 0) { return { fontSize: currentFontSize > 0 ? currentFontSize : baseFontSize, residualScale: 1 } } @@ -37,7 +39,20 @@ export function planPreviewFitScale(args: { // within the tolerance and the residual transform absorbs rounding. const naturalWidthAtBase = (screenWidth / currentFontSize) * baseFontSize const idealFontSize = baseFontSize * (boxWidth / naturalWidthAtBase) - const fontSize = Math.round(clamp(idealFontSize, MIN_PREVIEW_FONT_PX, baseFontSize) * 2) / 2 + const clampedFontSize = clamp( + idealFontSize, + MIN_PREVIEW_FONT_PX, + Math.min(baseFontSize, maxFontSize) + ) + const nearestFontSize = Math.round(clampedFontSize * 2) / 2 + const nearestPredictedWidth = (screenWidth / currentFontSize) * nearestFontSize + const nearestResidualScale = Math.min(1, boxWidth / nearestPredictedWidth) + // Prefer the next smaller native step when nearest rounding would leave a + // visible transform; below the readability floor, the fallback is accepted. + const fontSize = + clampedFontSize > MIN_PREVIEW_FONT_PX && nearestResidualScale < 1 - FONT_FIT_TOLERANCE + ? Math.floor(clampedFontSize * 2) / 2 + : nearestFontSize const withinTolerance = Math.abs(fontSize - currentFontSize) / currentFontSize <= FONT_FIT_TOLERANCE const effectiveFontSize = withinTolerance ? currentFontSize : fontSize diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-fit.test.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-fit.test.ts new file mode 100644 index 00000000000..9ca7da1e720 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-fit.test.ts @@ -0,0 +1,102 @@ +// @vitest-environment happy-dom + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createPreviewFitScheduler } from './preview-terminal-fit' + +function dimension(element: HTMLElement, name: string, get: () => number): void { + Object.defineProperty(element, name, { configurable: true, get }) +} + +describe('createPreviewFitScheduler', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('settles the reported adjacent-size cycle without another frame', () => { + const frames: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frames.push(callback) + return frames.length + }) + const box = document.createElement('div') + const container = document.createElement('div') + const screen = document.createElement('div') + screen.className = 'xterm-screen' + box.appendChild(container) + container.appendChild(screen) + const terminal = { + cols: 84, + rows: 24, + options: { fontSize: 14 }, + buffer: { active: { cursorY: 0 } } + } + const measuredWidths = new Map([ + [14, 706], + [6, 302], + [6.5, 328] + ]) + dimension(box, 'clientWidth', () => 315) + dimension(box, 'clientHeight', () => 480) + dimension(screen, 'offsetWidth', () => measuredWidths.get(terminal.options.fontSize)!) + dimension(screen, 'offsetHeight', () => 384) + const { scheduleFit } = createPreviewFitScheduler({ + container, + getTerminal: () => terminal as never, + getBaseFontSize: () => 14 + }) + + scheduleFit() + frames.shift()!(0) + expect(terminal.options.fontSize).toBe(6) + expect(frames).toHaveLength(1) + + frames.shift()!(0) + expect(terminal.options.fontSize).toBe(6) + expect(frames).toHaveLength(0) + }) + + it('allows the base font to return after a successful grid claim', () => { + const frames: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frames.push(callback) + return frames.length + }) + const box = document.createElement('div') + const container = document.createElement('div') + const screen = document.createElement('div') + screen.className = 'xterm-screen' + box.appendChild(container) + container.appendChild(screen) + const terminal = { + cols: 200, + rows: 24, + options: { fontSize: 7 }, + buffer: { active: { cursorY: 0 } } + } + dimension(box, 'clientWidth', () => 800) + dimension(box, 'clientHeight', () => 480) + dimension( + screen, + 'offsetWidth', + () => (terminal.cols === 200 ? 800 : 400) * (terminal.options.fontSize / 7) + ) + dimension(screen, 'offsetHeight', () => 192) + const { scheduleFit } = createPreviewFitScheduler({ + container, + getTerminal: () => terminal as never, + getBaseFontSize: () => 14 + }) + + scheduleFit() + frames.shift()!(0) + expect(terminal.options.fontSize).toBe(7) + + terminal.cols = 100 + scheduleFit() + frames.shift()!(0) + expect(terminal.options.fontSize).toBe(14) + expect(frames).toHaveLength(1) + + frames.shift()!(0) + expect(terminal.options.fontSize).toBe(14) + expect(frames).toHaveLength(0) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-fit.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-fit.ts index 4781f2ba7b0..c64b9d49951 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-fit.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-fit.ts @@ -13,6 +13,7 @@ export function createPreviewFitScheduler(args: { getBaseFontSize: () => number }): { scheduleFit: () => void } { const { container, getTerminal, getBaseFontSize } = args + let lastFitContext: { boxWidth: number; cols: number; baseFontSize: number } | null = null const fitToBox = (): void => { const terminal = getTerminal() @@ -22,12 +23,20 @@ export function createPreviewFitScheduler(args: { return } const baseFontSize = getBaseFontSize() + const currentFontSize = terminal.options.fontSize ?? baseFontSize + const allowFontGrowth = + !lastFitContext || + box.clientWidth > lastFitContext.boxWidth || + terminal.cols !== lastFitContext.cols || + baseFontSize !== lastFitContext.baseFontSize const plan = planPreviewFitScale({ boxWidth: box.clientWidth, screenWidth: Math.max(1, screen.offsetWidth), - currentFontSize: terminal.options.fontSize ?? baseFontSize, - baseFontSize + currentFontSize, + baseFontSize, + maxFontSize: allowFontGrowth ? baseFontSize : currentFontSize }) + lastFitContext = { boxWidth: box.clientWidth, cols: terminal.cols, baseFontSize } if (plan.fontSize !== terminal.options.fontSize) { terminal.options.fontSize = plan.fontSize // Re-measure next frame: the renderer applies the new metrics async.