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