authorgravatar for 269035359+t3-code[bot]@users.noreply.github.comt3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> 2026-08-05 17:02:53-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-05 17:02:53-04:00
log30e471530b3e2ddf59caf309636bed940b8b3776
tree3d5332674a00429bde3ba3a272283ffbae27f9e0
parentde592a00e89776d2e0614f3be6e666012d90cd51
signature Signed by PGP key B5690EEEBB952194

fix(web): preserve terminal font size when splitting (#5444)

Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com>

2 files changed, 0 insertions(+), 74 deletions(-)

apps/web/src/terminal/ghostty/surface.test.ts-19
......@@ -23,7 +23,6 @@ import {
2323 terminalLinkAtPositionWithRange,
2424 terminalContentOriginY,
2525 terminalFontFamily,
26 fittedTerminalFontSize,
2726 terminalFontSize,
2827 terminalWheelArrowData,
2928 terminalWheelDeltaRows,
......@@ -343,24 +342,6 @@ describe("terminal font resolution", () => {
343342 expect(terminalFontFamily(" , ")).toBe(DEFAULT_TERMINAL_FONT_FAMILY);
344343 });
345344
346 it("slides the rendered size down until a full-width grid fits the canvas", () => {
347 // SF Mono-like advance: 0.6em per cell.
348 const cellWidthAt = (size: number) => size * 0.6;
349 // A wide drawer keeps the preference untouched.
350 expect(fittedTerminalFontSize(cellWidthAt, 20, 1140)).toBe(20);
351 // A split pane at the same preference shrinks until 80 columns fit.
352 const fitted = fittedTerminalFontSize(cellWidthAt, 20, 570);
353 expect(fitted).toBeLessThan(20);
354 expect(Math.floor((570 - 8) / cellWidthAt(fitted))).toBeGreaterThanOrEqual(80);
355 // A tiny pane stops at the legibility floor instead of vanishing.
356 expect(fittedTerminalFontSize(cellWidthAt, 20, 220)).toBe(8);
357 // A preference below the floor is honored as-is.
358 expect(fittedTerminalFontSize(cellWidthAt, 6, 220)).toBe(6);
359 // Unmeasured layouts leave the preference alone.
360 expect(fittedTerminalFontSize(cellWidthAt, 14, 0)).toBe(14);
361 expect(fittedTerminalFontSize(() => 0, 14, 600)).toBe(14);
362 });
363
364345 it("clamps requested font sizes to the supported range", () => {
365346 expect(terminalFontSize()).toBe(DEFAULT_TERMINAL_FONT_SIZE);
366347 expect(terminalFontSize(Number.NaN)).toBe(DEFAULT_TERMINAL_FONT_SIZE);
apps/web/src/terminal/ghostty/surface.ts-55
......@@ -129,42 +129,6 @@ export async function loadTerminalFontFamily(
129129 return (environment?.resolve ?? terminalFontFamily)(family);
130130}
131131
132/**
133 * Grids narrower than a classic 80-column terminal wrap command output hard,
134 * so the rendered font size follows the canvas width: the preference is the
135 * ceiling, and the size slides down (to a legibility floor) until a full-width
136 * grid fits. A widening pane slides it back up toward the preference.
137 */
138const MIN_TERMINAL_FIT_COLUMNS = 80;
139const MIN_TERMINAL_FIT_FONT_SIZE = 8;
140
141export function fittedTerminalFontSize(
142 cellWidthAt: (size: number) => number,
143 requested: number,
144 mountWidth: number,
145): number {
146 const available = mountWidth - CONTENT_PADDING * 2;
147 if (available <= 0) return requested;
148 const floor = Math.min(requested, MIN_TERMINAL_FIT_FONT_SIZE);
149 const fits = (cellWidth: number) =>
150 cellWidth > 0 && Math.floor(available / cellWidth) >= MIN_TERMINAL_FIT_COLUMNS;
151 let cellWidth = cellWidthAt(requested);
152 if (cellWidth <= 0 || fits(cellWidth)) return requested;
153 // The advance scales linearly with size for monospace faces: jump close to
154 // the fitting size, then settle the remaining rounding one step at a time.
155 const targetCellWidth = available / MIN_TERMINAL_FIT_COLUMNS;
156 let size = Math.max(
157 floor,
158 Math.min(requested, Math.floor((requested * targetCellWidth) / cellWidth)),
159 );
160 while (size > floor) {
161 cellWidth = cellWidthAt(size);
162 if (cellWidth <= 0 || fits(cellWidth)) break;
163 size -= 1;
164 }
165 return size;
166}
167
168132export function terminalFontSize(size?: number): number {
169133 if (size === undefined || !Number.isFinite(size)) return DEFAULT_TERMINAL_FONT_SIZE;
170134 return Math.max(MIN_TERMINAL_FONT_SIZE, Math.min(MAX_TERMINAL_FONT_SIZE, Math.round(size)));
......@@ -519,7 +483,6 @@ export class GhosttyTerminalSurface {
519483 private fontFamily: string;
520484 private requestedFontFamily: string | undefined;
521485 private fontSize: number;
522 private requestedFontSize: number;
523486 private fontEpoch = 0;
524487 private pendingFontEpoch: number | null = null;
525488 private readonly resizeObserver: ResizeObserver;
......@@ -601,7 +564,6 @@ export class GhosttyTerminalSurface {
601564 this.fontFamily = fontFamily;
602565 this.requestedFontFamily = options.font?.family;
603566 this.fontSize = terminalFontSize(options.font?.size);
604 this.requestedFontSize = this.fontSize;
605567 this.resizeObserver = new ResizeObserver(() => this.fit());
606568 this.installEvents();
607569 this.watchDevicePixelRatio();
......@@ -719,7 +681,6 @@ export class GhosttyTerminalSurface {
719681 this.pendingFontEpoch = null;
720682 this.fontFamily = fontFamily;
721683 this.requestedFontFamily = font.family;
722 this.requestedFontSize = fontSize;
723684 this.fontSize = fontSize;
724685 this.applyFontMetrics();
725686 }
......@@ -775,22 +736,6 @@ export class GhosttyTerminalSurface {
775736 const width = this.mount.clientWidth;
776737 const height = this.mount.clientHeight;
777738 if (width <= 0 || height <= 0) return false;
778 const fitted = fittedTerminalFontSize(
779 (size) => measureGhosttyCell(this.context, size, this.fontFamily).width,
780 this.requestedFontSize,
781 width,
782 );
783 if (fitted !== this.fontSize) {
784 this.fontSize = fitted;
785 this.metrics = measureGhosttyCell(this.context, this.fontSize, this.fontFamily);
786 // The grid-change branch below resizes the core, but only when the
787 // column count moved; the cell geometry always did, so sync it here.
788 this.core.resize(this.cols, this.rows, this.metrics.width, this.metrics.height);
789 this.inputLeft = -1;
790 this.inputTop = -1;
791 this.forceFullRender = true;
792 this.scrollbarDirty = true;
793 }
794739 const ratio = window.devicePixelRatio || 1;
795740 const pixelWidth = Math.max(1, Math.round(width * ratio));
796741 const pixelHeight = Math.max(1, Math.round(height * ratio));