diff --git a/apps/web/src/appearanceFonts.test.ts b/apps/web/src/appearanceFonts.test.ts index 8467c13c2ce..5c642e33f0f 100644 --- a/apps/web/src/appearanceFonts.test.ts +++ b/apps/web/src/appearanceFonts.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { + areFontAdvancesMonospace, clampCodeFontSize, clampInterfaceFontSize, clampPromptFontSize, @@ -9,8 +10,22 @@ import { appearanceFontStack, cssFontFamilies, resolveDefaultFamilyLabel, + resolveTerminalFontPreference, } from "./appearanceFonts"; +describe("areFontAdvancesMonospace", () => { + it("accepts a fixed advance and rejects any proportional glyph", () => { + expect(areFontAdvancesMonospace([10, 10, 10, 10])).toBe(true); + expect(areFontAdvancesMonospace([10, 10, 7, 10])).toBe(false); + expect(areFontAdvancesMonospace([10, 10.02])).toBe(false); + }); + + it("fails open when canvas metrics are unavailable", () => { + expect(areFontAdvancesMonospace([])).toBe(true); + expect(areFontAdvancesMonospace([Number.NaN, Number.NaN])).toBe(true); + }); +}); + describe("cssFontFamilies", () => { it("returns null for effectively empty input", () => { expect(cssFontFamilies("")).toBeNull(); @@ -54,6 +69,34 @@ describe("appearanceFontStack", () => { }); }); +describe("resolveTerminalFontPreference", () => { + it("inherits the code font in simple mode", () => { + expect( + resolveTerminalFontPreference({ advanced: false, code: "Fira Code", terminal: "" }), + ).toBe("Fira Code"); + expect( + resolveTerminalFontPreference({ + advanced: false, + code: "Fira Code", + terminal: "Berkeley Mono", + }), + ).toBe("Fira Code"); + }); + + it("keeps code and terminal fonts independent in advanced mode", () => { + expect(resolveTerminalFontPreference({ advanced: true, code: "Fira Code", terminal: "" })).toBe( + "", + ); + expect( + resolveTerminalFontPreference({ + advanced: true, + code: "Fira Code", + terminal: "Berkeley Mono", + }), + ).toBe("Berkeley Mono"); + }); +}); + describe("font size clamping", () => { it("keeps sizes inside the ranges the UI can absorb", () => { expect(clampInterfaceFontSize(16)).toBe(16); diff --git a/apps/web/src/appearanceFonts.ts b/apps/web/src/appearanceFonts.ts index 3fb6c821a1b..60801ef0118 100644 --- a/apps/web/src/appearanceFonts.ts +++ b/apps/web/src/appearanceFonts.ts @@ -25,6 +25,22 @@ export const DEFAULT_SANS_FONT_STACK = export const DEFAULT_CODE_FONT_STACK = '"SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace'; +export const TYPOGRAPHY_ADVANCED_STORAGE_KEY = "t3code:typography-advanced"; + +/** + * Simple typography treats the terminal as another monospace surface. In + * Advanced mode an empty terminal preference means the terminal default, + * keeping later code-font changes isolated to code surfaces. + */ +export function resolveTerminalFontPreference(input: { + readonly advanced: boolean; + readonly code: string; + readonly terminal: string; +}): string { + if (input.advanced) return input.terminal; + return input.code; +} + function quoteFontFamilyName(name: string): string { const bare = name.trim(); if (bare.length === 0) return ""; @@ -165,6 +181,22 @@ export function isFontFamilyAvailable(family: string): boolean { } } +const MONOSPACE_PROBE_VARIANTS = ["normal 400", "normal 700", "italic 400", "italic 700"] as const; +const MONOSPACE_PROBE_GLYPHS = ["i", "M", "W", "0", "@", "#", ".", " "] as const; +const MONOSPACE_ADVANCE_TOLERANCE = 0.01; + +export function areFontAdvancesMonospace(advances: readonly number[]): boolean { + const reference = advances[0]; + if ( + reference === undefined || + reference <= 0 || + advances.some((advance) => !Number.isFinite(advance) || advance <= 0) + ) { + return true; + } + return advances.every((advance) => Math.abs(advance - reference) < MONOSPACE_ADVANCE_TOLERANCE); +} + /** * Whether a family renders every character on the same advance. Cell-grid * surfaces (the terminal) require this: a proportional face draws its text @@ -182,13 +214,15 @@ export function isMonospaceFamily(family: string): boolean { fontProbeContext = document.createElement("canvas").getContext("2d"); } if (fontProbeContext === null) return true; + const context = fontProbeContext; // Fall back to a generic mono so an absent face measures as monospace and // is left for the normal fallback chain to resolve. - fontProbeContext.font = `32px ${families}, monospace`; - const narrow = fontProbeContext.measureText("i").width; - const wide = fontProbeContext.measureText("M").width; - if (!Number.isFinite(narrow) || !Number.isFinite(wide) || wide === 0) return true; - return Math.abs(wide - narrow) < 0.5; + for (const variant of MONOSPACE_PROBE_VARIANTS) { + context.font = `${variant} 32px ${families}, monospace`; + const advances = MONOSPACE_PROBE_GLYPHS.map((glyph) => context.measureText(glyph).width); + if (!areFontAdvancesMonospace(advances)) return false; + } + return true; } catch { return true; } diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 914e04b647d..072241426e2 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -18,6 +18,7 @@ import { type ThreadId, } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; +import * as Schema from "effect/Schema"; import { type PointerEvent as ReactPointerEvent, type ReactNode, @@ -57,6 +58,7 @@ import { } from "../types"; import { readLocalApi } from "~/localApi"; import { useClientSettings } from "../hooks/useSettings"; +import { useLocalStorage } from "../hooks/useLocalStorage"; import { useAttachedTerminalSession } from "../state/terminalSessions"; import { serverEnvironment } from "../state/server"; import { previewEnvironment } from "../state/preview"; @@ -64,6 +66,7 @@ import { terminalEnvironment } from "../state/terminal"; import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview"; import { useAtomCommand } from "../state/use-atom-command"; import { preventTerminalCloseShortcut } from "../lib/terminalCloseShortcut"; +import { resolveTerminalFontPreference, TYPOGRAPHY_ADVANCED_STORAGE_KEY } from "../appearanceFonts"; const MIN_DRAWER_HEIGHT = 180; const MAX_DRAWER_HEIGHT_RATIO = 0.75; @@ -241,6 +244,7 @@ export function shouldHandleTerminalExit( } interface TerminalViewportProps { + advancedTypography: boolean; threadRef: ScopedThreadRef; threadId: ThreadId; terminalId: string; @@ -264,6 +268,7 @@ interface TerminalLaunchLocation { } export function TerminalViewport({ + advancedTypography, threadRef, threadId, terminalId, @@ -312,10 +317,12 @@ export function TerminalViewport({ onAddTerminalContext(selection); }); const readTerminalLabel = useEffectEvent(() => terminalLabel); - // The terminal inherits the monospace (code) preference unless it has an - // override of its own, so one font choice drives every mono surface. - const terminalFontFamily = useClientSettings( - (settings) => settings.fontFamilyTerminal.trim() || settings.fontFamilyCode, + const terminalFontFamily = useClientSettings((settings) => + resolveTerminalFontPreference({ + advanced: advancedTypography, + code: settings.fontFamilyCode, + terminal: settings.fontFamilyTerminal, + }), ); const terminalFontSize = useClientSettings((settings) => settings.fontSizeTerminal); const terminalFontRef = useRef({ family: terminalFontFamily, size: terminalFontSize }); @@ -921,6 +928,11 @@ export default function ThreadTerminalDrawer({ terminalLaunchLocationsById, }: ThreadTerminalDrawerProps) { const isPanel = mode === "panel"; + const [advancedTypography] = useLocalStorage( + TYPOGRAPHY_ADVANCED_STORAGE_KEY, + false, + Schema.Boolean, + ); const controlledDrawerHeight = clampDrawerHeight(height); const [drawerHeightState, setDrawerHeightState] = useState(() => ({ threadId, @@ -1357,6 +1369,7 @@ export default function ThreadTerminalDrawer({ >
updateSettings({ fontFamilyTerminal })} requireMonospace @@ -1258,7 +1258,11 @@ function TerminalFontRow() { }} preview={ } @@ -1350,7 +1354,11 @@ function SimpleFontRows() { <> @@ -1370,8 +1378,6 @@ const ADVANCED_TYPOGRAPHY_TARGET_IDS: ReadonlySet = new Set([ : []), ]); -const TYPOGRAPHY_ADVANCED_KEY = "t3code:typography-advanced"; - /** * The two-font view by default - one sans, one monospace, each cascading to * every surface it reaches - with an Advanced switch in the section header @@ -1380,7 +1386,11 @@ const TYPOGRAPHY_ADVANCED_KEY = "t3code:typography-advanced"; * target exists to scroll to. */ function TypographySection() { - const [advanced, setAdvanced] = useLocalStorage(TYPOGRAPHY_ADVANCED_KEY, false, Schema.Boolean); + const [advanced, setAdvanced] = useLocalStorage( + TYPOGRAPHY_ADVANCED_STORAGE_KEY, + false, + Schema.Boolean, + ); const searchTargetId = useSettingsSearchTargetId(); // Flip Advanced on once per search jump so the hidden target can mount and // scroll; tracking the handled id lets the user turn it back off without diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index f1559ac1578..d20a6cfe005 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import type { GhosttyCell, GhosttyRow } from "./core"; import { @@ -11,6 +11,7 @@ import { isTerminalCopyShortcut, isTerminalLinkPointerGesture, isTerminalPasteShortcut, + loadTerminalFontFamily, shouldBlinkTerminalCursor, shouldReportTerminalMouse, shouldShowTerminalLinkHover, @@ -295,6 +296,27 @@ describe("application mouse reporting", () => { }); describe("terminal font resolution", () => { + it("validates the requested face after its styles load", async () => { + let loaded = false; + const load = vi.fn(async () => { + loaded = true; + return []; + }); + const resolve = vi.fn(() => { + expect(loaded).toBe(true); + return DEFAULT_TERMINAL_FONT_FAMILY; + }); + + await expect( + loadTerminalFontFamily("Proportional Test", 12, { + load, + resolve, + }), + ).resolves.toBe(DEFAULT_TERMINAL_FONT_FAMILY); + expect(load).toHaveBeenCalledTimes(4); + expect(resolve).toHaveBeenCalledWith("Proportional Test"); + }); + it("keeps the glyph fallbacks behind a custom text face", () => { expect(terminalFontFamily()).toBe(DEFAULT_TERMINAL_FONT_FAMILY); expect(terminalFontFamily(" ")).toBe(DEFAULT_TERMINAL_FONT_FAMILY); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 853da9c139c..8ff92c01c0c 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -36,6 +36,13 @@ const CONTENT_PADDING = 4; const MIN_SCROLLBAR_THUMB_HEIGHT = 18; /** Half a blink cycle: the visible and hidden phases are equally long. */ const CURSOR_BLINK_INTERVAL_MS = 500; +const TERMINAL_FONT_LOAD_TEXT = "iMW0@# ."; +const TERMINAL_FONT_LOAD_VARIANTS = [ + "normal 400", + "normal 700", + "italic 400", + "italic 700", +] as const; /** Requested terminal font; omitted fields fall back to the defaults. */ export interface GhosttyTerminalFont { @@ -78,6 +85,13 @@ function quoteTerminalFontFamilies(list: string): string { .join(", "); } +function uncheckedTerminalFontFamily(family?: string): string { + const custom = family === undefined ? "" : quoteTerminalFontFamilies(family); + return custom.length === 0 + ? DEFAULT_TERMINAL_FONT_FAMILY + : `${custom}, ${TERMINAL_GLYPH_FALLBACKS}`; +} + export function terminalFontFamily(family?: string): string { // Quote non-ident names ("3270 Nerd Font", "M+ 1m"): an unquoted one makes // the whole canvas font string invalid and the assignment silently no-ops. @@ -88,7 +102,31 @@ export function terminalFontFamily(family?: string): string { // it here rather than render a ragged grid with a stranded cursor. if (!isMonospaceFamily(custom)) return DEFAULT_TERMINAL_FONT_FAMILY; // A custom face keeps the glyph fallbacks so prompt symbols stay covered. - return `${custom}, ${TERMINAL_GLYPH_FALLBACKS}`; + return uncheckedTerminalFontFamily(custom); +} + +/** Load every style the renderer can request, then validate the actual face. */ +export async function loadTerminalFontFamily( + family: string | undefined, + size: number, + environment?: { + readonly load: (font: string, text: string) => Promise; + readonly resolve: (family: string | undefined) => string; + }, +): Promise { + const candidate = uncheckedTerminalFontFamily(family); + const load = + environment?.load ?? ((font: string, text: string) => document.fonts.load(font, text)); + try { + await Promise.all( + TERMINAL_FONT_LOAD_VARIANTS.map((variant) => + load(`${variant} ${size}px ${candidate}`, TERMINAL_FONT_LOAD_TEXT), + ), + ); + } catch { + // The fixed-width fallback stack remains available if a face cannot load. + } + return (environment?.resolve ?? terminalFontFamily)(family); } /** @@ -479,9 +517,11 @@ export class GhosttyTerminalSurface { private readonly options: GhosttyTerminalSurfaceOptions; private metrics: GhosttyCellMetrics; private fontFamily: string; + private requestedFontFamily: string | undefined; private fontSize: number; private requestedFontSize: number; private fontEpoch = 0; + private pendingFontEpoch: number | null = null; private readonly resizeObserver: ResizeObserver; private readonly scrollbarThumb: HTMLDivElement; private snapshot: GhosttySnapshot | null = null; @@ -545,6 +585,7 @@ export class GhosttyTerminalSurface { context: CanvasRenderingContext2D, core: GhosttyTerminalCore, metrics: GhosttyCellMetrics, + fontFamily: string, options: GhosttyTerminalSurfaceOptions, ) { this.mount = mount; @@ -557,7 +598,8 @@ export class GhosttyTerminalSurface { this.metrics = metrics; this.options = options; this.theme = options.theme; - this.fontFamily = terminalFontFamily(options.font?.family); + this.fontFamily = fontFamily; + this.requestedFontFamily = options.font?.family; this.fontSize = terminalFontSize(options.font?.size); this.requestedFontSize = this.fontSize; this.resizeObserver = new ResizeObserver(() => this.fit()); @@ -600,16 +642,15 @@ export class GhosttyTerminalSurface { const context = canvas.getContext("2d", { alpha: false }); if (!context) throw new Error("Canvas 2D is unavailable"); - const fontFamily = terminalFontFamily(options.font?.family); const fontSize = terminalFontSize(options.font?.size); try { // Cell metrics must come from the faces that will render; measuring before // the bundled webfonts load would size the grid from a fallback font. await ensureTerminalSymbolsFont(); - await document.fonts.load(`${fontSize}px ${fontFamily}`); } catch { // Metrics fall back to whichever faces are already available. } + const fontFamily = await loadTerminalFontFamily(options.font?.family, fontSize); const metrics = measureGhosttyCell(context, fontSize, fontFamily); const grid = terminalGridSize(mount.clientWidth, mount.clientHeight, metrics, CONTENT_PADDING); const core = await GhosttyTerminalCore.create( @@ -629,6 +670,7 @@ export class GhosttyTerminalSurface { context, core, metrics, + fontFamily, options, ); surface.fit(); @@ -667,18 +709,16 @@ export class GhosttyTerminalSurface { async setFont(font: GhosttyTerminalFont): Promise { if (this.disposed) return; - const fontFamily = terminalFontFamily(font.family); const fontSize = terminalFontSize(font.size); // The fields only change together with their metrics after the load, and // the epoch lets the newest overlapping call win regardless of load order. const epoch = ++this.fontEpoch; - try { - await document.fonts.load(`${fontSize}px ${fontFamily}`); - } catch { - // Metrics fall back to whichever faces are already available. - } + this.pendingFontEpoch = epoch; + const fontFamily = await loadTerminalFontFamily(font.family, fontSize); if (this.disposed || epoch !== this.fontEpoch) return; + this.pendingFontEpoch = null; this.fontFamily = fontFamily; + this.requestedFontFamily = font.family; this.requestedFontSize = fontSize; this.fontSize = fontSize; this.applyFontMetrics(); @@ -706,6 +746,17 @@ export class GhosttyTerminalSurface { private readonly onFontsLoaded = () => { if (this.disposed) return; + // The explicit load validates every style and applies the newest request. + // Its own loading events must not revalidate the previously applied face. + if (this.pendingFontEpoch !== null) return; + // A face may become available after an earlier fallback measurement. Run + // the fixed-width guard again before using its newly loaded metrics. + const fontFamily = terminalFontFamily(this.requestedFontFamily); + if (fontFamily !== this.fontFamily) { + this.fontFamily = fontFamily; + this.applyFontMetrics(); + return; + } // A face that finished loading after the initial measurement changes glyph // advances; re-measure and refit so the grid matches what actually renders. const metrics = measureGhosttyCell(this.context, this.fontSize, this.fontFamily);