Skip to content

Commit 2a04db1

Browse files
authored
fix(web): keep terminal font settings reliable (#5397)
1 parent 37d3667 commit 2a04db1

6 files changed

Lines changed: 204 additions & 30 deletions

File tree

apps/web/src/appearanceFonts.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, it } from "vite-plus/test";
22

33
import {
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

16+
describe("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+
1429
describe("cssFontFamilies", () => {
1530
it("returns null for effectively empty input", () => {
1631
expect(cssFontFamilies("")).toBeNull();
@@ -54,6 +69,34 @@ describe("appearanceFontStack", () => {
5469
});
5570
});
5671

72+
describe("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+
57100
describe("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

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,22 @@ export const DEFAULT_SANS_FONT_STACK =
2525
export const DEFAULT_CODE_FONT_STACK =
2626
'"SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace';
2727

28+
export 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+
*/
35+
export 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+
2844
function 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

184+
const MONOSPACE_PROBE_VARIANTS = ["normal 400", "normal 700", "italic 400", "italic 700"] as const;
185+
const MONOSPACE_PROBE_GLYPHS = ["i", "M", "W", "0", "@", "#", ".", " "] as const;
186+
const MONOSPACE_ADVANCE_TOLERANCE = 0.01;
187+
188+
export 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

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
type ThreadId,
1919
} from "@t3tools/contracts";
2020
import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
21+
import * as Schema from "effect/Schema";
2122
import {
2223
type PointerEvent as ReactPointerEvent,
2324
type ReactNode,
@@ -57,13 +58,15 @@ import {
5758
} from "../types";
5859
import { readLocalApi } from "~/localApi";
5960
import { useClientSettings } from "../hooks/useSettings";
61+
import { useLocalStorage } from "../hooks/useLocalStorage";
6062
import { useAttachedTerminalSession } from "../state/terminalSessions";
6163
import { serverEnvironment } from "../state/server";
6264
import { previewEnvironment } from "../state/preview";
6365
import { terminalEnvironment } from "../state/terminal";
6466
import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview";
6567
import { useAtomCommand } from "../state/use-atom-command";
6668
import { preventTerminalCloseShortcut } from "../lib/terminalCloseShortcut";
69+
import { resolveTerminalFontPreference, TYPOGRAPHY_ADVANCED_STORAGE_KEY } from "../appearanceFonts";
6770

6871
const MIN_DRAWER_HEIGHT = 180;
6972
const MAX_DRAWER_HEIGHT_RATIO = 0.75;
@@ -241,6 +244,7 @@ export function shouldHandleTerminalExit(
241244
}
242245

243246
interface TerminalViewportProps {
247+
advancedTypography: boolean;
244248
threadRef: ScopedThreadRef;
245249
threadId: ThreadId;
246250
terminalId: string;
@@ -264,6 +268,7 @@ interface TerminalLaunchLocation {
264268
}
265269

266270
export 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

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,8 @@ import {
115115
isFontFamilyAvailable,
116116
isMonospaceFamily,
117117
resolveDefaultFamilyLabel,
118+
resolveTerminalFontPreference,
119+
TYPOGRAPHY_ADVANCED_STORAGE_KEY,
118120
} from "../../appearanceFonts";
119121
import { CodeFontPreview, PromptFontPreview, TerminalFontPreview } from "./SettingsFontPreviews";
120122
import { 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

1373-
const 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
*/
13821388
function 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

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, it } from "vite-plus/test";
1+
import { describe, expect, it, vi } from "vite-plus/test";
22

33
import type { GhosttyCell, GhosttyRow } from "./core";
44
import {
@@ -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

297298
describe("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);

0 commit comments

Comments
 (0)