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 @@...@@ -1,6 +1,7 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
22
3import {3import {
4 areFontAdvancesMonospace,
4 clampCodeFontSize,5 clampCodeFontSize,
5 clampInterfaceFontSize,6 clampInterfaceFontSize,
6 clampPromptFontSize,7 clampPromptFontSize,
...@@ -9,8 +10,22 @@ import {...@@ -9,8 +10,22 @@ import {
9 appearanceFontStack,10 appearanceFontStack,
10 cssFontFamilies,11 cssFontFamilies,
11 resolveDefaultFamilyLabel,12 resolveDefaultFamilyLabel,
13 resolveTerminalFontPreference,
12} from "./appearanceFonts";14} 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
14describe("cssFontFamilies", () => {29describe("cssFontFamilies", () => {
15 it("returns null for effectively empty input", () => {30 it("returns null for effectively empty input", () => {
16 expect(cssFontFamilies("")).toBeNull();31 expect(cssFontFamilies("")).toBeNull();
...@@ -54,6 +69,34 @@ describe("appearanceFontStack", () => {...@@ -54,6 +69,34 @@ describe("appearanceFontStack", () => {
54 });69 });
55});70});
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
57describe("font size clamping", () => {100describe("font size clamping", () => {
58 it("keeps sizes inside the ranges the UI can absorb", () => {101 it("keeps sizes inside the ranges the UI can absorb", () => {
59 expect(clampInterfaceFontSize(16)).toBe(16);102 expect(clampInterfaceFontSize(16)).toBe(16);
apps/web/src/appearanceFonts.ts+39-5
...@@ -25,6 +25,22 @@ export const DEFAULT_SANS_FONT_STACK =...@@ -25,6 +25,22 @@ export const DEFAULT_SANS_FONT_STACK =
25export const DEFAULT_CODE_FONT_STACK =25export const DEFAULT_CODE_FONT_STACK =
26 '"SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace';26 '"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
28function quoteFontFamilyName(name: string): string {44function quoteFontFamilyName(name: string): string {
29 const bare = name.trim();45 const bare = name.trim();
30 if (bare.length === 0) return "";46 if (bare.length === 0) return "";
...@@ -165,6 +181,22 @@ export function isFontFamilyAvailable(family: string): boolean {...@@ -165,6 +181,22 @@ export function isFontFamilyAvailable(family: string): boolean {
165 }181 }
166}182}
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
168/**200/**
169 * Whether a family renders every character on the same advance. Cell-grid201 * Whether a family renders every character on the same advance. Cell-grid
170 * surfaces (the terminal) require this: a proportional face draws its text202 * surfaces (the terminal) require this: a proportional face draws its text
...@@ -182,13 +214,15 @@ export function isMonospaceFamily(family: string): boolean {...@@ -182,13 +214,15 @@ export function isMonospaceFamily(family: string): boolean {
182 fontProbeContext = document.createElement("canvas").getContext("2d");214 fontProbeContext = document.createElement("canvas").getContext("2d");
183 }215 }
184 if (fontProbeContext === null) return true;216 if (fontProbeContext === null) return true;
217 const context = fontProbeContext;
185 // Fall back to a generic mono so an absent face measures as monospace and218 // Fall back to a generic mono so an absent face measures as monospace and
186 // is left for the normal fallback chain to resolve.219 // is left for the normal fallback chain to resolve.
187 fontProbeContext.font = `32px ${families}, monospace`;220 for (const variant of MONOSPACE_PROBE_VARIANTS) {
188 const narrow = fontProbeContext.measureText("i").width;221 context.font = `${variant} 32px ${families}, monospace`;
189 const wide = fontProbeContext.measureText("M").width;222 const advances = MONOSPACE_PROBE_GLYPHS.map((glyph) => context.measureText(glyph).width);
190 if (!Number.isFinite(narrow) || !Number.isFinite(wide) || wide === 0) return true;223 if (!areFontAdvancesMonospace(advances)) return false;
191 return Math.abs(wide - narrow) < 0.5;224 }
225 return true;
192 } catch {226 } catch {
193 return true;227 return true;
194 }228 }
apps/web/src/components/ThreadTerminalDrawer.tsx+18-4
...@@ -18,6 +18,7 @@ import {...@@ -18,6 +18,7 @@ import {
18 type ThreadId,18 type ThreadId,
19} from "@t3tools/contracts";19} from "@t3tools/contracts";
20import { getTerminalLabel } from "@t3tools/shared/terminalLabels";20import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
21import * as Schema from "effect/Schema";
21import {22import {
22 type PointerEvent as ReactPointerEvent,23 type PointerEvent as ReactPointerEvent,
23 type ReactNode,24 type ReactNode,
...@@ -57,6 +58,7 @@ import {...@@ -57,6 +58,7 @@ import {
57} from "../types";58} from "../types";
58import { readLocalApi } from "~/localApi";59import { readLocalApi } from "~/localApi";
59import { useClientSettings } from "../hooks/useSettings";60import { useClientSettings } from "../hooks/useSettings";
61import { useLocalStorage } from "../hooks/useLocalStorage";
60import { useAttachedTerminalSession } from "../state/terminalSessions";62import { useAttachedTerminalSession } from "../state/terminalSessions";
61import { serverEnvironment } from "../state/server";63import { serverEnvironment } from "../state/server";
62import { previewEnvironment } from "../state/preview";64import { previewEnvironment } from "../state/preview";
...@@ -64,6 +66,7 @@ import { terminalEnvironment } from "../state/terminal";...@@ -64,6 +66,7 @@ import { terminalEnvironment } from "../state/terminal";
64import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview";66import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview";
65import { useAtomCommand } from "../state/use-atom-command";67import { useAtomCommand } from "../state/use-atom-command";
66import { preventTerminalCloseShortcut } from "../lib/terminalCloseShortcut";68import { preventTerminalCloseShortcut } from "../lib/terminalCloseShortcut";
69import { resolveTerminalFontPreference, TYPOGRAPHY_ADVANCED_STORAGE_KEY } from "../appearanceFonts";
6770
68const MIN_DRAWER_HEIGHT = 180;71const MIN_DRAWER_HEIGHT = 180;
69const MAX_DRAWER_HEIGHT_RATIO = 0.75;72const MAX_DRAWER_HEIGHT_RATIO = 0.75;
...@@ -241,6 +244,7 @@ export function shouldHandleTerminalExit(...@@ -241,6 +244,7 @@ export function shouldHandleTerminalExit(
241}244}
242245
243interface TerminalViewportProps {246interface TerminalViewportProps {
247 advancedTypography: boolean;
244 threadRef: ScopedThreadRef;248 threadRef: ScopedThreadRef;
245 threadId: ThreadId;249 threadId: ThreadId;
246 terminalId: string;250 terminalId: string;
...@@ -264,6 +268,7 @@ interface TerminalLaunchLocation {...@@ -264,6 +268,7 @@ interface TerminalLaunchLocation {
264}268}
265269
266export function TerminalViewport({270export function TerminalViewport({
271 advancedTypography,
267 threadRef,272 threadRef,
268 threadId,273 threadId,
269 terminalId,274 terminalId,
...@@ -312,10 +317,12 @@ export function TerminalViewport({...@@ -312,10 +317,12 @@ export function TerminalViewport({
312 onAddTerminalContext(selection);317 onAddTerminalContext(selection);
313 });318 });
314 const readTerminalLabel = useEffectEvent(() => terminalLabel);319 const readTerminalLabel = useEffectEvent(() => terminalLabel);
315 // The terminal inherits the monospace (code) preference unless it has an320 const terminalFontFamily = useClientSettings((settings) =>
316 // override of its own, so one font choice drives every mono surface.321 resolveTerminalFontPreference({
317 const terminalFontFamily = useClientSettings(322 advanced: advancedTypography,
318 (settings) => settings.fontFamilyTerminal.trim() || settings.fontFamilyCode,323 code: settings.fontFamilyCode,
324 terminal: settings.fontFamilyTerminal,
325 }),
319 );326 );
320 const terminalFontSize = useClientSettings((settings) => settings.fontSizeTerminal);327 const terminalFontSize = useClientSettings((settings) => settings.fontSizeTerminal);
321 const terminalFontRef = useRef({ family: terminalFontFamily, size: terminalFontSize });328 const terminalFontRef = useRef({ family: terminalFontFamily, size: terminalFontSize });
...@@ -921,6 +928,11 @@ export default function ThreadTerminalDrawer({...@@ -921,6 +928,11 @@ export default function ThreadTerminalDrawer({
921 terminalLaunchLocationsById,928 terminalLaunchLocationsById,
922}: ThreadTerminalDrawerProps) {929}: ThreadTerminalDrawerProps) {
923 const isPanel = mode === "panel";930 const isPanel = mode === "panel";
931 const [advancedTypography] = useLocalStorage(
932 TYPOGRAPHY_ADVANCED_STORAGE_KEY,
933 false,
934 Schema.Boolean,
935 );
924 const controlledDrawerHeight = clampDrawerHeight(height);936 const controlledDrawerHeight = clampDrawerHeight(height);
925 const [drawerHeightState, setDrawerHeightState] = useState(() => ({937 const [drawerHeightState, setDrawerHeightState] = useState(() => ({
926 threadId,938 threadId,
...@@ -1357,6 +1369,7 @@ export default function ThreadTerminalDrawer({...@@ -1357,6 +1369,7 @@ export default function ThreadTerminalDrawer({
1357 >1369 >
1358 <div className="h-full p-1">1370 <div className="h-full p-1">
1359 <TerminalViewport1371 <TerminalViewport
1372 advancedTypography={advancedTypography}
1360 threadRef={threadRef}1373 threadRef={threadRef}
1361 threadId={threadId}1374 threadId={threadId}
1362 terminalId={terminalId}1375 terminalId={terminalId}
...@@ -1384,6 +1397,7 @@ export default function ThreadTerminalDrawer({...@@ -1384,6 +1397,7 @@ export default function ThreadTerminalDrawer({
1384 ) : (1397 ) : (
1385 <div className="h-full p-1">1398 <div className="h-full p-1">
1386 <TerminalViewport1399 <TerminalViewport
1400 advancedTypography={advancedTypography}
1387 key={resolvedActiveTerminalId}1401 key={resolvedActiveTerminalId}
1388 threadRef={threadRef}1402 threadRef={threadRef}
1389 threadId={threadId}1403 threadId={threadId}
apps/web/src/components/settings/SettingsPanels.tsx+20-10
...@@ -115,6 +115,8 @@ import {...@@ -115,6 +115,8 @@ import {
115 isFontFamilyAvailable,115 isFontFamilyAvailable,
116 isMonospaceFamily,116 isMonospaceFamily,
117 resolveDefaultFamilyLabel,117 resolveDefaultFamilyLabel,
118 resolveTerminalFontPreference,
119 TYPOGRAPHY_ADVANCED_STORAGE_KEY,
118} from "../../appearanceFonts";120} from "../../appearanceFonts";
119import { CodeFontPreview, PromptFontPreview, TerminalFontPreview } from "./SettingsFontPreviews";121import { CodeFontPreview, PromptFontPreview, TerminalFontPreview } from "./SettingsFontPreviews";
120import { discoverInstalledFonts, FontFamilyPicker, useFontEnumeration } from "./FontFamilyPicker";122import { discoverInstalledFonts, FontFamilyPicker, useFontEnumeration } from "./FontFamilyPicker";
...@@ -1151,10 +1153,8 @@ function useFontDefaultFamilies() {...@@ -1151,10 +1153,8 @@ function useFontDefaultFamilies() {
1151 return {1153 return {
1152 sans: defaults.sans,1154 sans: defaults.sans,
1153 code: defaults.code,1155 code: defaults.code,
1154 // The composer inherits whatever the interface preference resolves to;1156 // The composer inherits whatever the interface preference resolves to.
1155 // the terminal inherits the monospace preference the same way.
1156 interfaceFamily: settings.fontFamilySans.trim() || defaults.sans,1157 interfaceFamily: settings.fontFamilySans.trim() || defaults.sans,
1157 monoFamily: settings.fontFamilyCode.trim() || defaults.code,
1158 };1158 };
1159}1159}
11601160
...@@ -1244,8 +1244,8 @@ function TerminalFontRow() {...@@ -1244,8 +1244,8 @@ function TerminalFontRow() {
1244 return (1244 return (
1245 <FontFamilySettingsRow1245 <FontFamilySettingsRow
1246 {...searchableSetting("terminal-font")}1246 {...searchableSetting("terminal-font")}
1247 description="Terminal output. Follows the monospace font unless set."1247 description="Terminal output, independent from code blocks and diffs."
1248 defaultFamily={defaults.monoFamily}1248 defaultFamily={defaults.code}
1249 value={settings.fontFamilyTerminal}1249 value={settings.fontFamilyTerminal}
1250 onValueChange={(fontFamilyTerminal) => updateSettings({ fontFamilyTerminal })}1250 onValueChange={(fontFamilyTerminal) => updateSettings({ fontFamilyTerminal })}
1251 requireMonospace1251 requireMonospace
...@@ -1258,7 +1258,11 @@ function TerminalFontRow() {...@@ -1258,7 +1258,11 @@ function TerminalFontRow() {
1258 }}1258 }}
1259 preview={1259 preview={
1260 <TerminalFontPreview1260 <TerminalFontPreview
1261 family={settings.fontFamilyTerminal.trim() || settings.fontFamilyCode}1261 family={resolveTerminalFontPreference({
1262 advanced: true,
1263 code: settings.fontFamilyCode,
1264 terminal: settings.fontFamilyTerminal,
1265 })}
1262 size={settings.fontSizeTerminal}1266 size={settings.fontSizeTerminal}
1263 />1267 />
1264 }1268 }
...@@ -1350,7 +1354,11 @@ function SimpleFontRows() {...@@ -1350,7 +1354,11 @@ function SimpleFontRows() {
1350 <>1354 <>
1351 <CodeFontPreview />1355 <CodeFontPreview />
1352 <TerminalFontPreview1356 <TerminalFontPreview
1353 family={settings.fontFamilyTerminal.trim() || settings.fontFamilyCode}1357 family={resolveTerminalFontPreference({
1358 advanced: false,
1359 code: settings.fontFamilyCode,
1360 terminal: settings.fontFamilyTerminal,
1361 })}
1354 size={settings.fontSizeTerminal}1362 size={settings.fontSizeTerminal}
1355 />1363 />
1356 </>1364 </>
...@@ -1370,8 +1378,6 @@ const ADVANCED_TYPOGRAPHY_TARGET_IDS: ReadonlySet<string> = new Set([...@@ -1370,8 +1378,6 @@ const ADVANCED_TYPOGRAPHY_TARGET_IDS: ReadonlySet<string> = new Set([
1370 : []),1378 : []),
1371]);1379]);
13721380
1373const TYPOGRAPHY_ADVANCED_KEY = "t3code:typography-advanced";
1374
1375/**1381/**
1376 * The two-font view by default - one sans, one monospace, each cascading to1382 * The two-font view by default - one sans, one monospace, each cascading to
1377 * every surface it reaches - with an Advanced switch in the section header1383 * every surface it reaches - with an Advanced switch in the section header
...@@ -1380,7 +1386,11 @@ const TYPOGRAPHY_ADVANCED_KEY = "t3code:typography-advanced";...@@ -1380,7 +1386,11 @@ const TYPOGRAPHY_ADVANCED_KEY = "t3code:typography-advanced";
1380 * target exists to scroll to.1386 * target exists to scroll to.
1381 */1387 */
1382function TypographySection() {1388function 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 );
1384 const searchTargetId = useSettingsSearchTargetId();1394 const searchTargetId = useSettingsSearchTargetId();
1385 // Flip Advanced on once per search jump so the hidden target can mount and1395 // Flip Advanced on once per search jump so the hidden target can mount and
1386 // scroll; tracking the handled id lets the user turn it back off without1396 // 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 @@...@@ -1,4 +1,4 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it, vi } from "vite-plus/test";
22
3import type { GhosttyCell, GhosttyRow } from "./core";3import type { GhosttyCell, GhosttyRow } from "./core";
4import {4import {
...@@ -11,6 +11,7 @@ import {...@@ -11,6 +11,7 @@ import {
11 isTerminalCopyShortcut,11 isTerminalCopyShortcut,
12 isTerminalLinkPointerGesture,12 isTerminalLinkPointerGesture,
13 isTerminalPasteShortcut,13 isTerminalPasteShortcut,
14 loadTerminalFontFamily,
14 shouldBlinkTerminalCursor,15 shouldBlinkTerminalCursor,
15 shouldReportTerminalMouse,16 shouldReportTerminalMouse,
16 shouldShowTerminalLinkHover,17 shouldShowTerminalLinkHover,
...@@ -295,6 +296,27 @@ describe("application mouse reporting", () => {...@@ -295,6 +296,27 @@ describe("application mouse reporting", () => {
295});296});
296297
297describe("terminal font resolution", () => {298describe("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
298 it("keeps the glyph fallbacks behind a custom text face", () => {320 it("keeps the glyph fallbacks behind a custom text face", () => {
299 expect(terminalFontFamily()).toBe(DEFAULT_TERMINAL_FONT_FAMILY);321 expect(terminalFontFamily()).toBe(DEFAULT_TERMINAL_FONT_FAMILY);
300 expect(terminalFontFamily(" ")).toBe(DEFAULT_TERMINAL_FONT_FAMILY);322 expect(terminalFontFamily(" ")).toBe(DEFAULT_TERMINAL_FONT_FAMILY);
apps/web/src/terminal/ghostty/surface.ts+61-10
...@@ -36,6 +36,13 @@ const CONTENT_PADDING = 4;...@@ -36,6 +36,13 @@ const CONTENT_PADDING = 4;
36const MIN_SCROLLBAR_THUMB_HEIGHT = 18;36const MIN_SCROLLBAR_THUMB_HEIGHT = 18;
37/** Half a blink cycle: the visible and hidden phases are equally long. */37/** Half a blink cycle: the visible and hidden phases are equally long. */
38const CURSOR_BLINK_INTERVAL_MS = 500;38const 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
40/** Requested terminal font; omitted fields fall back to the defaults. */47/** Requested terminal font; omitted fields fall back to the defaults. */
41export interface GhosttyTerminalFont {48export interface GhosttyTerminalFont {
...@@ -78,6 +85,13 @@ function quoteTerminalFontFamilies(list: string): string {...@@ -78,6 +85,13 @@ function quoteTerminalFontFamilies(list: string): string {
78 .join(", ");85 .join(", ");
79}86}
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
81export function terminalFontFamily(family?: string): string {95export function terminalFontFamily(family?: string): string {
82 // Quote non-ident names ("3270 Nerd Font", "M+ 1m"): an unquoted one makes96 // Quote non-ident names ("3270 Nerd Font", "M+ 1m"): an unquoted one makes
83 // the whole canvas font string invalid and the assignment silently no-ops.97 // the whole canvas font string invalid and the assignment silently no-ops.
...@@ -88,7 +102,31 @@ export function terminalFontFamily(family?: string): string {...@@ -88,7 +102,31 @@ export function terminalFontFamily(family?: string): string {
88 // it here rather than render a ragged grid with a stranded cursor.102 // it here rather than render a ragged grid with a stranded cursor.
89 if (!isMonospaceFamily(custom)) return DEFAULT_TERMINAL_FONT_FAMILY;103 if (!isMonospaceFamily(custom)) return DEFAULT_TERMINAL_FONT_FAMILY;
90 // A custom face keeps the glyph fallbacks so prompt symbols stay covered.104 // 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);
92}130}
93131
94/**132/**
...@@ -479,9 +517,11 @@ export class GhosttyTerminalSurface {...@@ -479,9 +517,11 @@ export class GhosttyTerminalSurface {
479 private readonly options: GhosttyTerminalSurfaceOptions;517 private readonly options: GhosttyTerminalSurfaceOptions;
480 private metrics: GhosttyCellMetrics;518 private metrics: GhosttyCellMetrics;
481 private fontFamily: string;519 private fontFamily: string;
520 private requestedFontFamily: string | undefined;
482 private fontSize: number;521 private fontSize: number;
483 private requestedFontSize: number;522 private requestedFontSize: number;
484 private fontEpoch = 0;523 private fontEpoch = 0;
524 private pendingFontEpoch: number | null = null;
485 private readonly resizeObserver: ResizeObserver;525 private readonly resizeObserver: ResizeObserver;
486 private readonly scrollbarThumb: HTMLDivElement;526 private readonly scrollbarThumb: HTMLDivElement;
487 private snapshot: GhosttySnapshot | null = null;527 private snapshot: GhosttySnapshot | null = null;
...@@ -545,6 +585,7 @@ export class GhosttyTerminalSurface {...@@ -545,6 +585,7 @@ export class GhosttyTerminalSurface {
545 context: CanvasRenderingContext2D,585 context: CanvasRenderingContext2D,
546 core: GhosttyTerminalCore,586 core: GhosttyTerminalCore,
547 metrics: GhosttyCellMetrics,587 metrics: GhosttyCellMetrics,
588 fontFamily: string,
548 options: GhosttyTerminalSurfaceOptions,589 options: GhosttyTerminalSurfaceOptions,
549 ) {590 ) {
550 this.mount = mount;591 this.mount = mount;
...@@ -557,7 +598,8 @@ export class GhosttyTerminalSurface {...@@ -557,7 +598,8 @@ export class GhosttyTerminalSurface {
557 this.metrics = metrics;598 this.metrics = metrics;
558 this.options = options;599 this.options = options;
559 this.theme = options.theme;600 this.theme = options.theme;
560 this.fontFamily = terminalFontFamily(options.font?.family);601 this.fontFamily = fontFamily;
602 this.requestedFontFamily = options.font?.family;
561 this.fontSize = terminalFontSize(options.font?.size);603 this.fontSize = terminalFontSize(options.font?.size);
562 this.requestedFontSize = this.fontSize;604 this.requestedFontSize = this.fontSize;
563 this.resizeObserver = new ResizeObserver(() => this.fit());605 this.resizeObserver = new ResizeObserver(() => this.fit());
...@@ -600,16 +642,15 @@ export class GhosttyTerminalSurface {...@@ -600,16 +642,15 @@ export class GhosttyTerminalSurface {
600642
601 const context = canvas.getContext("2d", { alpha: false });643 const context = canvas.getContext("2d", { alpha: false });
602 if (!context) throw new Error("Canvas 2D is unavailable");644 if (!context) throw new Error("Canvas 2D is unavailable");
603 const fontFamily = terminalFontFamily(options.font?.family);
604 const fontSize = terminalFontSize(options.font?.size);645 const fontSize = terminalFontSize(options.font?.size);
605 try {646 try {
606 // Cell metrics must come from the faces that will render; measuring before647 // Cell metrics must come from the faces that will render; measuring before
607 // the bundled webfonts load would size the grid from a fallback font.648 // the bundled webfonts load would size the grid from a fallback font.
608 await ensureTerminalSymbolsFont();649 await ensureTerminalSymbolsFont();
609 await document.fonts.load(`${fontSize}px ${fontFamily}`);
610 } catch {650 } catch {
611 // Metrics fall back to whichever faces are already available.651 // Metrics fall back to whichever faces are already available.
612 }652 }
653 const fontFamily = await loadTerminalFontFamily(options.font?.family, fontSize);
613 const metrics = measureGhosttyCell(context, fontSize, fontFamily);654 const metrics = measureGhosttyCell(context, fontSize, fontFamily);
614 const grid = terminalGridSize(mount.clientWidth, mount.clientHeight, metrics, CONTENT_PADDING);655 const grid = terminalGridSize(mount.clientWidth, mount.clientHeight, metrics, CONTENT_PADDING);
615 const core = await GhosttyTerminalCore.create(656 const core = await GhosttyTerminalCore.create(
...@@ -629,6 +670,7 @@ export class GhosttyTerminalSurface {...@@ -629,6 +670,7 @@ export class GhosttyTerminalSurface {
629 context,670 context,
630 core,671 core,
631 metrics,672 metrics,
673 fontFamily,
632 options,674 options,
633 );675 );
634 surface.fit();676 surface.fit();
...@@ -667,18 +709,16 @@ export class GhosttyTerminalSurface {...@@ -667,18 +709,16 @@ export class GhosttyTerminalSurface {
667709
668 async setFont(font: GhosttyTerminalFont): Promise<void> {710 async setFont(font: GhosttyTerminalFont): Promise<void> {
669 if (this.disposed) return;711 if (this.disposed) return;
670 const fontFamily = terminalFontFamily(font.family);
671 const fontSize = terminalFontSize(font.size);712 const fontSize = terminalFontSize(font.size);
672 // The fields only change together with their metrics after the load, and713 // The fields only change together with their metrics after the load, and
673 // the epoch lets the newest overlapping call win regardless of load order.714 // the epoch lets the newest overlapping call win regardless of load order.
674 const epoch = ++this.fontEpoch;715 const epoch = ++this.fontEpoch;
675 try {716 this.pendingFontEpoch = epoch;
676 await document.fonts.load(`${fontSize}px ${fontFamily}`);717 const fontFamily = await loadTerminalFontFamily(font.family, fontSize);
677 } catch {
678 // Metrics fall back to whichever faces are already available.
679 }
680 if (this.disposed || epoch !== this.fontEpoch) return;718 if (this.disposed || epoch !== this.fontEpoch) return;
719 this.pendingFontEpoch = null;
681 this.fontFamily = fontFamily;720 this.fontFamily = fontFamily;
721 this.requestedFontFamily = font.family;
682 this.requestedFontSize = fontSize;722 this.requestedFontSize = fontSize;
683 this.fontSize = fontSize;723 this.fontSize = fontSize;
684 this.applyFontMetrics();724 this.applyFontMetrics();
...@@ -706,6 +746,17 @@ export class GhosttyTerminalSurface {...@@ -706,6 +746,17 @@ export class GhosttyTerminalSurface {
706746
707 private readonly onFontsLoaded = () => {747 private readonly onFontsLoaded = () => {
708 if (this.disposed) return;748 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 }
709 // A face that finished loading after the initial measurement changes glyph760 // A face that finished loading after the initial measurement changes glyph
710 // advances; re-measure and refit so the grid matches what actually renders.761 // advances; re-measure and refit so the grid matches what actually renders.
711 const metrics = measureGhosttyCell(this.context, this.fontSize, this.fontFamily);762 const metrics = measureGhosttyCell(this.context, this.fontSize, this.fontFamily);