authorgravatar for 71498452+StiensWout@users.noreply.github.comWout Stiens <71498452+StiensWout@users.noreply.github.com> 2026-08-05 13:42:56+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-05 13:42:56+02:00
log2a04db134c2d88f06e5b8d61a8410cb51ea07430
tree551e1626acf3f05a5aed30d2bc0304aa251cb9e1
parent37d3667de4e45ffb42dc96e8b7fbf39e3a78c1da
signature Signed by PGP key B5690EEEBB952194

fix(web): keep terminal font settings reliable (#5397)


6 files changed, 204 insertions(+), 30 deletions(-)

apps/web/src/appearanceFonts.test.ts+43
......@@ -1,6 +1,7 @@
11import { describe, expect, it } from "vite-plus/test";
22
33import {
4 areFontAdvancesMonospace,
45 clampCodeFontSize,
56 clampInterfaceFontSize,
67 clampPromptFontSize,
......@@ -9,8 +10,22 @@ import {
910 appearanceFontStack,
1011 cssFontFamilies,
1112 resolveDefaultFamilyLabel,
13 resolveTerminalFontPreference,
1214} from "./appearanceFonts";
1315
16describe("areFontAdvancesMonospace", () => {
17 it("accepts a fixed advance and rejects any proportional glyph", () => {
18 expect(areFontAdvancesMonospace([10, 10, 10, 10])).toBe(true);
19 expect(areFontAdvancesMonospace([10, 10, 7, 10])).toBe(false);
20 expect(areFontAdvancesMonospace([10, 10.02])).toBe(false);
21 });
22
23 it("fails open when canvas metrics are unavailable", () => {
24 expect(areFontAdvancesMonospace([])).toBe(true);
25 expect(areFontAdvancesMonospace([Number.NaN, Number.NaN])).toBe(true);
26 });
27});
28
1429describe("cssFontFamilies", () => {
1530 it("returns null for effectively empty input", () => {
1631 expect(cssFontFamilies("")).toBeNull();
......@@ -54,6 +69,34 @@ describe("appearanceFontStack", () => {
5469 });
5570});
5671
72describe("resolveTerminalFontPreference", () => {
73 it("inherits the code font in simple mode", () => {
74 expect(
75 resolveTerminalFontPreference({ advanced: false, code: "Fira Code", terminal: "" }),
76 ).toBe("Fira Code");
77 expect(
78 resolveTerminalFontPreference({
79 advanced: false,
80 code: "Fira Code",
81 terminal: "Berkeley Mono",
82 }),
83 ).toBe("Fira Code");
84 });
85
86 it("keeps code and terminal fonts independent in advanced mode", () => {
87 expect(resolveTerminalFontPreference({ advanced: true, code: "Fira Code", terminal: "" })).toBe(
88 "",
89 );
90 expect(
91 resolveTerminalFontPreference({
92 advanced: true,
93 code: "Fira Code",
94 terminal: "Berkeley Mono",
95 }),
96 ).toBe("Berkeley Mono");
97 });
98});
99
57100describe("font size clamping", () => {
58101 it("keeps sizes inside the ranges the UI can absorb", () => {
59102 expect(clampInterfaceFontSize(16)).toBe(16);
apps/web/src/appearanceFonts.ts+39-5
......@@ -25,6 +25,22 @@ export const DEFAULT_SANS_FONT_STACK =
2525export const DEFAULT_CODE_FONT_STACK =
2626 '"SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace';
2727
28export const TYPOGRAPHY_ADVANCED_STORAGE_KEY = "t3code:typography-advanced";
29
30/**
31 * Simple typography treats the terminal as another monospace surface. In
32 * Advanced mode an empty terminal preference means the terminal default,
33 * keeping later code-font changes isolated to code surfaces.
34 */
35export function resolveTerminalFontPreference(input: {
36 readonly advanced: boolean;
37 readonly code: string;
38 readonly terminal: string;
39}): string {
40 if (input.advanced) return input.terminal;
41 return input.code;
42}
43
2844function quoteFontFamilyName(name: string): string {
2945 const bare = name.trim();
3046 if (bare.length === 0) return "";
......@@ -165,6 +181,22 @@ export function isFontFamilyAvailable(family: string): boolean {
165181 }
166182}
167183
184const MONOSPACE_PROBE_VARIANTS = ["normal 400", "normal 700", "italic 400", "italic 700"] as const;
185const MONOSPACE_PROBE_GLYPHS = ["i", "M", "W", "0", "@", "#", ".", " "] as const;
186const MONOSPACE_ADVANCE_TOLERANCE = 0.01;
187
188export function areFontAdvancesMonospace(advances: readonly number[]): boolean {
189 const reference = advances[0];
190 if (
191 reference === undefined ||
192 reference <= 0 ||
193 advances.some((advance) => !Number.isFinite(advance) || advance <= 0)
194 ) {
195 return true;
196 }
197 return advances.every((advance) => Math.abs(advance - reference) < MONOSPACE_ADVANCE_TOLERANCE);
198}
199
168200/**
169201 * Whether a family renders every character on the same advance. Cell-grid
170202 * surfaces (the terminal) require this: a proportional face draws its text
......@@ -182,13 +214,15 @@ export function isMonospaceFamily(family: string): boolean {
182214 fontProbeContext = document.createElement("canvas").getContext("2d");
183215 }
184216 if (fontProbeContext === null) return true;
217 const context = fontProbeContext;
185218 // Fall back to a generic mono so an absent face measures as monospace and
186219 // is left for the normal fallback chain to resolve.
187 fontProbeContext.font = `32px ${families}, monospace`;
188 const narrow = fontProbeContext.measureText("i").width;
189 const wide = fontProbeContext.measureText("M").width;
190 if (!Number.isFinite(narrow) || !Number.isFinite(wide) || wide === 0) return true;
191 return Math.abs(wide - narrow) < 0.5;
220 for (const variant of MONOSPACE_PROBE_VARIANTS) {
221 context.font = `${variant} 32px ${families}, monospace`;
222 const advances = MONOSPACE_PROBE_GLYPHS.map((glyph) => context.measureText(glyph).width);
223 if (!areFontAdvancesMonospace(advances)) return false;
224 }
225 return true;
192226 } catch {
193227 return true;
194228 }
apps/web/src/components/ThreadTerminalDrawer.tsx+18-4
......@@ -18,6 +18,7 @@ import {
1818 type ThreadId,
1919} from "@t3tools/contracts";
2020import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
21import * as Schema from "effect/Schema";
2122import {
2223 type PointerEvent as ReactPointerEvent,
2324 type ReactNode,
......@@ -57,6 +58,7 @@ import {
5758} from "../types";
5859import { readLocalApi } from "~/localApi";
5960import { useClientSettings } from "../hooks/useSettings";
61import { useLocalStorage } from "../hooks/useLocalStorage";
6062import { useAttachedTerminalSession } from "../state/terminalSessions";
6163import { serverEnvironment } from "../state/server";
6264import { previewEnvironment } from "../state/preview";
......@@ -64,6 +66,7 @@ import { terminalEnvironment } from "../state/terminal";
6466import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview";
6567import { useAtomCommand } from "../state/use-atom-command";
6668import { preventTerminalCloseShortcut } from "../lib/terminalCloseShortcut";
69import { resolveTerminalFontPreference, TYPOGRAPHY_ADVANCED_STORAGE_KEY } from "../appearanceFonts";
6770
6871const MIN_DRAWER_HEIGHT = 180;
6972const MAX_DRAWER_HEIGHT_RATIO = 0.75;
......@@ -241,6 +244,7 @@ export function shouldHandleTerminalExit(
241244}
242245
243246interface TerminalViewportProps {
247 advancedTypography: boolean;
244248 threadRef: ScopedThreadRef;
245249 threadId: ThreadId;
246250 terminalId: string;
......@@ -264,6 +268,7 @@ interface TerminalLaunchLocation {
264268}
265269
266270export function TerminalViewport({
271 advancedTypography,
267272 threadRef,
268273 threadId,
269274 terminalId,
......@@ -312,10 +317,12 @@ export function TerminalViewport({
312317 onAddTerminalContext(selection);
313318 });
314319 const readTerminalLabel = useEffectEvent(() => terminalLabel);
315 // The terminal inherits the monospace (code) preference unless it has an
316 // override of its own, so one font choice drives every mono surface.
317 const terminalFontFamily = useClientSettings(
318 (settings) => settings.fontFamilyTerminal.trim() || settings.fontFamilyCode,
320 const terminalFontFamily = useClientSettings((settings) =>
321 resolveTerminalFontPreference({
322 advanced: advancedTypography,
323 code: settings.fontFamilyCode,
324 terminal: settings.fontFamilyTerminal,
325 }),
319326 );
320327 const terminalFontSize = useClientSettings((settings) => settings.fontSizeTerminal);
321328 const terminalFontRef = useRef({ family: terminalFontFamily, size: terminalFontSize });
......@@ -921,6 +928,11 @@ export default function ThreadTerminalDrawer({
921928 terminalLaunchLocationsById,
922929}: ThreadTerminalDrawerProps) {
923930 const isPanel = mode === "panel";
931 const [advancedTypography] = useLocalStorage(
932 TYPOGRAPHY_ADVANCED_STORAGE_KEY,
933 false,
934 Schema.Boolean,
935 );
924936 const controlledDrawerHeight = clampDrawerHeight(height);
925937 const [drawerHeightState, setDrawerHeightState] = useState(() => ({
926938 threadId,
......@@ -1357,6 +1369,7 @@ export default function ThreadTerminalDrawer({
13571369 >
13581370 <div className="h-full p-1">
13591371 <TerminalViewport
1372 advancedTypography={advancedTypography}
13601373 threadRef={threadRef}
13611374 threadId={threadId}
13621375 terminalId={terminalId}
......@@ -1384,6 +1397,7 @@ export default function ThreadTerminalDrawer({
13841397 ) : (
13851398 <div className="h-full p-1">
13861399 <TerminalViewport
1400 advancedTypography={advancedTypography}
13871401 key={resolvedActiveTerminalId}
13881402 threadRef={threadRef}
13891403 threadId={threadId}
apps/web/src/components/settings/SettingsPanels.tsx+20-10
......@@ -115,6 +115,8 @@ import {
115115 isFontFamilyAvailable,
116116 isMonospaceFamily,
117117 resolveDefaultFamilyLabel,
118 resolveTerminalFontPreference,
119 TYPOGRAPHY_ADVANCED_STORAGE_KEY,
118120} from "../../appearanceFonts";
119121import { CodeFontPreview, PromptFontPreview, TerminalFontPreview } from "./SettingsFontPreviews";
120122import { discoverInstalledFonts, FontFamilyPicker, useFontEnumeration } from "./FontFamilyPicker";
......@@ -1151,10 +1153,8 @@ function useFontDefaultFamilies() {
11511153 return {
11521154 sans: defaults.sans,
11531155 code: defaults.code,
1154 // The composer inherits whatever the interface preference resolves to;
1155 // the terminal inherits the monospace preference the same way.
1156 // The composer inherits whatever the interface preference resolves to.
11561157 interfaceFamily: settings.fontFamilySans.trim() || defaults.sans,
1157 monoFamily: settings.fontFamilyCode.trim() || defaults.code,
11581158 };
11591159}
11601160
......@@ -1244,8 +1244,8 @@ function TerminalFontRow() {
12441244 return (
12451245 <FontFamilySettingsRow
12461246 {...searchableSetting("terminal-font")}
1247 description="Terminal output. Follows the monospace font unless set."
1248 defaultFamily={defaults.monoFamily}
1247 description="Terminal output, independent from code blocks and diffs."
1248 defaultFamily={defaults.code}
12491249 value={settings.fontFamilyTerminal}
12501250 onValueChange={(fontFamilyTerminal) => updateSettings({ fontFamilyTerminal })}
12511251 requireMonospace
......@@ -1258,7 +1258,11 @@ function TerminalFontRow() {
12581258 }}
12591259 preview={
12601260 <TerminalFontPreview
1261 family={settings.fontFamilyTerminal.trim() || settings.fontFamilyCode}
1261 family={resolveTerminalFontPreference({
1262 advanced: true,
1263 code: settings.fontFamilyCode,
1264 terminal: settings.fontFamilyTerminal,
1265 })}
12621266 size={settings.fontSizeTerminal}
12631267 />
12641268 }
......@@ -1350,7 +1354,11 @@ function SimpleFontRows() {
13501354 <>
13511355 <CodeFontPreview />
13521356 <TerminalFontPreview
1353 family={settings.fontFamilyTerminal.trim() || settings.fontFamilyCode}
1357 family={resolveTerminalFontPreference({
1358 advanced: false,
1359 code: settings.fontFamilyCode,
1360 terminal: settings.fontFamilyTerminal,
1361 })}
13541362 size={settings.fontSizeTerminal}
13551363 />
13561364 </>
......@@ -1370,8 +1378,6 @@ const ADVANCED_TYPOGRAPHY_TARGET_IDS: ReadonlySet<string> = new Set([
13701378 : []),
13711379]);
13721380
1373const TYPOGRAPHY_ADVANCED_KEY = "t3code:typography-advanced";
1374
13751381/**
13761382 * The two-font view by default - one sans, one monospace, each cascading to
13771383 * every surface it reaches - with an Advanced switch in the section header
......@@ -1380,7 +1386,11 @@ const TYPOGRAPHY_ADVANCED_KEY = "t3code:typography-advanced";
13801386 * target exists to scroll to.
13811387 */
13821388function TypographySection() {
1383 const [advanced, setAdvanced] = useLocalStorage(TYPOGRAPHY_ADVANCED_KEY, false, Schema.Boolean);
1389 const [advanced, setAdvanced] = useLocalStorage(
1390 TYPOGRAPHY_ADVANCED_STORAGE_KEY,
1391 false,
1392 Schema.Boolean,
1393 );
13841394 const searchTargetId = useSettingsSearchTargetId();
13851395 // Flip Advanced on once per search jump so the hidden target can mount and
13861396 // scroll; tracking the handled id lets the user turn it back off without
apps/web/src/terminal/ghostty/surface.test.ts+23-1
......@@ -1,4 +1,4 @@
1import { describe, expect, it } from "vite-plus/test";
1import { describe, expect, it, vi } from "vite-plus/test";
22
33import type { GhosttyCell, GhosttyRow } from "./core";
44import {
......@@ -11,6 +11,7 @@ import {
1111 isTerminalCopyShortcut,
1212 isTerminalLinkPointerGesture,
1313 isTerminalPasteShortcut,
14 loadTerminalFontFamily,
1415 shouldBlinkTerminalCursor,
1516 shouldReportTerminalMouse,
1617 shouldShowTerminalLinkHover,
......@@ -295,6 +296,27 @@ describe("application mouse reporting", () => {
295296});
296297
297298describe("terminal font resolution", () => {
299 it("validates the requested face after its styles load", async () => {
300 let loaded = false;
301 const load = vi.fn(async () => {
302 loaded = true;
303 return [];
304 });
305 const resolve = vi.fn(() => {
306 expect(loaded).toBe(true);
307 return DEFAULT_TERMINAL_FONT_FAMILY;
308 });
309
310 await expect(
311 loadTerminalFontFamily("Proportional Test", 12, {
312 load,
313 resolve,
314 }),
315 ).resolves.toBe(DEFAULT_TERMINAL_FONT_FAMILY);
316 expect(load).toHaveBeenCalledTimes(4);
317 expect(resolve).toHaveBeenCalledWith("Proportional Test");
318 });
319
298320 it("keeps the glyph fallbacks behind a custom text face", () => {
299321 expect(terminalFontFamily()).toBe(DEFAULT_TERMINAL_FONT_FAMILY);
300322 expect(terminalFontFamily(" ")).toBe(DEFAULT_TERMINAL_FONT_FAMILY);
apps/web/src/terminal/ghostty/surface.ts+61-10
......@@ -36,6 +36,13 @@ const CONTENT_PADDING = 4;
3636const MIN_SCROLLBAR_THUMB_HEIGHT = 18;
3737/** Half a blink cycle: the visible and hidden phases are equally long. */
3838const CURSOR_BLINK_INTERVAL_MS = 500;
39const TERMINAL_FONT_LOAD_TEXT = "iMW0@# .";
40const TERMINAL_FONT_LOAD_VARIANTS = [
41 "normal 400",
42 "normal 700",
43 "italic 400",
44 "italic 700",
45] as const;
3946
4047/** Requested terminal font; omitted fields fall back to the defaults. */
4148export interface GhosttyTerminalFont {
......@@ -78,6 +85,13 @@ function quoteTerminalFontFamilies(list: string): string {
7885 .join(", ");
7986}
8087
88function uncheckedTerminalFontFamily(family?: string): string {
89 const custom = family === undefined ? "" : quoteTerminalFontFamilies(family);
90 return custom.length === 0
91 ? DEFAULT_TERMINAL_FONT_FAMILY
92 : `${custom}, ${TERMINAL_GLYPH_FALLBACKS}`;
93}
94
8195export function terminalFontFamily(family?: string): string {
8296 // Quote non-ident names ("3270 Nerd Font", "M+ 1m"): an unquoted one makes
8397 // the whole canvas font string invalid and the assignment silently no-ops.
......@@ -88,7 +102,31 @@ export function terminalFontFamily(family?: string): string {
88102 // it here rather than render a ragged grid with a stranded cursor.
89103 if (!isMonospaceFamily(custom)) return DEFAULT_TERMINAL_FONT_FAMILY;
90104 // A custom face keeps the glyph fallbacks so prompt symbols stay covered.
91 return `${custom}, ${TERMINAL_GLYPH_FALLBACKS}`;
105 return uncheckedTerminalFontFamily(custom);
106}
107
108/** Load every style the renderer can request, then validate the actual face. */
109export async function loadTerminalFontFamily(
110 family: string | undefined,
111 size: number,
112 environment?: {
113 readonly load: (font: string, text: string) => Promise<unknown>;
114 readonly resolve: (family: string | undefined) => string;
115 },
116): Promise<string> {
117 const candidate = uncheckedTerminalFontFamily(family);
118 const load =
119 environment?.load ?? ((font: string, text: string) => document.fonts.load(font, text));
120 try {
121 await Promise.all(
122 TERMINAL_FONT_LOAD_VARIANTS.map((variant) =>
123 load(`${variant} ${size}px ${candidate}`, TERMINAL_FONT_LOAD_TEXT),
124 ),
125 );
126 } catch {
127 // The fixed-width fallback stack remains available if a face cannot load.
128 }
129 return (environment?.resolve ?? terminalFontFamily)(family);
92130}
93131
94132/**
......@@ -479,9 +517,11 @@ export class GhosttyTerminalSurface {
479517 private readonly options: GhosttyTerminalSurfaceOptions;
480518 private metrics: GhosttyCellMetrics;
481519 private fontFamily: string;
520 private requestedFontFamily: string | undefined;
482521 private fontSize: number;
483522 private requestedFontSize: number;
484523 private fontEpoch = 0;
524 private pendingFontEpoch: number | null = null;
485525 private readonly resizeObserver: ResizeObserver;
486526 private readonly scrollbarThumb: HTMLDivElement;
487527 private snapshot: GhosttySnapshot | null = null;
......@@ -545,6 +585,7 @@ export class GhosttyTerminalSurface {
545585 context: CanvasRenderingContext2D,
546586 core: GhosttyTerminalCore,
547587 metrics: GhosttyCellMetrics,
588 fontFamily: string,
548589 options: GhosttyTerminalSurfaceOptions,
549590 ) {
550591 this.mount = mount;
......@@ -557,7 +598,8 @@ export class GhosttyTerminalSurface {
557598 this.metrics = metrics;
558599 this.options = options;
559600 this.theme = options.theme;
560 this.fontFamily = terminalFontFamily(options.font?.family);
601 this.fontFamily = fontFamily;
602 this.requestedFontFamily = options.font?.family;
561603 this.fontSize = terminalFontSize(options.font?.size);
562604 this.requestedFontSize = this.fontSize;
563605 this.resizeObserver = new ResizeObserver(() => this.fit());
......@@ -600,16 +642,15 @@ export class GhosttyTerminalSurface {
600642
601643 const context = canvas.getContext("2d", { alpha: false });
602644 if (!context) throw new Error("Canvas 2D is unavailable");
603 const fontFamily = terminalFontFamily(options.font?.family);
604645 const fontSize = terminalFontSize(options.font?.size);
605646 try {
606647 // Cell metrics must come from the faces that will render; measuring before
607648 // the bundled webfonts load would size the grid from a fallback font.
608649 await ensureTerminalSymbolsFont();
609 await document.fonts.load(`${fontSize}px ${fontFamily}`);
610650 } catch {
611651 // Metrics fall back to whichever faces are already available.
612652 }
653 const fontFamily = await loadTerminalFontFamily(options.font?.family, fontSize);
613654 const metrics = measureGhosttyCell(context, fontSize, fontFamily);
614655 const grid = terminalGridSize(mount.clientWidth, mount.clientHeight, metrics, CONTENT_PADDING);
615656 const core = await GhosttyTerminalCore.create(
......@@ -629,6 +670,7 @@ export class GhosttyTerminalSurface {
629670 context,
630671 core,
631672 metrics,
673 fontFamily,
632674 options,
633675 );
634676 surface.fit();
......@@ -667,18 +709,16 @@ export class GhosttyTerminalSurface {
667709
668710 async setFont(font: GhosttyTerminalFont): Promise<void> {
669711 if (this.disposed) return;
670 const fontFamily = terminalFontFamily(font.family);
671712 const fontSize = terminalFontSize(font.size);
672713 // The fields only change together with their metrics after the load, and
673714 // the epoch lets the newest overlapping call win regardless of load order.
674715 const epoch = ++this.fontEpoch;
675 try {
676 await document.fonts.load(`${fontSize}px ${fontFamily}`);
677 } catch {
678 // Metrics fall back to whichever faces are already available.
679 }
716 this.pendingFontEpoch = epoch;
717 const fontFamily = await loadTerminalFontFamily(font.family, fontSize);
680718 if (this.disposed || epoch !== this.fontEpoch) return;
719 this.pendingFontEpoch = null;
681720 this.fontFamily = fontFamily;
721 this.requestedFontFamily = font.family;
682722 this.requestedFontSize = fontSize;
683723 this.fontSize = fontSize;
684724 this.applyFontMetrics();
......@@ -706,6 +746,17 @@ export class GhosttyTerminalSurface {
706746
707747 private readonly onFontsLoaded = () => {
708748 if (this.disposed) return;
749 // The explicit load validates every style and applies the newest request.
750 // Its own loading events must not revalidate the previously applied face.
751 if (this.pendingFontEpoch !== null) return;
752 // A face may become available after an earlier fallback measurement. Run
753 // the fixed-width guard again before using its newly loaded metrics.
754 const fontFamily = terminalFontFamily(this.requestedFontFamily);
755 if (fontFamily !== this.fontFamily) {
756 this.fontFamily = fontFamily;
757 this.applyFontMetrics();
758 return;
759 }
709760 // A face that finished loading after the initial measurement changes glyph
710761 // advances; re-measure and refit so the grid matches what actually renders.
711762 const metrics = measureGhosttyCell(this.context, this.fontSize, this.fontFamily);