Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
5 changes: 5 additions & 0 deletions changelog.d/mac-app-installed-font-terminal.md
Original file line number Diff line number Diff line change
@@ -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.
37 changes: 36 additions & 1 deletion src/lib/fontFamily.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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();
Expand Down
72 changes: 72 additions & 0 deletions src/lib/fontFamily.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>();
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);
Expand Down
4 changes: 2 additions & 2 deletions src/lib/useTerminalMount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions src/lib/useTerminalRefits.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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();
Expand Down
Loading