| 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,6 +1,7 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 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"; |
| 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 | describe("cssFontFamilies", () => { | 29 | describe("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 | }); |
| 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 | describe("font size clamping", () => { | 100 | describe("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 = |
| 25 | export const DEFAULT_CODE_FONT_STACK = | 25 | export 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'; |
| 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 | function quoteFontFamilyName(name: string): string { | 44 | function 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 | } |
| 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 | * Whether a family renders every character on the same advance. Cell-grid | 201 | * Whether a family renders every character on the same advance. Cell-grid |
| 170 | * surfaces (the terminal) require this: a proportional face draws its text | 202 | * 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 and | 218 | // 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"; |
| 20 | import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; | 20 | import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; |
| 21 | import * as Schema from "effect/Schema"; | ||
| 21 | import { | 22 | import { |
| 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"; |
| 58 | import { readLocalApi } from "~/localApi"; | 59 | import { readLocalApi } from "~/localApi"; |
| 59 | import { useClientSettings } from "../hooks/useSettings"; | 60 | import { useClientSettings } from "../hooks/useSettings"; |
| 61 | import { useLocalStorage } from "../hooks/useLocalStorage"; | ||
| 60 | import { useAttachedTerminalSession } from "../state/terminalSessions"; | 62 | import { useAttachedTerminalSession } from "../state/terminalSessions"; |
| 61 | import { serverEnvironment } from "../state/server"; | 63 | import { serverEnvironment } from "../state/server"; |
| 62 | import { previewEnvironment } from "../state/preview"; | 64 | import { previewEnvironment } from "../state/preview"; |
| ... | @@ -64,6 +66,7 @@ import { terminalEnvironment } from "../state/terminal"; | ... | @@ -64,6 +66,7 @@ import { terminalEnvironment } from "../state/terminal"; |
| 64 | import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview"; | 66 | import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview"; |
| 65 | import { useAtomCommand } from "../state/use-atom-command"; | 67 | import { useAtomCommand } from "../state/use-atom-command"; |
| 66 | import { preventTerminalCloseShortcut } from "../lib/terminalCloseShortcut"; | 68 | import { preventTerminalCloseShortcut } from "../lib/terminalCloseShortcut"; |
| 69 | import { resolveTerminalFontPreference, TYPOGRAPHY_ADVANCED_STORAGE_KEY } from "../appearanceFonts"; | ||
| 67 | 70 | ||
| 68 | const MIN_DRAWER_HEIGHT = 180; | 71 | const MIN_DRAWER_HEIGHT = 180; |
| 69 | const MAX_DRAWER_HEIGHT_RATIO = 0.75; | 72 | const MAX_DRAWER_HEIGHT_RATIO = 0.75; |
| ... | @@ -241,6 +244,7 @@ export function shouldHandleTerminalExit( | ... | @@ -241,6 +244,7 @@ export function shouldHandleTerminalExit( |
| 241 | } | 244 | } |
| 242 | 245 | ||
| 243 | interface TerminalViewportProps { | 246 | interface 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 | } |
| 265 | 269 | ||
| 266 | export function TerminalViewport({ | 270 | export 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 an | 320 | 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 | <TerminalViewport | 1371 | <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 | <TerminalViewport | 1399 | <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"; |
| 119 | import { CodeFontPreview, PromptFontPreview, TerminalFontPreview } from "./SettingsFontPreviews"; | 121 | import { CodeFontPreview, PromptFontPreview, TerminalFontPreview } from "./SettingsFontPreviews"; |
| 120 | import { discoverInstalledFonts, FontFamilyPicker, useFontEnumeration } from "./FontFamilyPicker"; | 122 | import { 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 | } |
| 1160 | 1160 | ||
| ... | @@ -1244,8 +1244,8 @@ function TerminalFontRow() { | ... | @@ -1244,8 +1244,8 @@ function TerminalFontRow() { |
| 1244 | return ( | 1244 | return ( |
| 1245 | <FontFamilySettingsRow | 1245 | <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 | requireMonospace | 1251 | requireMonospace |
| ... | @@ -1258,7 +1258,11 @@ function TerminalFontRow() { | ... | @@ -1258,7 +1258,11 @@ function TerminalFontRow() { |
| 1258 | }} | 1258 | }} |
| 1259 | preview={ | 1259 | preview={ |
| 1260 | <TerminalFontPreview | 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 | 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 | <TerminalFontPreview | 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 | 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 | ]); |
| 1372 | 1380 | ||
| 1373 | const TYPOGRAPHY_ADVANCED_KEY = "t3code:typography-advanced"; | ||
| 1374 | |||
| 1375 | /** | 1381 | /** |
| 1376 | * The two-font view by default - one sans, one monospace, each cascading to | 1382 | * 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 header | 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,7 +1386,11 @@ const TYPOGRAPHY_ADVANCED_KEY = "t3code:typography-advanced"; |
| 1380 | * target exists to scroll to. | 1386 | * target exists to scroll to. |
| 1381 | */ | 1387 | */ |
| 1382 | function TypographySection() { | 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 | const searchTargetId = useSettingsSearchTargetId(); | 1394 | const searchTargetId = useSettingsSearchTargetId(); |
| 1385 | // Flip Advanced on once per search jump so the hidden target can mount and | 1395 | // 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 without | 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,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 | import type { GhosttyCell, GhosttyRow } from "./core"; | 3 | import type { GhosttyCell, GhosttyRow } from "./core"; |
| 4 | import { | 4 | import { |
| ... | @@ -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 | }); |
| 296 | 297 | ||
| 297 | describe("terminal font resolution", () => { | 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 | 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; |
| 36 | const MIN_SCROLLBAR_THUMB_HEIGHT = 18; | 36 | const 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. */ |
| 38 | const CURSOR_BLINK_INTERVAL_MS = 500; | 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 | /** Requested terminal font; omitted fields fall back to the defaults. */ | 47 | /** Requested terminal font; omitted fields fall back to the defaults. */ |
| 41 | export interface GhosttyTerminalFont { | 48 | export 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 | } |
| 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 | export function terminalFontFamily(family?: string): string { | 95 | export function terminalFontFamily(family?: string): string { |
| 82 | // Quote non-ident names ("3270 Nerd Font", "M+ 1m"): an unquoted one makes | 96 | // 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. */ | ||
| 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,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 { |
| 600 | 642 | ||
| 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 before | 647 | // 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 { |
| 667 | 709 | ||
| 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, and | 713 | // 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 { |
| 706 | 746 | ||
| 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 glyph | 760 | // 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); |