diff --git a/package.json b/package.json index 3fd332f5fe1..2dd9706e681 100644 --- a/package.json +++ b/package.json @@ -91,7 +91,7 @@ "test:e2e:workspace-session-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-quit-relaunch-session.spec.ts tests/e2e/golden-terminal-file-link.spec.ts tests/e2e/golden-worktree-create-switch.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:multi-client-navigation": "node config/scripts/run-multi-client-navigation-e2e.mjs", "test:e2e:floating-mobile-emulator": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/floating-mobile-emulator-tab.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "test:e2e:terminal-rendering-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts tests/e2e/terminal-webgl-atlas-budget.spec.ts --grep @terminal-rendering-golden --config tests/playwright.config.ts --project electron-headless --workers=1", + "test:e2e:terminal-rendering-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts tests/e2e/terminal-webgl-atlas-budget.spec.ts tests/e2e/terminal-padding-geometry-golden.spec.ts --grep @terminal-rendering-golden --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:source-control-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-file-open-edit-save.spec.ts tests/e2e/golden-source-control-commit.spec.ts tests/e2e/golden-source-control-open-diff.spec.ts --grep @golden --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:posix-profile-index-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-posix-fresh-startup.spec.ts tests/e2e/golden-posix-profile-index-fsync.spec.ts --grep @posix-profile-index-golden --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:windows-fresh-startup-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-windows-fresh-startup.spec.ts --grep @windows-fresh-startup-golden --config tests/playwright.config.ts --project electron-headless --workers=1", diff --git a/src/main/ghostty/mapper-extended.test.ts b/src/main/ghostty/mapper-extended.test.ts index 402e9253f21..9584ac75017 100644 --- a/src/main/ghostty/mapper-extended.test.ts +++ b/src/main/ghostty/mapper-extended.test.ts @@ -90,9 +90,9 @@ describe('mapGhosttyToOrca — window-padding', () => { expect(result.unsupportedKeys).toEqual([]) }) - it('preserves odd-sum dual-value padding as a fractional average', () => { + it('rounds odd-sum dual-value padding to the integer used for fitting', () => { const result = mapGhosttyToOrca({ 'window-padding-x': '1,2' }) - expect(result.diff).toEqual({ terminalPaddingX: 1.5 }) + expect(result.diff).toEqual({ terminalPaddingX: 2 }) expect(result.unsupportedKeys).toEqual([]) }) diff --git a/src/main/ghostty/numeric-config-values.ts b/src/main/ghostty/numeric-config-values.ts index f8a7ce7b468..9a20f040aee 100644 --- a/src/main/ghostty/numeric-config-values.ts +++ b/src/main/ghostty/numeric-config-values.ts @@ -1,3 +1,5 @@ +import { normalizeTerminalPadding } from '../../shared/terminal-padding-settings' + // Why: `Number("1e10")` succeeds and passes `Number.isInteger`, so a Ghostty // config with `window-padding-x = 1e9` would sail through the mapper and land // an absurd value in the store. Restrict to plain decimal integers. @@ -10,9 +12,8 @@ export const parseStrictInt = (v: string): number | null => { return Number.isFinite(num) ? num : null } -// Why: Ghostty accepts "top,bottom" / "left,right" pairs for window paddings, -// but Orca stores a single value per axis — average the pair so the total -// padding along the axis stays the same. +// Why: Orca stores one integer per axis, so collapse paired paddings to the +// nearest value that xterm's integer-based fit calculation will also use. export const parsePaddingValue = (v: string): number | null => { const parts = v.split(',') if (parts.length > 2) { @@ -26,5 +27,5 @@ export const parsePaddingValue = (v: string): number | null => { } nums.push(num) } - return nums.reduce((sum, num) => sum + num, 0) / nums.length + return normalizeTerminalPadding(nums.reduce((sum, num) => sum + num, 0) / nums.length) } diff --git a/src/renderer/src/assets/terminal-container-geometry.test.ts b/src/renderer/src/assets/terminal-container-geometry.test.ts index db079c9095f..28eb858fcdd 100644 --- a/src/renderer/src/assets/terminal-container-geometry.test.ts +++ b/src/renderer/src/assets/terminal-container-geometry.test.ts @@ -5,9 +5,7 @@ const terminalCss = fs.readFileSync(new URL('./terminal.css', import.meta.url), describe('terminal container geometry', () => { it('keeps the hidden link tooltip out of the fitted terminal height', () => { - expect(terminalCss).toMatch( - /\.xterm-container\s*{[^}]*height:\s*calc\(100% - var\(--pane-padding-y, 4px\)\);/s - ) + expect(terminalCss).toMatch(/\.xterm-container\s*{[^}]*height:\s*100%;/s) expect(terminalCss).toMatch( /\.pane\[data-has-title\] \.xterm-container\s*{[^}]*height:\s*calc\(100% - var\(--orca-pane-title-height\)\);/s ) @@ -15,4 +13,12 @@ describe('terminal container geometry', () => { /\.pane-link-tooltip\s*{[^}]*height:\s*var\(--orca-terminal-link-tooltip-height\);/s ) }) + + it('insets the xterm grid on all four edges', () => { + expect(terminalCss).toMatch( + /\.xterm-container \.xterm\s*{[^}]*padding:\s*var\(--pane-padding-y, 4px\) var\(--pane-padding-x, 4px\);/s + ) + expect(terminalCss).toMatch(/\.xterm-container\s*{[^}]*width:\s*100%;/s) + expect(terminalCss).not.toMatch(/\.xterm-container\s*{[^}]*margin-left:/s) + }) }) diff --git a/src/renderer/src/assets/terminal.css b/src/renderer/src/assets/terminal.css index ee5e0af01f5..16b3e36ec5e 100644 --- a/src/renderer/src/assets/terminal.css +++ b/src/renderer/src/assets/terminal.css @@ -25,9 +25,11 @@ outline: none; } -.pane-manager-root .xterm { +.xterm-container .xterm { + box-sizing: border-box; height: 100%; width: 100%; + padding: var(--pane-padding-y, 4px) var(--pane-padding-x, 4px); } .pane-manager-root .xterm-viewport { @@ -177,7 +179,7 @@ .pane[data-terminal-attention] .xterm-container::after { content: ''; position: absolute; - inset: 0 4px 4px 0; + inset: 0; z-index: 5; pointer-events: none; border-radius: 2px; @@ -498,16 +500,13 @@ border-color: var(--ring); } -/* Baseline xterm-container layout — padding is driven by CSS custom properties - so Ghostty-imported window-padding-x/y can be applied without inline styles. - The fallback 4px preserves the legacy default. */ +/* The container fills the pane; xterm owns the four-edge grid inset so its fit + calculation subtracts the configured padding before choosing cols/rows. */ .xterm-container { box-sizing: border-box; position: relative; - width: calc(100% - var(--pane-padding-x, 4px)); - height: calc(100% - var(--pane-padding-y, 4px)); - margin-top: var(--pane-padding-y, 4px); - margin-left: var(--pane-padding-x, 4px); + width: 100%; + height: 100%; } /* When a pane has a title, shift the terminal content down to make room. diff --git a/src/renderer/src/components/settings/TerminalWindowSection.padding.test.tsx b/src/renderer/src/components/settings/TerminalWindowSection.padding.test.tsx new file mode 100644 index 00000000000..5f54fb38a9e --- /dev/null +++ b/src/renderer/src/components/settings/TerminalWindowSection.padding.test.tsx @@ -0,0 +1,81 @@ +// @vitest-environment happy-dom + +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { getDefaultSettings } from '../../../../shared/constants' +import { TerminalWindowSection } from './TerminalWindowSection' + +type NumberFieldProps = { + label: string + value: number + onChange: (value: number) => void +} + +const numberFields = vi.hoisted(() => new Map()) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, defaultValue: string) => defaultValue +})) + +vi.mock('./SettingsFormControls', () => ({ + ColorField: function ColorField() { + return null + }, + NumberField: function NumberField(props: NumberFieldProps) { + numberFields.set(props.label, props) + return null + } +})) + +vi.mock('./SearchableSetting', () => ({ + SearchableSetting: function SearchableSetting({ children }: { children?: ReactNode }) { + return children ?? null + } +})) + +vi.mock('../ui/button', () => ({ Button: () => null })) +vi.mock('../ui/label', () => ({ Label: () => null })) +vi.mock('../ui/switch', () => ({ Switch: () => null })) + +describe('TerminalWindowSection padding', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + numberFields.clear() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + document.body.replaceChildren() + }) + + it('displays and persists the same integer padding used by terminal fitting', () => { + const updateSettings = vi.fn() + const settings = { + ...getDefaultSettings('/tmp'), + terminalPaddingX: 1.5, + terminalPaddingY: 2.5 + } as GlobalSettings + + act(() => + root.render() + ) + + const horizontal = numberFields.get('Horizontal Padding') + const vertical = numberFields.get('Vertical Padding') + expect(horizontal?.value).toBe(2) + expect(vertical?.value).toBe(3) + + act(() => horizontal?.onChange(3.5)) + act(() => vertical?.onChange(4.5)) + expect(updateSettings).toHaveBeenNthCalledWith(1, { terminalPaddingX: 4 }) + expect(updateSettings).toHaveBeenNthCalledWith(2, { terminalPaddingY: 5 }) + }) +}) diff --git a/src/renderer/src/components/settings/TerminalWindowSection.tsx b/src/renderer/src/components/settings/TerminalWindowSection.tsx index 863d7fa11b0..e9efb2c4396 100644 --- a/src/renderer/src/components/settings/TerminalWindowSection.tsx +++ b/src/renderer/src/components/settings/TerminalWindowSection.tsx @@ -1,5 +1,6 @@ import { useRef, useState } from 'react' import { RotateCw } from 'lucide-react' +import { normalizeTerminalPadding } from '../../../../shared/terminal-padding-settings' import type { GlobalSettings } from '../../../../shared/global-settings-types' import { Button } from '../ui/button' import { Label } from '../ui/label' @@ -185,13 +186,15 @@ export function TerminalWindowSection({ 'Horizontal Padding' )} description="" - value={settings.terminalPaddingX ?? 4} + value={normalizeTerminalPadding(settings.terminalPaddingX ?? 4)} defaultValue={4} min={0} max={512} step={1} suffix="px" - onChange={(value) => updateSettings({ terminalPaddingX: Math.max(0, value) })} + onChange={(value) => + updateSettings({ terminalPaddingX: normalizeTerminalPadding(value) }) + } /> @@ -212,13 +215,15 @@ export function TerminalWindowSection({ 'Vertical Padding' )} description="" - value={settings.terminalPaddingY ?? 4} + value={normalizeTerminalPadding(settings.terminalPaddingY ?? 4)} defaultValue={4} min={0} max={512} step={1} suffix="px" - onChange={(value) => updateSettings({ terminalPaddingY: Math.max(0, value) })} + onChange={(value) => + updateSettings({ terminalPaddingY: normalizeTerminalPadding(value) }) + } /> diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts index 6f7bec8592b..2bee7878d0b 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts @@ -3,6 +3,7 @@ import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' import { getDefaultSettings } from '../../../../shared/constants' import { applyTerminalAppearance, + composeRendererTerminalTheme, hexToRgba, publishTerminalViewAttributesAtAppStart } from './terminal-appearance' @@ -216,6 +217,54 @@ describe('applyTerminalAppearance theme assignment', () => { expect(pane.terminal.options.theme?.background).toBe('#102030') }) + it('lets the pane paint a fractional background exactly once', () => { + const pane = makePane(1) + const settings = getDefaultSettings('/tmp') + const manager = makeManager([pane]) + + applyTerminalAppearance( + manager, + { ...settings, terminalBackgroundOpacity: 0.5 }, + true, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + + expect(pane.terminal.options.theme?.background).toMatch(/^rgba\(.+, 0\)$/) + expect(pane.terminal.options.allowTransparency).toBe(true) + expect(manager.setPaneStyleOptions).toHaveBeenCalledWith( + expect.objectContaining({ paneBackground: expect.stringMatching(/^rgba\(.+, 0\.5\)$/) }) + ) + }) + + it('stamps four-edge padding before fitting', () => { + const pane = makePane(1) + pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 79, rows: 23 })) + const manager = makeManager([pane]) + const settings = getDefaultSettings('/tmp') + + applyTerminalAppearance( + manager, + { ...settings, terminalPaddingX: 11, terminalPaddingY: 7 }, + true, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + + expect(manager.setPaneStyleOptions).toHaveBeenCalledWith( + expect.objectContaining({ paddingX: 11, paddingY: 7 }) + ) + expect(vi.mocked(manager.setPaneStyleOptions).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(pane.fitAddon.proposeDimensions).mock.invocationCallOrder[0]! + ) + }) + // #7934: contrast correction rescues invisible white text on light backgrounds but over-corrects on dark; // gate by the composed theme's background luminance (either theme slot can hold either kind of theme). it('keeps xterm contrast correction on light themes', () => { @@ -362,6 +411,23 @@ describe('applyTerminalAppearance theme assignment', () => { }) }) +describe('composeRendererTerminalTheme', () => { + it('preserves background RGB while removing fractional renderer alpha', () => { + const theme = { background: 'rgba(16, 32, 48, 0.5)', foreground: '#ffffff' } + + expect(composeRendererTerminalTheme(theme, 0.5)).toEqual({ + ...theme, + background: 'rgba(16, 32, 48, 0)' + }) + }) + + it('keeps the opaque fast path unchanged', () => { + const theme = { background: '#102030' } + + expect(composeRendererTerminalTheme(theme, 1)).toBe(theme) + }) +}) + describe('publishTerminalViewAttributesAtAppStart', () => { // Hidden-at-launch PTYs query OSC 10/11 before any pane mounts; publish with no pane manager (terminal-query-authority.md). it('publishes composed attributes without any pane mount and dedupes repeats', () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.ts index bb61a50fbbc..c7a1156500f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.ts @@ -29,6 +29,7 @@ import { publishTerminalViewAttributes } from './terminal-view-attributes-publis import { normalizeTerminalLineHeight } from '../../../../shared/terminal-line-height-settings' import { maybePushMode2031Flip } from './terminal-mode-2031-replies' import { resolveTerminalMinimumContrastRatio } from '@/lib/terminal-contrast-correction' +import { normalizeTerminalPadding } from '../../../../shared/terminal-padding-settings' export function hexToRgba(hex: string, alpha: number): string { let clean = hex.replace('#', '') @@ -132,6 +133,22 @@ function composedTerminalThemesEqual(a: ITheme | undefined, b: ITheme): boolean return extA.length === extB.length && extA.every((value, i) => value === extB[i]) } +export function composeRendererTerminalTheme( + theme: ITheme | null, + backgroundOpacity: number | undefined +): ITheme | null { + if (!theme?.background || backgroundOpacity === undefined || backgroundOpacity >= 1) { + return theme + } + const transparentBackground = theme.background.replace( + /^rgba\(([^,]+),\s*([^,]+),\s*([^,]+),\s*[^)]+\)$/, + 'rgba($1, $2, $3, 0)' + ) + return transparentBackground === theme.background + ? theme + : { ...theme, background: transparentBackground } +} + export function applyTerminalAppearance( manager: PaneManager, settings: GlobalSettings, @@ -149,6 +166,7 @@ export function applyTerminalAppearance( // Publish composed appearance to main's hidden-PTY query responder — the only point it exists; deduped in the publisher. publishTerminalViewAttributes(theme, appearance.mode, settings) const paneBackground = theme?.background ?? '#000000' + const rendererTheme = composeRendererTerminalTheme(theme, settings.terminalBackgroundOpacity) const terminalFontWeights = resolveTerminalFontWeights( settings.terminalFontWeight, @@ -159,10 +177,23 @@ export function applyTerminalAppearance( settings.terminalFontFamily ) + // Why before fit: FitAddon reads live .xterm padding when proposing cols/rows. + manager.setPaneStyleOptions({ + splitBackground: paneBackground, + paneBackground, + inactivePaneOpacity: paneStyles.inactivePaneOpacity, + activePaneOpacity: paneStyles.activePaneOpacity, + opacityTransitionMs: paneStyles.opacityTransitionMs, + dividerThicknessPx: paneStyles.dividerThicknessPx, + focusFollowsMouse: paneStyles.focusFollowsMouse, + paddingX: normalizeTerminalPadding(settings.terminalPaddingX ?? 4), + paddingY: normalizeTerminalPadding(settings.terminalPaddingY ?? 4) + }) + for (const pane of manager.getPanes()) { // Why value-gated: writing options.theme rebuilds the palette, discarding TUI OSC 4/10/11/12 mutations; skip on no-op change. - if (theme && !composedTerminalThemesEqual(pane.terminal.options.theme, theme)) { - pane.terminal.options.theme = theme + if (rendererTheme && !composedTerminalThemesEqual(pane.terminal.options.theme, rendererTheme)) { + pane.terminal.options.theme = rendererTheme } // Gate off the configured theme background; the live OSC-11 background is deliberately preserved by the // theme write above, so a TUI that repaints its background at runtime won't re-gate (known limitation). @@ -227,16 +258,4 @@ export function applyTerminalAppearance( safeFit(pane) } } - - manager.setPaneStyleOptions({ - splitBackground: paneBackground, - paneBackground, - inactivePaneOpacity: paneStyles.inactivePaneOpacity, - activePaneOpacity: paneStyles.activePaneOpacity, - opacityTransitionMs: paneStyles.opacityTransitionMs, - dividerThicknessPx: paneStyles.dividerThicknessPx, - focusFollowsMouse: paneStyles.focusFollowsMouse, - paddingX: settings.terminalPaddingX, - paddingY: settings.terminalPaddingY - }) } diff --git a/src/shared/terminal-padding-settings.test.ts b/src/shared/terminal-padding-settings.test.ts new file mode 100644 index 00000000000..ee7ac432f46 --- /dev/null +++ b/src/shared/terminal-padding-settings.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { normalizeTerminalPadding } from './terminal-padding-settings' + +describe('normalizeTerminalPadding', () => { + it('rounds fractional padding to the nearest fitted pixel', () => { + expect(normalizeTerminalPadding(1.5)).toBe(2) + expect(normalizeTerminalPadding(2.49)).toBe(2) + }) + + it('keeps padding inside the settings range', () => { + expect(normalizeTerminalPadding(-1)).toBe(0) + expect(normalizeTerminalPadding(513)).toBe(512) + }) +}) diff --git a/src/shared/terminal-padding-settings.ts b/src/shared/terminal-padding-settings.ts new file mode 100644 index 00000000000..1f18a46973f --- /dev/null +++ b/src/shared/terminal-padding-settings.ts @@ -0,0 +1,6 @@ +const MIN_TERMINAL_PADDING = 0 +const MAX_TERMINAL_PADDING = 512 + +export function normalizeTerminalPadding(value: number): number { + return Math.min(MAX_TERMINAL_PADDING, Math.max(MIN_TERMINAL_PADDING, Math.round(value))) +} diff --git a/tests/e2e/terminal-padding-geometry-golden.spec.ts b/tests/e2e/terminal-padding-geometry-golden.spec.ts new file mode 100644 index 00000000000..89b744f73b9 --- /dev/null +++ b/tests/e2e/terminal-padding-geometry-golden.spec.ts @@ -0,0 +1,222 @@ +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { waitForActiveTerminalManager } from './helpers/terminal' + +type TerminalGeometry = { + cellWidth: number + cellHeight: number + cols: number + rows: number + scrollbarWidth: number + pane: { top: number; right: number; bottom: number; left: number; width: number; height: number } + terminal: { top: number; right: number; bottom: number; left: number } + screen: { + top: number + right: number + bottom: number + left: number + width: number + height: number + } +} + +const FIRST_PADDING = { x: 11, y: 7 } +const SECOND_PADDING = { x: 17, y: 13 } +const TARGET_COLS = 70 +const TARGET_ROWS = 20 +const MIN_REMAINDER = 2 + +async function readTerminalGeometry(page: Page): Promise { + return page.evaluate(() => { + const terminalElement = document.querySelector('.pane .xterm') + const paneElement = terminalElement?.closest('.pane') + const screenElement = terminalElement?.querySelector('.xterm-screen') + const manager = [...(window.__paneManagers?.values() ?? [])].find((candidate) => + candidate.getPanes().some((pane) => pane.container === paneElement) + ) + const pane = manager?.getPanes().find((candidate) => candidate.container === paneElement) + const cell = pane?.terminal.dimensions?.css.cell + if (!terminalElement || !paneElement || !screenElement || !pane || !cell) { + throw new Error('Active terminal geometry is unavailable') + } + const paneRect = paneElement.getBoundingClientRect() + const terminalRect = terminalElement.getBoundingClientRect() + const screenRect = screenElement.getBoundingClientRect() + return { + cellWidth: cell.width, + cellHeight: cell.height, + cols: pane.terminal.cols, + rows: pane.terminal.rows, + scrollbarWidth: pane.terminal.options.scrollbar?.width ?? 14, + pane: { + top: paneRect.top, + right: paneRect.right, + bottom: paneRect.bottom, + left: paneRect.left, + width: paneRect.width, + height: paneRect.height + }, + terminal: { + top: terminalRect.top, + right: terminalRect.right, + bottom: terminalRect.bottom, + left: terminalRect.left + }, + screen: { + top: screenRect.top, + right: screenRect.right, + bottom: screenRect.bottom, + left: screenRect.left, + width: screenRect.width, + height: screenRect.height + } + } + }) +} + +function findNonDivisibleSize(cellSize: number, cells: number, chrome: number): number { + const floor = Math.ceil(cellSize * cells + chrome) + for (let size = floor; size < floor + Math.ceil(cellSize); size += 1) { + const available = size - chrome + const remainder = available - Math.floor(available / cellSize) * cellSize + if (remainder > MIN_REMAINDER && remainder < cellSize - MIN_REMAINDER) { + return size + } + } + throw new Error(`Could not force a non-divisible ${cellSize}px cell geometry`) +} + +async function applyPaddingAndPaneSize( + page: Page, + padding: { x: number; y: number }, + size: { width: number; height: number } +): Promise { + await page.evaluate( + async ({ nextPadding, nextSize }) => { + const store = window.__store + const terminal = document.querySelector('.pane .xterm') + const pane = terminal?.closest('.pane') + if (!store || !pane) { + throw new Error('Terminal settings surface is unavailable') + } + await store.getState().updateSettings({ + terminalPaddingX: nextPadding.x, + terminalPaddingY: nextPadding.y + }) + pane.style.setProperty('width', `${nextSize.width}px`, 'important') + pane.style.setProperty('height', `${nextSize.height}px`, 'important') + }, + { nextPadding: padding, nextSize: size } + ) + + await page.waitForFunction( + ({ expectedPadding, expectedSize }) => { + const store = window.__store + const terminal = document.querySelector('.pane .xterm') + const pane = terminal?.closest('.pane') + if (!store || !pane) { + return false + } + const settings = store.getState().settings + const rect = pane.getBoundingClientRect() + return ( + settings?.terminalPaddingX === expectedPadding.x && + settings?.terminalPaddingY === expectedPadding.y && + Math.abs(rect.width - expectedSize.width) < 0.5 && + Math.abs(rect.height - expectedSize.height) < 0.5 + ) + }, + { expectedPadding: padding, expectedSize: size } + ) + + await page.evaluate(() => { + for (const manager of window.__paneManagers?.values() ?? []) { + for (const pane of manager.getPanes()) { + pane.fitAddon.fit() + } + } + }) + await page.waitForTimeout(100) +} + +function expectClose(actual: number, expected: number): void { + expect(Math.abs(actual - expected)).toBeLessThan(0.75) +} + +function assertFourEdgeGeometry( + geometry: TerminalGeometry, + padding: { x: number; y: number } +): { horizontalRemainder: number; verticalRemainder: number } { + const verticalRemainder = geometry.pane.height - padding.y * 2 - geometry.screen.height + + // The old layout only exposed its trailing gap when cell fitting left a remainder. + expect(verticalRemainder).toBeGreaterThan(MIN_REMAINDER) + expect(verticalRemainder).toBeLessThan(geometry.cellHeight) + + expect({ + left: geometry.terminal.left - geometry.pane.left, + top: geometry.terminal.top - geometry.pane.top, + right: geometry.pane.right - geometry.terminal.right, + bottom: geometry.pane.bottom - geometry.terminal.bottom + }).toEqual({ left: 0, top: 0, right: 0, bottom: 0 }) + + const horizontalRemainder = + geometry.pane.width - padding.x * 2 - geometry.scrollbarWidth - geometry.screen.width + + expect(horizontalRemainder).toBeGreaterThan(0) + expect(horizontalRemainder).toBeLessThan(geometry.cellWidth) + + expectClose(geometry.screen.left - geometry.pane.left, padding.x) + expectClose(geometry.screen.top - geometry.pane.top, padding.y) + expectClose( + geometry.pane.right - geometry.screen.right, + padding.x + geometry.scrollbarWidth + horizontalRemainder + ) + expectClose(geometry.pane.bottom - geometry.screen.bottom, padding.y + verticalRemainder) + + return { horizontalRemainder, verticalRemainder } +} + +test.describe('terminal padding geometry', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage) + }) + + // #13252: the old start-edge margin left the grid flush right/bottom. The + // #14583 re-land was reverted, so this guard checks geometry, not its paint mechanism. + test('keeps configured padding on all four edges with a forced cell remainder @terminal-rendering-golden', async ({ + orcaPage + }) => { + const baseline = await readTerminalGeometry(orcaPage) + const firstSize = { + width: findNonDivisibleSize( + baseline.cellWidth, + TARGET_COLS, + FIRST_PADDING.x * 2 + baseline.scrollbarWidth + ), + height: findNonDivisibleSize(baseline.cellHeight, TARGET_ROWS, FIRST_PADDING.y * 2) + } + + await applyPaddingAndPaneSize(orcaPage, FIRST_PADDING, firstSize) + const first = await readTerminalGeometry(orcaPage) + const firstRemainders = assertFourEdgeGeometry(first, FIRST_PADDING) + + // Keep usable dimensions constant while changing both setting values. + const secondSize = { + width: firstSize.width + (SECOND_PADDING.x - FIRST_PADDING.x) * 2, + height: firstSize.height + (SECOND_PADDING.y - FIRST_PADDING.y) * 2 + } + await applyPaddingAndPaneSize(orcaPage, SECOND_PADDING, secondSize) + const second = await readTerminalGeometry(orcaPage) + const secondRemainders = assertFourEdgeGeometry(second, SECOND_PADDING) + + expect(second.cols).toBe(first.cols) + expect(second.rows).toBe(first.rows) + expectClose(secondRemainders.horizontalRemainder, firstRemainders.horizontalRemainder) + expectClose(secondRemainders.verticalRemainder, firstRemainders.verticalRemainder) + }) +})