fix(terminal): align stored and fitted padding

This commit is contained in:
Brennan Benson
2026-08-14 18:39:58 -07:00
parent 183bcb2aa4
commit 60a7059c3f
7 changed files with 119 additions and 11 deletions
+2 -2
View File
@@ -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 nearest fitted pixel', () => {
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([])
})
+4 -3
View File
@@ -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.
@@ -11,8 +13,7 @@ export const parseStrictInt = (v: string): number | 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.
// but Orca stores an integer per axis — preserve the nearest total inset.
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)
}
@@ -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,6 +1,7 @@
import { useRef, useState } from 'react'
import { RotateCw } from 'lucide-react'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import { normalizeTerminalPadding } from '../../../../shared/terminal-padding-settings'
import { Button } from '../ui/button'
import { Label } from '../ui/label'
import { Switch } from '../ui/switch'
@@ -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 { PaneManager } from '@/lib/pane-manager/pane-manager'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import { resolveTerminalFontWeights } from '../../../../shared/terminal-fonts'
import { resolveTerminalLigaturesEnabled } from '../../../../shared/terminal-ligatures'
import { normalizeTerminalPadding } from '../../../../shared/terminal-padding-settings'
import {
getBuiltinTheme,
resolvePaneStyleOptions,
@@ -159,8 +160,8 @@ export function applyTerminalAppearance(
settings.terminalFontFamily
)
// FitAddon parses each CSS padding as an integer, so normalize imported half-pixels before styling and fitting.
const paddingX = Math.round(settings.terminalPaddingX ?? 4)
const paddingY = Math.round(settings.terminalPaddingY ?? 4)
const paddingX = normalizeTerminalPadding(settings.terminalPaddingX ?? 4)
const paddingY = normalizeTerminalPadding(settings.terminalPaddingY ?? 4)
// Why before the pane loop: FitAddon subtracts live .xterm padding, so the
// CSS vars must be stamped before safeFit or a padding shrink leaves a stale grid.
@@ -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)
})
})
+6
View File
@@ -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)))
}