mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(terminal): apply pane padding on all four edges (#15544)
* fix(terminal): apply pane padding on all four edges Move the configured inset onto xterm so the terminal fills its pane while the fit calculation accounts for both sides of each axis. Add a geometry golden that forces cell remainders and verifies dynamic padding without relying on renderer pixels. * fix(terminal): normalize imported padding for fitting * fix(terminal): align stored and fitted padding
This commit is contained in:
+1
-1
@@ -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",
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<string, NumberFieldProps>())
|
||||
|
||||
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(<TerminalWindowSection settings={settings} updateSettings={updateSettings} />)
|
||||
)
|
||||
|
||||
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 })
|
||||
})
|
||||
})
|
||||
@@ -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) })
|
||||
}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
|
||||
@@ -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) })
|
||||
}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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<TerminalGeometry> {
|
||||
return page.evaluate(() => {
|
||||
const terminalElement = document.querySelector<HTMLElement>('.pane .xterm')
|
||||
const paneElement = terminalElement?.closest<HTMLElement>('.pane')
|
||||
const screenElement = terminalElement?.querySelector<HTMLElement>('.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<void> {
|
||||
await page.evaluate(
|
||||
async ({ nextPadding, nextSize }) => {
|
||||
const store = window.__store
|
||||
const terminal = document.querySelector<HTMLElement>('.pane .xterm')
|
||||
const pane = terminal?.closest<HTMLElement>('.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<HTMLElement>('.pane .xterm')
|
||||
const pane = terminal?.closest<HTMLElement>('.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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user