diff --git a/DESIGN.md b/DESIGN.md index 94a0ac0e66..ec14f6d1da 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -545,6 +545,14 @@ The values are the ones in `src/styles.css`. Every token but `--fs-input` is `ca name `"JetBrains Mono Web"`. The name is the app's own so that the terminal never takes it: xterm's stack (`src/lib/fontFamily.ts`) names `"JetBrains Mono"`, sizes every cell from the first font that matches, and keeps meaning the copy installed on the device. +- Except in Ovis for Mac's window: WebKit there draws a font the user installed, but its canvas, + which xterm and the glyph fit measure with, sees only the system's fonts. The terminal leaves + such a font out of its stack (`measurableFontStack`) and draws what Safari draws, Menlo and + Apple SD Gothic Neo, rather than a grid measured in one font and drawn in another. It follows + Zed and Warp, whose terminals size a cell with the font that draws it. VS Code's xterm.js + terminal, the nearest web one, never meets the case: in Electron the canvas and the page see + the same installed fonts. A WKWebView's canvas cannot, so here the font is dropped rather than + measured. - Chat reading scale: prose, the user's bubble and live narration are `--fs-chat` on `--lh-prose`; `strong` is `--fw-semibold` in `--text-strong`; inline code and code file chips are `0.87em` of their line; a code block is `--fs-sm` on `--lh-code`; headings are h1 `--fs-xl`, diff --git a/changelog.d/mac-app-installed-font-terminal.md b/changelog.d/mac-app-installed-font-terminal.md new file mode 100644 index 0000000000..d1dc32ab65 --- /dev/null +++ b/changelog.d/mac-app-installed-font-terminal.md @@ -0,0 +1,5 @@ +### Fixed +- In Ovis for Mac's window, the terminal no longer draws Korean larger than its cells, or rows over + each other, on a Mac with fonts such as JetBrains Mono or D2Coding installed: the app's web view + draws an installed font but cannot measure it, so the terminal now uses the system's fonts there, + as in Safari. diff --git a/src/lib/fontFamily.test.ts b/src/lib/fontFamily.test.ts index bc7e32b275..9125cdfa5a 100644 --- a/src/lib/fontFamily.test.ts +++ b/src/lib/fontFamily.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from "bun:test"; import { readFileSync } from "node:fs"; -import { chatFontStack, loadFontStack, TERMINAL_FONT_STACK, terminalFontStack } from "./fontFamily.ts"; +import { chatFontStack, type FontWidths, loadFontStack, measurableFontStack, TERMINAL_FONT_STACK, terminalFontStack } from "./fontFamily.ts"; describe("terminal font stack", () => { it("is the built-in stack when nothing is typed", () => { @@ -34,6 +34,41 @@ describe("terminal font stack", () => { }); }); +describe("fonts the canvas cannot measure", () => { + // a page that has the fonts in `drawn` and a canvas that has those in `measured`: a list is as wide + // as its first font either one has, and every monospaced face is as wide as monospace + const page = (drawn: readonly string[], measured: readonly string[]): FontWidths => { + const width = (has: readonly string[]) => (family: string): number => { + const names = family.split(",").map((name) => name.trim().replace(/["']/g, "")); + const first = names.find((name) => has.includes(name) || name === "monospace" || name === "serif"); + return first === "serif" ? 280 : first === "D2Coding" ? 270 : 300; + }; + return { drawn: width(drawn), measured: width(measured) }; + }; + const system = ["Menlo", "Monaco"]; + const installed = ["JetBrains Mono", "D2Coding"]; + + it("leaves out the installed fonts Ovis for Mac's web view draws but its canvas cannot measure", () => { + const widths = page([...system, ...installed], system); + expect(measurableFontStack(TERMINAL_FONT_STACK, widths)).toBe(TERMINAL_FONT_STACK.replace('"JetBrains Mono", ', "").replace('"D2Coding", ', "")); + // a font typed in Settings goes the same way + expect(measurableFontStack(terminalFontStack("D2Coding"), widths)).toBe(TERMINAL_FONT_STACK.replace('"JetBrains Mono", ', "").replace('"D2Coding", ', "")); + }); + + it("keeps the stack where the canvas measures what the page draws, as in Chromium and Safari", () => { + expect(measurableFontStack(TERMINAL_FONT_STACK, page([...system, ...installed], [...system, ...installed]))).toBe(TERMINAL_FONT_STACK); + expect(measurableFontStack(TERMINAL_FONT_STACK, page(system, system))).toBe(TERMINAL_FONT_STACK); + }); + + it("keeps the bundled icon font, which the canvas measures once it has loaded", () => { + expect(measurableFontStack(TERMINAL_FONT_STACK, page(["Symbols Nerd Font Mono", ...system], system))).toBe(TERMINAL_FONT_STACK); + }); + + it("measures nothing without a page", () => { + expect(measurableFontStack(TERMINAL_FONT_STACK)).toBe(TERMINAL_FONT_STACK); + }); +}); + describe("chat font stack", () => { it("keeps the UI font when nothing is typed", () => { expect(chatFontStack("")).toBeNull(); diff --git a/src/lib/fontFamily.ts b/src/lib/fontFamily.ts index abaf74091b..3011249b39 100644 --- a/src/lib/fontFamily.ts +++ b/src/lib/fontFamily.ts @@ -69,6 +69,78 @@ export function terminalFontStack(family: string): string { return chosen === "" ? TERMINAL_FONT_STACK : `${SYMBOLS_FONT}, ${chosen}, ${TERMINAL_TEXT_FONTS}`; } +/** The width of PROBE_TEXT in a `font-family` list: as the page draws it, and as a canvas measures it. */ +export interface FontWidths { + drawn(family: string): number; + measured(family: string): number; +} + +// narrow and wide Latin, a digit and Hangul: any text face changes the width of some of them +const PROBE_TEXT = "mmmmmmmmmmlli1WW가나다"; +const PROBE_SIZE = "32px"; +// a monospaced face can match monospace's Latin to the pixel, never serif's as well +const PROBE_FALLBACKS = ["monospace", "serif"] as const; +const PROBE_TOLERANCE_PX = 0.5; +const GENERIC_FAMILIES = new Set(["monospace", "serif", "sans-serif", "cursive", "fantasy", "system-ui", "ui-monospace", "ui-serif", "ui-sans-serif", "ui-rounded", "math", "emoji", "fangsong"]); + +let pageWidths: FontWidths | undefined; + +/** This page's measurements, each made once: null without a document to measure in. */ +function browserFontWidths(): FontWidths | null { + if (pageWidths) return pageWidths; + const context = typeof document === "undefined" ? null : document.createElement("canvas").getContext("2d"); + if (!context || !document.body) return null; + const widths = new Map(); + const once = (kind: string, measure: (family: string) => number) => (family: string): number => { + const key = `${kind} ${family}`; + let width = widths.get(key); + if (width === undefined) { + width = measure(family); + widths.set(key, width); + } + return width; + }; + pageWidths = { + drawn: once("drawn", (family) => { + const span = document.createElement("span"); + span.style.cssText = "position:absolute;visibility:hidden;white-space:pre;letter-spacing:0"; + span.style.fontSize = PROBE_SIZE; + span.style.fontFamily = family; + span.textContent = PROBE_TEXT; + document.body.append(span); + const width = span.getBoundingClientRect().width; + span.remove(); + return width; + }), + measured: once("measured", (family) => { + context.font = `${PROBE_SIZE} ${family}`; + return context.measureText(PROBE_TEXT).width; + }), + }; + return pageWidths; +} + +function finds(width: (family: string) => number, name: string): boolean { + return PROBE_FALLBACKS.some((fallback) => Math.abs(width(`${name}, ${fallback}`) - width(fallback)) > PROBE_TOLERANCE_PX); +} + +/** + * The stack without the families this page draws but its canvas cannot measure. WebKit in an + * app's web view (Ovis for Mac's window) draws text in a font the user installed, while its + * canvas sees only the system's fonts; Safari hides an installed font from both, Chromium shows + * it to both. xterm sizes its cells from a canvas (TextMetrics), and lib/terminalGlyphs.ts fits + * wide glyphs from one: with JetBrains Mono and D2Coding installed, the app measured a grid in + * Menlo and drew it in those, rows too short for their glyphs, and Hangul it measured as Apple SD + * Gothic Neo was enlarged past its two cells. Left out, such a family falls back as if it were not + * installed, and the terminal draws what Safari draws. The icon font and generic families stay. + */ +export function measurableFontStack(stack: string, widths: FontWidths | null = browserFontWidths()): string { + if (!widths) return stack; + const names = stack.split(",").map((name) => name.trim()); + const kept = names.filter((name) => name === SYMBOLS_FONT || GENERIC_FAMILIES.has(name) || !finds(widths.drawn, name) || finds(widths.measured, name)); + return kept.length === names.length ? stack : kept.join(", "); +} + /** The chat body's `font-family`, or null to keep the UI font. */ export function chatFontStack(family: string): string | null { const chosen = sanitizeFontFamily(family); diff --git a/src/lib/useTerminalMount.ts b/src/lib/useTerminalMount.ts index 146f4a79d4..9a7f8344ab 100644 --- a/src/lib/useTerminalMount.ts +++ b/src/lib/useTerminalMount.ts @@ -16,7 +16,7 @@ import { paneStorageId } from "../../shared/machines.ts"; import { secretPrompt } from "../../shared/secret-prompt.ts"; import type { ClientRole, ServerMessage } from "../../shared/protocol.ts"; import { terminalTheme, type Palette, type ResolvedTheme, type Settings } from "./settings.ts"; -import { TERMINAL_FONT_STACK } from "./fontFamily.ts"; +import { measurableFontStack, TERMINAL_FONT_STACK } from "./fontFamily.ts"; import type { Translate } from "./i18n.ts"; import { isAppShortcut } from "./shortcuts.ts"; import { keyboardHoldsGrid, keyboardUp } from "./keyboard.ts"; @@ -187,7 +187,7 @@ export function useTerminalMount(deps: TerminalMountDeps): void { allowProposedApi: true, fontSize: terminalFontSize, // a chosen family follows in the font effect below, once its faces have loaded - fontFamily: TERMINAL_FONT_STACK, + fontFamily: measurableFontStack(TERMINAL_FONT_STACK), theme: terminalTheme(theme, palette), // a program's own colors suit one background (Claude Code's question titles are #ffffff, near // 1:1 on a light palette): xterm moves a foreground under 4.5:1 toward black or white. Dim text diff --git a/src/lib/useTerminalRefits.ts b/src/lib/useTerminalRefits.ts index 30b0dd5711..be4556abf1 100644 --- a/src/lib/useTerminalRefits.ts +++ b/src/lib/useTerminalRefits.ts @@ -1,6 +1,6 @@ import { useEffect, useRef, type RefObject } from "react"; import { terminalTheme, type Palette, type ResolvedTheme } from "./settings.ts"; -import { loadFontStack, TERMINAL_FONT_STACK, terminalFontStack } from "./fontFamily.ts"; +import { loadFontStack, measurableFontStack, TERMINAL_FONT_STACK, terminalFontStack } from "./fontFamily.ts"; import { modalOpen } from "./focus.ts"; import { inUse } from "./paneTerminalShared.ts"; import type { TerminalPeek } from "./terminalPeek.ts"; @@ -60,9 +60,10 @@ export function useTerminalRefits(deps: TerminalRefitsDeps): void { // a newer font, or an unmounted terminal, took over while the faces loaded if (superseded || termRef.current !== term) return; baseFontRef.current = terminalFontSize; - if (term.options.fontSize === terminalFontSize && term.options.fontFamily === fontFamily) return; + const family = measurableFontStack(fontFamily); + if (term.options.fontSize === terminalFontSize && term.options.fontFamily === family) return; term.options.fontSize = terminalFontSize; - term.options.fontFamily = fontFamily; + term.options.fontFamily = family; if (observeRef.current || fixedGridRef.current || chatViewRef.current || keyboardHoldsRef.current(true)) return; try { fitRef.current?.fit();