diff --git a/apps/web/src/components/TerminalSplitPanes.tsx b/apps/web/src/components/TerminalSplitPanes.tsx new file mode 100644 index 000000000000..3b3c509cbd4e --- /dev/null +++ b/apps/web/src/components/TerminalSplitPanes.tsx @@ -0,0 +1,284 @@ +import { + type KeyboardEvent as ReactKeyboardEvent, + type ReactNode, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { useResizeDrag } from "~/hooks/useResizeDrag"; +import { + constrainPaneSizes, + equalPaneSizes, + MIN_TERMINAL_PANE_PX, + paneBoundaryOffsets, + paneGridTemplate, + panePixelBoundaries, + resizeAdjacentPanes, + resolvePaneSizes, + snapPaneSizesToWholePixels, + type TerminalSplitDirection, +} from "~/terminal/splitPaneSizes"; + +interface TerminalSplitPanesProps { + terminalIds: readonly string[]; + direction: TerminalSplitDirection; + activeTerminalId: string; + sizes: readonly number[] | undefined; + onSizesChange: (sizes: number[]) => void; + onPaneActivate: (terminalId: string) => void; + onResizeEnd: () => void; + renderTerminal: (terminalId: string) => ReactNode; +} + +function sizesDiffer(left: readonly number[], right: readonly number[]) { + return left.some((size, index) => size !== right[index]); +} + +/** Renders terminal panes with frame-synchronous, directly manipulated split handles. */ +export function TerminalSplitPanes({ + terminalIds, + direction, + activeTerminalId, + sizes, + onSizesChange, + onPaneActivate, + onResizeEnd, + renderTerminal, +}: TerminalSplitPanesProps) { + const containerRef = useRef(null); + const containerPxRef = useRef(0); + const renderedContainerPxRef = useRef(0); + const [containerPx, setContainerPx] = useState(0); + const latestSizesRef = useRef([]); + const draggingRef = useRef(false); + const handleStateRef = useRef>([]); + const callbacksRef = useRef({ onSizesChange, onResizeEnd }); + useLayoutEffect(() => { + callbacksRef.current = { onSizesChange, onResizeEnd }; + }, [onSizesChange, onResizeEnd]); + + const resolved = resolvePaneSizes(sizes, terminalIds.length); + const resolvedRef = useRef(resolved); + const displayed = constrainPaneSizes(resolved, containerPx, MIN_TERMINAL_PANE_PX[direction]); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + const observer = new ResizeObserver(([entry]) => { + if (!entry) return; + const nextContainerPx = + direction === "horizontal" ? entry.contentRect.width : entry.contentRect.height; + containerPxRef.current = nextContainerPx; + const currentSizes = resolvedRef.current; + const minimum = MIN_TERMINAL_PANE_PX[direction]; + const previous = constrainPaneSizes(currentSizes, renderedContainerPxRef.current, minimum); + const next = constrainPaneSizes(currentSizes, nextContainerPx, minimum); + if (!sizesDiffer(previous, next)) return; + renderedContainerPxRef.current = nextContainerPx; + setContainerPx(nextContainerPx); + }); + observer.observe(container); + return () => observer.disconnect(); + }, [direction, sizes, terminalIds.length]); + + const writeSizesToDom = useCallback( + (nextSizes: readonly number[], extentPx?: number) => { + const container = containerRef.current; + if (!container) return; + const horizontal = direction === "horizontal"; + const boundaries = extentPx && extentPx > 0 ? panePixelBoundaries(nextSizes, extentPx) : null; + // Terminal canvases paint on whole CSS pixels; fractional tracks make text and borders drift. + const template = boundaries + ? [ + ...boundaries.map((boundary, index) => { + const previousBoundary = boundaries[index - 1] ?? 0; + return `${boundary - previousBoundary}px`; + }), + "minmax(0, 1fr)", + ].join(" ") + : paneGridTemplate(nextSizes); + container.style.gridTemplateColumns = horizontal ? template : ""; + container.style.gridTemplateRows = horizontal ? "" : template; + const positions = boundaries ?? paneBoundaryOffsets(nextSizes).map((offset) => offset * 100); + for (const [index, positionValue] of positions.entries()) { + const handle = handleStateRef.current[index]; + if (!handle) continue; + const position = boundaries ? `${positionValue}px` : `calc(${positionValue}%)`; + handle.style.left = horizontal ? position : ""; + handle.style.top = horizontal ? "" : position; + } + }, + [direction], + ); + + const resizeHandlers = useResizeDrag( + (event) => { + const dragContainerPx = containerPxRef.current; + if (dragContainerPx <= 0) return null; + // React clears currentTarget after dispatch; cleanup runs at drag end. + const handle = event.currentTarget; + const handleIndex = Number(handle.dataset.handleIndex); + const startSizes = displayed; + const boundaryStartPx = paneBoundaryOffsets(startSizes)[handleIndex]! * dragContainerPx; + draggingRef.current = true; + latestSizesRef.current = startSizes; + handle.dataset.dragging = "true"; + + return { + width: boundaryStartPx, + axis: direction === "horizontal" ? "x" : "y", + edge: "right", + resize(value) { + const next = resizeAdjacentPanes({ + sizes: startSizes, + handleIndex, + deltaPx: value - boundaryStartPx, + containerPx: dragContainerPx, + minPanePx: MIN_TERMINAL_PANE_PX[direction], + }); + const snapped = snapPaneSizesToWholePixels(next, dragContainerPx); + writeSizesToDom(snapped, dragContainerPx); + latestSizesRef.current = snapped; + return panePixelBoundaries(snapped, dragContainerPx)[handleIndex]!; + }, + finish(_value, moved) { + const changed = sizesDiffer(startSizes, latestSizesRef.current); + if (moved && changed) { + callbacksRef.current.onSizesChange(latestSizesRef.current); + callbacksRef.current.onResizeEnd(); + } else { + latestSizesRef.current = startSizes; + writeSizesToDom(startSizes); + } + }, + cleanup() { + draggingRef.current = false; + handle.removeAttribute("data-dragging"); + }, + }; + }, + `${direction}:${terminalIds.join(",")}`, + ); + + useLayoutEffect(() => { + resolvedRef.current = resolved; + if (draggingRef.current) { + writeSizesToDom(latestSizesRef.current, containerPxRef.current); + return; + } + latestSizesRef.current = displayed; + writeSizesToDom(displayed); + }); + + const handleKeyDown = (event: ReactKeyboardEvent, handleIndex: number) => { + if (draggingRef.current) return; + + if (event.key === "Enter") { + event.preventDefault(); + event.stopPropagation(); + callbacksRef.current.onSizesChange(equalPaneSizes(terminalIds.length)); + callbacksRef.current.onResizeEnd(); + return; + } + + const step = event.shiftKey ? 96 : 24; + const deltaPx = + direction === "horizontal" + ? event.key === "ArrowLeft" + ? -step + : event.key === "ArrowRight" + ? step + : undefined + : event.key === "ArrowUp" + ? -step + : event.key === "ArrowDown" + ? step + : undefined; + if (deltaPx === undefined) return; + + event.preventDefault(); + event.stopPropagation(); + const next = resizeAdjacentPanes({ + sizes: displayed, + handleIndex, + deltaPx, + containerPx: containerPxRef.current, + minPanePx: MIN_TERMINAL_PANE_PX[direction], + }); + const snapped = snapPaneSizesToWholePixels(next, containerPxRef.current); + callbacksRef.current.onSizesChange(snapped); + callbacksRef.current.onResizeEnd(); + }; + + // Mid-drag re-renders are corrected by the layout effect, which re-applies latestSizesRef. + const offsets = paneBoundaryOffsets(displayed); + const gridStyle = + direction === "horizontal" + ? { gridTemplateColumns: paneGridTemplate(displayed) } + : { gridTemplateRows: paneGridTemplate(displayed) }; + + return ( +
+ {terminalIds.map((terminalId) => ( +
{ + if (terminalId !== activeTerminalId) onPaneActivate(terminalId); + }} + > +
{renderTerminal(terminalId)}
+
+ ))} + {offsets.map((offset: number, handleIndex: number) => ( +
{ + handleStateRef.current[handleIndex] = element; + }} + className={`group absolute z-20 select-none touch-none outline-none ${ + direction === "horizontal" + ? "bottom-0 top-0 w-2 -translate-x-1/2 cursor-col-resize" + : "left-0 right-0 h-2 -translate-y-1/2 cursor-row-resize" + }`} + style={ + direction === "horizontal" + ? { left: `calc(${offset * 100}%)` } + : { top: `calc(${offset * 100}%)` } + } + role="separator" + tabIndex={0} + aria-orientation={direction === "horizontal" ? "vertical" : "horizontal"} + aria-label="Resize terminal panes" + aria-valuemin={0} + aria-valuemax={100} + aria-valuenow={Math.round(offset * 100)} + data-handle-index={handleIndex} + {...resizeHandlers} + onKeyDown={(event) => handleKeyDown(event, handleIndex)} + onDoubleClick={() => { + callbacksRef.current.onSizesChange(equalPaneSizes(terminalIds.length)); + callbacksRef.current.onResizeEnd(); + }} + > + +
+ ))} +
+ ); +} diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 91c7cc855596..4e7825d38823 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -42,6 +42,7 @@ import { import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { Button } from "~/components/ui/button"; import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button"; +import { TerminalSplitPanes } from "~/components/TerminalSplitPanes"; import { stackedThreadToast, toastManager } from "~/components/ui/toast"; import { readTextFromClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { cn } from "~/lib/utils"; @@ -1106,6 +1107,7 @@ export default function ThreadTerminalDrawer({ setDrawerHeight(nextHeight); }); const [resizeEpoch, setResizeEpoch] = useState(0); + const [splitSizesByGroupId, setSplitSizesByGroupId] = useState>({}); const drawerHeightRef = useRef(drawerHeight); const lastSyncedHeightRef = useRef(controlledDrawerHeight); const onHeightChangeRef = useRef(onHeightChange); @@ -1225,6 +1227,10 @@ export default function ThreadTerminalDrawer({ (normalizedTerminalIds.length > 0 ? [resolvedActiveTerminalId] : []); const splitDirection = resolvedTerminalGroups[resolvedActiveGroupIndex]?.splitDirection ?? "horizontal"; + const resolvedActiveGroupId = resolvedTerminalGroups[resolvedActiveGroupIndex]?.id; + const splitSizeKey = resolvedActiveGroupId + ? `${resolvedActiveGroupId}:${splitDirection}` + : undefined; const hasTerminalSidebar = normalizedTerminalIds.length > 1; const isSplitView = visibleTerminalIds.length > 1; const showGroupHeaders = @@ -1494,66 +1500,48 @@ export default function ThreadTerminalDrawer({ >
{isSplitView ? ( -
- {visibleTerminalIds.map((terminalId) => { + { + if (!splitSizeKey) return; + setSplitSizesByGroupId((previous) => ({ + ...previous, + [splitSizeKey]: sizes, + })); + }} + onPaneActivate={onActiveTerminalChange} + onResizeEnd={() => setResizeEpoch((value) => value + 1)} + renderTerminal={(terminalId) => { const terminalLaunchLocation = resolveTerminalLaunchLocation(terminalId); return ( -
{ - if (terminalId !== resolvedActiveTerminalId) { - onActiveTerminalChange(terminalId); - } - }} - > -
- onCloseTerminal(terminalId)} - onAddTerminalContext={onAddTerminalContext} - focusRequestId={focusRequestId} - autoFocus={terminalId === resolvedActiveTerminalId} - visible={visible} - resizeEpoch={resizeEpoch} - drawerHeight={drawerHeight} - keybindings={keybindings} - /> -
-
+ onCloseTerminal(terminalId)} + onAddTerminalContext={onAddTerminalContext} + focusRequestId={focusRequestId} + autoFocus={terminalId === resolvedActiveTerminalId} + visible={visible} + resizeEpoch={resizeEpoch} + drawerHeight={drawerHeight} + keybindings={keybindings} + /> ); - })} -
+ }} + /> ) : (
>; +let frame: FrameRequestCallback | undefined; +let captured = false; + +const target = { + setPointerCapture: () => { + captured = true; + }, + hasPointerCapture: () => captured, + releasePointerCapture: () => { + captured = false; + }, +}; +const style = { + cursor: "", + userSelect: "", + removeProperty(property: string) { + if (property === "cursor") this.cursor = ""; + if (property === "user-select") this.userSelect = ""; + }, +}; + +function pointer(clientX: number, clientY: number) { + return { + button: 0, + pointerId: 1, + clientX, + clientY, + currentTarget: target, + preventDefault() {}, + stopPropagation() {}, + } as unknown as PointerEvent; +} + +function ResizeHarness({ + resize, + finish, +}: { + resize: (value: number) => number; + finish: () => void; +}) { + const nextHandlers = useResizeDrag(() => ({ + width: 200, + axis: "y", + edge: "right", + resize, + finish, + })); + useLayoutEffect(() => { + handlers = nextHandlers; + }); + return null; +} + +beforeEach(() => { + captured = false; + frame = undefined; + style.cursor = ""; + style.userSelect = ""; + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", { addEventListener() {}, removeEventListener() {} }); + vi.stubGlobal("document", { body: { style } }); + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + frame = callback; + return 42; + }); + vi.stubGlobal("cancelAnimationFrame", vi.fn()); +}); + +afterEach(async () => { + await act(() => renderer.unmount()); + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +describe("vertical resize drag", () => { + it("uses clientY and the row resize cursor", async () => { + const resize = vi.fn((value: number) => value); + const finish = vi.fn(); + await act(() => { + renderer = create(); + }); + + await act(() => { + handlers.onPointerDown(pointer(100, 300)); + handlers.onPointerMove(pointer(500, 325)); + }); + expect(style.cursor).toBe("row-resize"); + await act(() => frame?.(0)); + expect(resize).toHaveBeenLastCalledWith(225); + + await act(() => handlers.onPointerUp(pointer(900, 340))); + expect(resize).toHaveBeenLastCalledWith(240); + expect(finish).toHaveBeenCalledWith(240, true); + }); +}); diff --git a/apps/web/src/hooks/useResizeDrag.ts b/apps/web/src/hooks/useResizeDrag.ts index 6509a61767d0..8c8c35cf311d 100644 --- a/apps/web/src/hooks/useResizeDrag.ts +++ b/apps/web/src/hooks/useResizeDrag.ts @@ -2,6 +2,8 @@ import { type PointerEvent, useCallback, useEffect, useLayoutEffect, useRef } fr interface ResizeSession { width: number; + axis?: "x" | "y"; + // Edge meaning is unchanged across axes: left subtracts the pointer delta, right adds it. edge: "left" | "right"; resize: (width: number) => number; finish: (width: number, moved: boolean) => void; @@ -17,8 +19,8 @@ export function useResizeDrag( session: ResizeSession; target: T; pointerId: number; - startX: number; - pendingX: number; + startPosition: number; + pendingPosition: number; width: number; moved: boolean; frame: number | null; @@ -27,7 +29,8 @@ export function useResizeDrag( const flush = useCallback(() => { const active = drag.current; if (!active) return; - const delta = (active.pendingX - active.startX) * (active.session.edge === "left" ? -1 : 1); + const delta = + (active.pendingPosition - active.startPosition) * (active.session.edge === "left" ? -1 : 1); active.moved ||= Math.abs(delta) > 2; active.width = active.session.resize(active.session.width + delta); }, []); @@ -76,7 +79,9 @@ export function useResizeDrag( const active = drag.current; if (!active || active.pointerId !== event.pointerId) return; event.preventDefault(); - if (usePosition) active.pendingX = event.clientX; + if (usePosition) { + active.pendingPosition = active.session.axis === "y" ? event.clientY : event.clientX; + } finish(); }; @@ -93,25 +98,27 @@ export function useResizeDrag( } event.preventDefault(); event.stopPropagation(); + const position = session.axis === "y" ? event.clientY : event.clientX; drag.current = { session, target: event.currentTarget, pointerId: event.pointerId, - startX: event.clientX, - pendingX: event.clientX, + startPosition: position, + pendingPosition: position, width: session.width, moved: false, frame: null, }; - document.body.style.cursor = "col-resize"; + document.body.style.cursor = session.axis === "y" ? "row-resize" : "col-resize"; document.body.style.userSelect = "none"; }, onPointerMove(event: PointerEvent) { const active = drag.current; if (!active || active.pointerId !== event.pointerId) return; event.preventDefault(); - active.pendingX = event.clientX; - active.moved ||= Math.abs(event.clientX - active.startX) > 2; + const position = active.session.axis === "y" ? event.clientY : event.clientX; + active.pendingPosition = position; + active.moved ||= Math.abs(position - active.startPosition) > 2; if (active.frame !== null) return; active.frame = requestAnimationFrame(() => { active.frame = null; diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 59150ee320ae..b5d7e12f1092 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -145,6 +145,7 @@ describe("GhosttyTerminalSurface visibility", () => { }, ); const snapshot = vi.spyOn(GhosttyTerminalCore.prototype, "snapshot"); + const coreResize = vi.spyOn(GhosttyTerminalCore.prototype, "resize"); const onData = vi.fn<(data: string) => void>(); return { @@ -153,6 +154,7 @@ describe("GhosttyTerminalSurface visibility", () => { paint, requestFrame, snapshot, + coreResize, onData, get renderedSnapshot() { const result = snapshot.mock.results.at(-1); @@ -210,6 +212,151 @@ describe("GhosttyTerminalSurface visibility", () => { vi.restoreAllMocks(); }); + it("restores the PTY layout and suspends paint after a settled narrowing", async () => { + const harness = createHarness(); + const onResize = vi.fn(); + await harness.create({ onResize }); + vi.advanceTimersByTime(150); + harness.coreResize.mockClear(); + onResize.mockClear(); + + harness.mount.clientWidth = 88; + harness.resize(); + harness.snapshot.mockClear(); + harness.paint.mockClear(); + vi.advanceTimersByTime(150); + harness.flushFrame(); + + expect(harness.coreResize.mock.calls.map(([cols, rows]) => [cols, rows])).toEqual([ + [10, 6], + [20, 6], + ]); + expect(onResize).toHaveBeenCalledOnce(); + expect(onResize).toHaveBeenCalledWith(10, 6); + expect(harness.snapshot).not.toHaveBeenCalled(); + expect(harness.paint).not.toHaveBeenCalled(); + }); + + it("restores the new grid once on output without notifying twice", async () => { + const harness = createHarness(); + const onResize = vi.fn(); + const surface = await harness.create({ onResize }); + vi.advanceTimersByTime(150); + onResize.mockClear(); + harness.mount.clientWidth = 88; + harness.resize(); + vi.advanceTimersByTime(150); + harness.coreResize.mockClear(); + harness.snapshot.mockClear(); + + surface.write("redrawn"); + harness.flushFrame(); + + expect(harness.coreResize.mock.calls.map(([cols, rows]) => [cols, rows])).toEqual([[10, 6]]); + expect(onResize).toHaveBeenCalledOnce(); + expect(harness.snapshot).toHaveBeenCalledOnce(); + }); + + it("restores and repaints after the output fallback timeout", async () => { + const harness = createHarness(); + await harness.create(); + vi.advanceTimersByTime(150); + harness.mount.clientWidth = 88; + harness.resize(); + vi.advanceTimersByTime(150); + harness.coreResize.mockClear(); + harness.snapshot.mockClear(); + + vi.advanceTimersByTime(250); + harness.flushFrame(); + + expect(harness.coreResize.mock.calls.map(([cols, rows]) => [cols, rows])).toEqual([[10, 6]]); + expect(harness.snapshot).toHaveBeenCalledOnce(); + }); + + it("notifies an alternate-screen resize without restoring the old grid", async () => { + const harness = createHarness(); + const onResize = vi.fn(); + const surface = await harness.create({ onResize }); + vi.advanceTimersByTime(150); + surface.write("\x1b[?1049h"); + harness.flushFrame(); + harness.coreResize.mockClear(); + onResize.mockClear(); + + harness.mount.clientWidth = 88; + harness.resize(); + vi.advanceTimersByTime(150); + + expect(harness.coreResize.mock.calls.map(([cols, rows]) => [cols, rows])).toEqual([[10, 6]]); + expect(onResize).toHaveBeenCalledOnce(); + expect(onResize).toHaveBeenCalledWith(10, 6); + }); + + it("notifies a rows-only resize without restoring the old grid", async () => { + const harness = createHarness(); + const onResize = vi.fn(); + await harness.create({ onResize }); + vi.advanceTimersByTime(150); + harness.coreResize.mockClear(); + onResize.mockClear(); + + harness.mount.clientHeight = 120; + harness.resize(); + vi.advanceTimersByTime(150); + + expect(harness.coreResize.mock.calls.map(([cols, rows]) => [cols, rows])).toEqual([[20, 7]]); + expect(onResize).toHaveBeenCalledOnce(); + expect(onResize).toHaveBeenCalledWith(20, 7); + }); + + it("resumes live reflow and paint when a resize continues during a pending restore", async () => { + const harness = createHarness(); + const onResize = vi.fn(); + const surface = await harness.create({ onResize }); + vi.advanceTimersByTime(150); + harness.mount.clientWidth = 88; + harness.resize(); + vi.advanceTimersByTime(150); + harness.coreResize.mockClear(); + harness.snapshot.mockClear(); + onResize.mockClear(); + + // Resizing the canvas clears it, so a resumed drag must repaint instead of staying blank. + harness.mount.clientWidth = 128; + harness.resize(); + harness.flushFrame(); + expect(harness.coreResize.mock.calls.map(([cols, rows]) => [cols, rows])).toEqual([ + [10, 6], + [15, 6], + ]); + expect(harness.snapshot).toHaveBeenCalled(); + + vi.advanceTimersByTime(150); + expect(onResize).toHaveBeenCalledOnce(); + expect(onResize).toHaveBeenCalledWith(15, 6); + harness.coreResize.mockClear(); + surface.write("redrawn again"); + expect(harness.coreResize.mock.calls.map(([cols, rows]) => [cols, rows])).toEqual([[15, 6]]); + }); + + it("disposes a pending restore without painting or throwing", async () => { + const harness = createHarness(); + const surface = await harness.create(); + vi.advanceTimersByTime(150); + harness.mount.clientWidth = 88; + harness.resize(); + vi.advanceTimersByTime(150); + harness.coreResize.mockClear(); + harness.snapshot.mockClear(); + + expect(() => surface.dispose()).not.toThrow(); + harness.flushFrame(); + + expect(harness.coreResize.mock.calls.map(([cols, rows]) => [cols, rows])).toEqual([[10, 6]]); + expect(harness.snapshot).not.toHaveBeenCalled(); + }); + it("stops hidden snapshots and paint while preserving live VT replies and the next cursor", async () => { const harness = createHarness(); const surface = await harness.create(); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index be62ede4d065..dfde54822726 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -589,6 +589,11 @@ export class GhosttyTerminalSurface { private scrollbarPointerId: number | null = null; private scrollbarPointerOffset = 0; private disposed = false; + private ptyCols: number | null = null; + private ptyRows: number | null = null; + private paintSuspended = false; + private pendingRestore: { cols: number; rows: number } | null = null; + private pendingRestoreTimer: number | null = null; private resizeNotifyTimer: number | null = null; private originY = CONTENT_PADDING; private mountHeight = 0; @@ -764,6 +769,7 @@ export class GhosttyTerminalSurface { write(data: string): void { if (this.disposed) return; this.core.write(data); + this.applyPendingRestore(); this.synchronizeMouseTrackingState(); // Restart the blink cycle from the visible phase so the cursor never sits // invisible through a stream of output or a burst of typing echo. @@ -776,6 +782,7 @@ export class GhosttyTerminalSurface { if (this.disposed) return; this.lastMouseMotionData = ""; this.core.resetAndWrite(data); + this.applyPendingRestore(); this.synchronizeMouseTrackingState(); // A replayed session starts from the visible phase like any other write: // reattaching mid-blink must not open on an invisible cursor. @@ -873,6 +880,13 @@ export class GhosttyTerminalSurface { // The DPR transform must be installed even when the target size happens to // equal the canvas default 300x150 backing store, so the first fit always // schedules a canvas configuration. + // Resizing the canvas clears it, so a resize during a pending restore resumes live paint first. + if ( + this.pendingRestore !== null && + (this.canvas.width !== pixelWidth || this.canvas.height !== pixelHeight) + ) { + this.applyPendingRestore(); + } if ( this.canvas.width !== pixelWidth || this.canvas.height !== pixelHeight || @@ -893,6 +907,9 @@ export class GhosttyTerminalSurface { if (grid.cols !== this.cols || grid.rows !== this.rows || !this.resizeNotified) { this.cols = grid.cols; this.rows = grid.rows; + // A resize that resumes while the shell redraws must keep reflowing and painting live; + // the next settle restores the shell's layout again. + if (this.pendingRestore !== null) this.applyPendingRestore(); this.core.resize(grid.cols, grid.rows, this.metrics.width, this.metrics.height); this.notifyResize(); this.forceFullRender = true; @@ -916,10 +933,53 @@ export class GhosttyTerminalSurface { if (this.resizeNotifyTimer !== null) window.clearTimeout(this.resizeNotifyTimer); this.resizeNotifyTimer = window.setTimeout(() => { this.resizeNotifyTimer = null; - if (!this.disposed) this.options.onResize(this.cols, this.rows); + if (this.disposed) return; + const newCols = this.cols; + const newRows = this.rows; + if (newCols !== this.ptyCols && !this.core.isAlternateScreen() && this.ptyCols !== null) { + // Shells redraw assuming their last known width. + this.paintSuspended = true; + this.core.resize(this.ptyCols, this.ptyRows!, this.metrics.width, this.metrics.height); + this.ptyCols = newCols; + this.ptyRows = newRows; + this.options.onResize(newCols, newRows); + this.pendingRestore = { cols: newCols, rows: newRows }; + if (this.pendingRestoreTimer !== null) { + window.clearTimeout(this.pendingRestoreTimer); + } + this.pendingRestoreTimer = window.setTimeout(() => this.applyPendingRestore(), 250); + return; + } + this.ptyCols = this.cols; + this.ptyRows = this.rows; + this.options.onResize(this.cols, this.rows); + if (this.pendingRestore !== null && this.pendingRestoreTimer === null) { + this.pendingRestoreTimer = window.setTimeout(() => this.applyPendingRestore(), 250); + } }, 150); } + private applyPendingRestore(): void { + if (this.pendingRestore === null || this.ptyCols === null || this.ptyRows === null) { + return; + } + if (this.pendingRestoreTimer !== null) { + window.clearTimeout(this.pendingRestoreTimer); + } + this.core.resize( + this.pendingRestore.cols, + this.pendingRestore.rows, + this.metrics.width, + this.metrics.height, + ); + this.pendingRestore = null; + this.pendingRestoreTimer = null; + this.paintSuspended = false; + this.forceFullRender = true; + this.scrollbarDirty = true; + this.requestRender(); + } + focus(): void { if (this.disposed || !this.visible) return; this.input.focus({ preventScroll: true }); @@ -1028,6 +1088,10 @@ export class GhosttyTerminalSurface { this.dprMedia = null; this.reducedMotionMedia?.removeEventListener("change", this.onReducedMotionChange); if (this.selectionScrollTimer !== null) window.clearInterval(this.selectionScrollTimer); + if (this.pendingRestoreTimer !== null) { + window.clearTimeout(this.pendingRestoreTimer); + this.pendingRestoreTimer = null; + } if (this.resizeNotifyTimer !== null) { window.clearTimeout(this.resizeNotifyTimer); this.resizeNotifyTimer = null; @@ -1035,6 +1099,8 @@ export class GhosttyTerminalSurface { // the surface unmounts inside the debounce window. this.options.onResize(this.cols, this.rows); } + if (this.pendingRestore !== null) this.applyPendingRestore(); + this.pendingRestore = null; this.cancelRender(); if (this.compositionSuppressionTimer !== null) { window.clearTimeout(this.compositionSuppressionTimer); @@ -1781,6 +1847,7 @@ export class GhosttyTerminalSurface { } private requestRender(): void { + if (this.paintSuspended) return; if (this.disposed || !this.visible || !this.hasSize || this.frame !== 0) return; this.frame = window.requestAnimationFrame(() => { this.frame = 0; @@ -1800,6 +1867,7 @@ export class GhosttyTerminalSurface { } private renderFrame(): void { + if (this.paintSuspended) return; if (this.disposed || !this.visible) return; if (this.frame !== 0) { window.cancelAnimationFrame(this.frame); diff --git a/apps/web/src/terminal/splitPaneSizes.test.ts b/apps/web/src/terminal/splitPaneSizes.test.ts new file mode 100644 index 000000000000..18a7196908e2 --- /dev/null +++ b/apps/web/src/terminal/splitPaneSizes.test.ts @@ -0,0 +1,416 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + constrainPaneSizes, + equalPaneSizes, + paneBoundaryOffsets, + paneGridTemplate, + panePixelBoundaries, + resizeAdjacentPanes, + resolvePaneSizes, + snapPaneSizesToWholePixels, +} from "./splitPaneSizes"; + +describe("constrainPaneSizes", () => { + it("returns unchanged when all panes fit at minimum", () => { + const sizes = [0.25, 0.75]; + const result = constrainPaneSizes(sizes, 1000, 160); + expect(result).toEqual(sizes); + expect(result).not.toBe(sizes); + }); + + it("raises a below-min pane and takes the deficit proportionally", () => { + const result = constrainPaneSizes([0.1, 0.3, 0.6], 1000, 200); + expect(result[0]!).toBeCloseTo(0.2, 5); + expect(result[1]!).toBeCloseTo(0.28, 5); + expect(result[2]!).toBeCloseTo(0.52, 5); + }); + + it("sums to exactly 1", () => { + const result = constrainPaneSizes([0.05, 0.25, 0.7], 1000, 200); + expect(result.reduce((total, size) => total + size, 0)).toBe(1); + }); + + it("falls back to equal sizes when the container cannot fit every minimum", () => { + expect(constrainPaneSizes([0.1, 0.2, 0.7], 400, 160)).toEqual(equalPaneSizes(3)); + }); + + it("returns a copy unchanged when containerPx <= 0", () => { + const sizes = [0.25, 0.75]; + const result = constrainPaneSizes(sizes, 0, 160); + expect(result).toEqual(sizes); + expect(result).not.toBe(sizes); + }); + + it("returns a copy unchanged for a single pane", () => { + const sizes = [1]; + const result = constrainPaneSizes(sizes, 100, 160); + expect(result).toEqual(sizes); + expect(result).not.toBe(sizes); + }); + + it("never mutates the input", () => { + const sizes = [0.1, 0.3, 0.6]; + const copy = [...sizes]; + constrainPaneSizes(sizes, 1000, 200); + expect(sizes).toEqual(copy); + }); +}); + +describe("equalPaneSizes", () => { + it("returns empty array for count <= 0", () => { + expect(equalPaneSizes(0)).toEqual([]); + expect(equalPaneSizes(-1)).toEqual([]); + }); + + it("returns equal fractions for count > 0", () => { + expect(equalPaneSizes(1)).toEqual([1]); + expect(equalPaneSizes(2)).toEqual([0.5, 0.5]); + expect(equalPaneSizes(3)).toEqual([1 / 3, 1 / 3, 1 / 3]); + expect(equalPaneSizes(4)).toEqual([0.25, 0.25, 0.25, 0.25]); + }); + + it("sums to 1", () => { + for (const count of [1, 2, 3, 5, 10]) { + const sizes = equalPaneSizes(count); + const sum = sizes.reduce((a, b) => a + b, 0); + expect(sum).toBeCloseTo(1, 5); + } + }); +}); + +describe("resolvePaneSizes", () => { + it("returns equalPaneSizes when sizes is undefined", () => { + expect(resolvePaneSizes(undefined, 2)).toEqual([0.5, 0.5]); + expect(resolvePaneSizes(undefined, 3)).toEqual([1 / 3, 1 / 3, 1 / 3]); + }); + + it("returns equalPaneSizes when length mismatch", () => { + expect(resolvePaneSizes([0.5, 0.5], 3)).toEqual([1 / 3, 1 / 3, 1 / 3]); + expect(resolvePaneSizes([0.25, 0.25, 0.25, 0.25], 2)).toEqual([0.5, 0.5]); + }); + + it("returns equalPaneSizes when entry is non-finite", () => { + expect(resolvePaneSizes([0.5, NaN], 2)).toEqual([0.5, 0.5]); + expect(resolvePaneSizes([0.5, Infinity], 2)).toEqual([0.5, 0.5]); + }); + + it("returns equalPaneSizes when entry is <= 0", () => { + expect(resolvePaneSizes([0.5, 0], 2)).toEqual([0.5, 0.5]); + expect(resolvePaneSizes([0.5, -0.1], 2)).toEqual([0.5, 0.5]); + }); + + it("normalizes valid sizes to sum to 1", () => { + expect(resolvePaneSizes([1, 1], 2)).toEqual([0.5, 0.5]); + expect(resolvePaneSizes([1, 2, 3], 3)).toEqual([1 / 6, 2 / 6, 3 / 6]); + expect(resolvePaneSizes([2, 3, 5], 3)).toEqual([0.2, 0.3, 0.5]); + }); + + it("returns a new array, not the input", () => { + const input = [0.5, 0.5]; + const result = resolvePaneSizes(input, 2); + expect(result).not.toBe(input); + }); +}); + +describe("resizeAdjacentPanes", () => { + it("returns a copy when containerPx <= 0", () => { + const sizes = [0.5, 0.5]; + const result = resizeAdjacentPanes({ + sizes, + handleIndex: 0, + deltaPx: 10, + containerPx: 0, + minPanePx: 100, + }); + expect(result).toEqual(sizes); + expect(result).not.toBe(sizes); + }); + + it("returns a copy when handleIndex out of range", () => { + const sizes = [0.5, 0.5]; + expect( + resizeAdjacentPanes({ + sizes, + handleIndex: -1, + deltaPx: 10, + containerPx: 100, + minPanePx: 50, + }), + ).toEqual(sizes); + + expect( + resizeAdjacentPanes({ + sizes, + handleIndex: 1, + deltaPx: 10, + containerPx: 100, + minPanePx: 50, + }), + ).toEqual(sizes); + + expect( + resizeAdjacentPanes({ + sizes, + handleIndex: 2, + deltaPx: 10, + containerPx: 100, + minPanePx: 50, + }), + ).toEqual(sizes); + }); + + it("returns a copy when deltaPx is not finite", () => { + const sizes = [0.5, 0.5]; + expect( + resizeAdjacentPanes({ + sizes, + handleIndex: 0, + deltaPx: NaN, + containerPx: 100, + minPanePx: 50, + }), + ).toEqual(sizes); + + expect( + resizeAdjacentPanes({ + sizes, + handleIndex: 0, + deltaPx: Infinity, + containerPx: 100, + minPanePx: 50, + }), + ).toEqual(sizes); + }); + + it("grows left pane and shrinks right with positive deltaPx", () => { + const result = resizeAdjacentPanes({ + sizes: [0.5, 0.5], + handleIndex: 0, + deltaPx: 25, + containerPx: 200, + minPanePx: 20, + }); + expect(result[0]!).toBeCloseTo(0.625, 5); + expect(result[1]!).toBeCloseTo(0.375, 5); + }); + + it("shrinks left pane and grows right with negative deltaPx", () => { + const result = resizeAdjacentPanes({ + sizes: [0.5, 0.5], + handleIndex: 0, + deltaPx: -25, + containerPx: 200, + minPanePx: 20, + }); + expect(result[0]!).toBeCloseTo(0.375, 5); + expect(result[1]!).toBeCloseTo(0.625, 5); + }); + + it("preserves combined fraction of two panes", () => { + const sizes = [0.3, 0.7]; + const result = resizeAdjacentPanes({ + sizes, + handleIndex: 0, + deltaPx: 50, + containerPx: 100, + minPanePx: 10, + }); + const combined = (result[0] ?? 0) + (result[1] ?? 0); + expect(combined).toBeCloseTo(1.0, 5); + }); + + it("clamps to minimum fraction at left edge", () => { + const result = resizeAdjacentPanes({ + sizes: [0.5, 0.5], + handleIndex: 0, + deltaPx: -100, + containerPx: 200, + minPanePx: 80, + }); + expect(result[0]!).toBeCloseTo(0.4, 5); + expect(result[1]!).toBeCloseTo(0.6, 5); + }); + + it("clamps to minimum fraction at right edge", () => { + const result = resizeAdjacentPanes({ + sizes: [0.5, 0.5], + handleIndex: 0, + deltaPx: 100, + containerPx: 200, + minPanePx: 80, + }); + expect(result[0]!).toBeCloseTo(0.6, 5); + expect(result[1]!).toBeCloseTo(0.4, 5); + }); + + it("uses half of combined when pair is too small for both mins", () => { + const result = resizeAdjacentPanes({ + sizes: [0.1, 0.1], + handleIndex: 0, + deltaPx: 50, + containerPx: 100, + minPanePx: 60, + }); + expect(result[0]!).toBeCloseTo(0.1, 5); + expect(result[1]!).toBeCloseTo(0.1, 5); + }); + + it("does not mutate input", () => { + const sizes = [0.5, 0.5]; + const sizesCopy = [...sizes]; + resizeAdjacentPanes({ + sizes, + handleIndex: 0, + deltaPx: 50, + containerPx: 100, + minPanePx: 10, + }); + expect(sizes).toEqual(sizesCopy); + }); + + it("works with multiple panes", () => { + const sizes = [0.25, 0.25, 0.25, 0.25]; + const result = resizeAdjacentPanes({ + sizes, + handleIndex: 1, + deltaPx: 10, + containerPx: 200, + minPanePx: 10, + }); + expect(result[0]!).toBeCloseTo(0.25, 5); + expect(result[1]!).toBeCloseTo(0.3, 5); + expect(result[2]!).toBeCloseTo(0.2, 5); + expect(result[3]!).toBeCloseTo(0.25, 5); + }); +}); + +describe("paneGridTemplate", () => { + it("formats single pane", () => { + expect(paneGridTemplate([1])).toBe("minmax(0, 1fr)"); + }); + + it("formats two equal panes", () => { + expect(paneGridTemplate([0.5, 0.5])).toBe("minmax(0, 0.5fr) minmax(0, 0.5fr)"); + }); + + it("formats three equal panes", () => { + expect(paneGridTemplate([1 / 3, 1 / 3, 1 / 3])).toBe( + "minmax(0, 0.333333fr) minmax(0, 0.333333fr) minmax(0, 0.333333fr)", + ); + }); + + it("formats unequal panes", () => { + const result = paneGridTemplate([0.25, 0.75]); + expect(result).toBe("minmax(0, 0.25fr) minmax(0, 0.75fr)"); + }); + + it("limits fractions to 6 decimal places", () => { + const result = paneGridTemplate([0.3333333, 0.6666667]); + expect(result).toContain("0.333333"); + expect(result).toContain("0.666667"); + }); +}); + +describe("paneBoundaryOffsets", () => { + it("returns empty array for single pane", () => { + expect(paneBoundaryOffsets([1])).toEqual([]); + }); + + it("returns correct offsets for two equal panes", () => { + expect(paneBoundaryOffsets([0.5, 0.5])).toEqual([0.5]); + }); + + it("returns correct offsets for two unequal panes", () => { + expect(paneBoundaryOffsets([0.25, 0.75])).toEqual([0.25]); + }); + + it("returns correct offsets for three equal panes", () => { + expect(paneBoundaryOffsets([1 / 3, 1 / 3, 1 / 3])).toEqual([1 / 3, 2 / 3]); + }); + + it("returns correct offsets for three unequal panes", () => { + const result = paneBoundaryOffsets([0.25, 0.25, 0.5]); + expect(result[0]!).toBeCloseTo(0.25, 5); + expect(result[1]!).toBeCloseTo(0.5, 5); + }); + + it("has length sizes.length - 1", () => { + for (const count of [1, 2, 3, 5, 10]) { + const sizes = equalPaneSizes(count); + const offsets = paneBoundaryOffsets(sizes); + expect(offsets).toHaveLength(count - 1); + } + }); + + it("boundary offsets are in range [0, 1)", () => { + const sizes = [0.1, 0.2, 0.3, 0.4]; + const offsets = paneBoundaryOffsets(sizes); + for (const offset of offsets) { + expect(offset).toBeGreaterThanOrEqual(0); + expect(offset).toBeLessThan(1); + } + }); + + it("boundary offsets are increasing", () => { + const sizes = [0.1, 0.2, 0.3, 0.4]; + const offsets = paneBoundaryOffsets(sizes); + for (let i = 1; i < offsets.length; i++) { + expect(offsets[i]!).toBeGreaterThan(offsets[i - 1]!); + } + }); +}); + +describe("snapPaneSizesToWholePixels", () => { + it("snaps boundaries to increasing whole pixels and preserves the total", () => { + const result = snapPaneSizesToWholePixels([0.333333, 0.333333, 0.333334], 101); + const boundaries = panePixelBoundaries(result, 101); + + expect(boundaries).toEqual([34, 67]); + expect(boundaries.every(Number.isInteger)).toBe(true); + expect(boundaries[1]!).toBeGreaterThan(boundaries[0]!); + expect(result.reduce((total, size) => total + size, 0)).toBe(1); + }); + + it("returns a copy unchanged when containerPx <= 0", () => { + const sizes = [0.25, 0.75]; + const result = snapPaneSizesToWholePixels(sizes, 0); + expect(result).toEqual(sizes); + expect(result).not.toBe(sizes); + }); + + it("returns a copy unchanged for a single pane", () => { + const sizes = [1]; + const result = snapPaneSizesToWholePixels(sizes, 100); + expect(result).toEqual(sizes); + expect(result).not.toBe(sizes); + }); + + it("does not mutate the input", () => { + const sizes = [0.333333, 0.333333, 0.333334]; + const copy = [...sizes]; + snapPaneSizesToWholePixels(sizes, 101); + expect(sizes).toEqual(copy); + }); + + it("clamps boundaries within the valid pixel range", () => { + const result = snapPaneSizesToWholePixels([0.001, 0.001, 0.998], 100); + expect(panePixelBoundaries(result, 100)).toEqual([1, 2]); + + const endClamped = snapPaneSizesToWholePixels([0.998, 0.001, 0.001], 100); + expect(panePixelBoundaries(endClamped, 100)).toEqual([98, 99]); + }); +}); + +describe("panePixelBoundaries", () => { + it("returns increasing whole-pixel internal boundaries in range", () => { + const boundaries = panePixelBoundaries([0.25, 0.25, 0.5], 101); + expect(boundaries).toHaveLength(2); + expect(boundaries.every(Number.isInteger)).toBe(true); + expect(boundaries[1]!).toBeGreaterThan(boundaries[0]!); + for (const boundary of boundaries) { + expect(boundary).toBeGreaterThanOrEqual(1); + expect(boundary).toBeLessThanOrEqual(100); + } + }); +}); diff --git a/apps/web/src/terminal/splitPaneSizes.ts b/apps/web/src/terminal/splitPaneSizes.ts new file mode 100644 index 000000000000..29d9861e8dcd --- /dev/null +++ b/apps/web/src/terminal/splitPaneSizes.ts @@ -0,0 +1,169 @@ +export type TerminalSplitDirection = "horizontal" | "vertical"; + +/** Minimum pane extent in CSS px along the split axis. "horizontal" = side-by-side columns (width), "vertical" = stacked rows (height). */ +export const MIN_TERMINAL_PANE_PX: Readonly> = Object.freeze( + { horizontal: 160, vertical: 64 }, +); + +/** Equal fractions for `count` panes (count <= 0 returns []). */ +export function equalPaneSizes(count: number): number[] { + if (count <= 0) return []; + const fraction = 1 / count; + return Array.from({ length: count }, () => fraction); +} + +/** + * Returns usable sizes for `count` panes: when `sizes` is undefined, has a different length, + * or contains a non-finite / <= 0 entry, returns equalPaneSizes(count). Otherwise returns a + * NEW array normalized so it sums to exactly 1 (divide each by the sum). + */ +export function resolvePaneSizes(sizes: readonly number[] | undefined, count: number): number[] { + if (!sizes || sizes.length !== count) { + return equalPaneSizes(count); + } + + for (const size of sizes) { + if (!Number.isFinite(size) || size <= 0) { + return equalPaneSizes(count); + } + } + + const sum = sizes.reduce((a, b) => a + b, 0); + return Array.from(sizes, (size) => size / sum); +} + +/** Raises undersized panes to the minimum fraction while preserving a total size of 1. */ +export function constrainPaneSizes( + sizes: readonly number[], + containerPx: number, + minPanePx: number, +): number[] { + if (containerPx <= 0 || sizes.length <= 1) return Array.from(sizes); + + const minFraction = minPanePx / containerPx; + if (sizes.length * minFraction >= 1) return equalPaneSizes(sizes.length); + if (sizes.every((size) => size >= minFraction)) return Array.from(sizes); + + const totalExcess = sizes.reduce((total, size) => total + Math.max(0, size - minFraction), 0); + const availableExcess = 1 - sizes.length * minFraction; + const result = sizes.map((size) => + size <= minFraction + ? minFraction + : minFraction + ((size - minFraction) / totalExcess) * availableExcess, + ); + const correction = 1 - result.reduce((total, size) => total + size, 0); + const correctionIndex = result.findIndex((size) => size > minFraction); + result[correctionIndex] = (result[correctionIndex] ?? 0) + correction; + return result; +} + +/** + * Moves the boundary between pane `handleIndex` and pane `handleIndex + 1` by `deltaPx` + * (positive = towards the end, i.e. pane handleIndex grows). Only those two panes change; + * their combined fraction is preserved. Each of the two is clamped to at least + * minPanePx / containerPx, but if the pair is too small for both minimums the min fraction + * becomes half of the pair's total. Returns a NEW array (never mutates input). If + * containerPx <= 0, handleIndex is out of range (must be 0..sizes.length-2), or deltaPx is + * not finite, return a copy of `sizes` unchanged. + */ +export function resizeAdjacentPanes(input: { + readonly sizes: readonly number[]; + readonly handleIndex: number; + readonly deltaPx: number; + readonly containerPx: number; + readonly minPanePx: number; +}): number[] { + const { sizes, handleIndex, deltaPx, containerPx, minPanePx } = input; + + const isValidHandleIndex = handleIndex >= 0 && handleIndex < sizes.length - 1; + if (containerPx <= 0 || !isValidHandleIndex || !Number.isFinite(deltaPx)) { + return Array.from(sizes); + } + + const result = Array.from(sizes); + const minFraction = minPanePx / containerPx; + const deltaFraction = deltaPx / containerPx; + + const leftIndex = handleIndex; + const rightIndex = handleIndex + 1; + + const leftSize = result[leftIndex] ?? 0; + const rightSize = result[rightIndex] ?? 0; + const combined = leftSize + rightSize; + + let newLeft = leftSize + deltaFraction; + let newRight = combined - newLeft; + + if (combined < 2 * minFraction) { + const halfCombined = combined / 2; + newLeft = halfCombined; + newRight = halfCombined; + } else { + newLeft = Math.max(minFraction, Math.min(newLeft, combined - minFraction)); + newRight = combined - newLeft; + } + + result[leftIndex] = newLeft; + result[rightIndex] = newRight; + + return result; +} + +/** CSS grid template for the sizes: each entry `minmax(0, fr)` joined by a space. Use the fraction with at most 6 decimal places (trim trailing zeros is not required). */ +export function paneGridTemplate(sizes: readonly number[]): string { + return sizes + .map((size) => { + const rounded = Math.round(size * 1000000) / 1000000; + return `minmax(0, ${rounded}fr)`; + }) + .join(" "); +} + +/** Cumulative offsets (fractions 0..1) of each internal boundary: length sizes.length - 1. e.g. [0.25, 0.25, 0.5] -> [0.25, 0.5]. */ +export function paneBoundaryOffsets(sizes: readonly number[]): number[] { + const result: number[] = []; + let cumulative = 0; + + for (let i = 0; i < sizes.length - 1; i++) { + const size = sizes[i] ?? 0; + cumulative += size; + result.push(cumulative); + } + + return result; +} + +/** Internal pane boundaries rounded to whole CSS pixels for the given container extent. */ +export function panePixelBoundaries(sizes: readonly number[], containerPx: number): number[] { + return paneBoundaryOffsets(sizes).map((offset) => Math.round(offset * containerPx)); +} + +/** + * Returns pane fractions whose internal boundaries land on whole CSS pixels. This keeps terminal + * canvases and their borders aligned while a split is dragged. + */ +export function snapPaneSizesToWholePixels( + sizes: readonly number[], + containerPx: number, +): number[] { + if (containerPx <= 0 || sizes.length <= 1) return Array.from(sizes); + + const boundaryCount = sizes.length - 1; + const boundaries = panePixelBoundaries(sizes, containerPx); + let previous = 0; + + for (let index = 0; index < boundaries.length; index++) { + const upper = Math.ceil(containerPx) - (boundaryCount - index); + const boundary = boundaries[index] ?? previous + 1; + boundaries[index] = Math.max(previous + 1, Math.min(boundary, upper)); + previous = boundaries[index]!; + } + + const result = boundaries.map((boundary, index) => { + const previousBoundary = boundaries[index - 1] ?? 0; + return (boundary - previousBoundary) / containerPx; + }); + const allocated = result.reduce((total, size) => total + size, 0); + result.push(1 - allocated); + return result; +}