From b4a3e866c61d96c85530a5d9e882eb6811351b08 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:38:18 +0800 Subject: [PATCH] fix(terminal): render the terminal in the bundled JetBrains Mono (#44) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: the terminal built its own font-family ("JetBrains Mono", Nerd Fonts, monospace) without the bundled "JetBrains Mono Variable", so on Windows (no JetBrains Mono installed) xterm fell back to the system monospace (SimSun on Chinese Windows); xterm also measured cells before the webfont loaded. Fix: one resolver (src/platform/fonts.ts monoFontFamily) maps JetBrains Mono to the bundled face; xterm re-measures once fonts load; "Use editor font" also takes the editor's fallback font (IDEA). Siblings covered: embedded run terminal, editor default family + fallback, Settings › Editor › Font preview. Verified in-app on macOS (terminal renders JetBrains Mono Variable). Not verified on Windows. Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn --- src/editor/core/cm/setup.ts | 6 +- src/features/settings/pages/editor/font.tsx | 3 +- src/features/terminal/embedded.ts | 9 +- src/features/terminal/session.ts | 14 ++- src/features/terminal/terminal.test.ts | 37 +++++-- src/features/terminal/theme.ts | 113 ++++++++++++-------- src/platform/fonts.test.ts | 24 +++++ src/platform/fonts.ts | 45 ++++++++ 8 files changed, 185 insertions(+), 66 deletions(-) create mode 100644 src/platform/fonts.test.ts create mode 100644 src/platform/fonts.ts diff --git a/src/editor/core/cm/setup.ts b/src/editor/core/cm/setup.ts index ed2c43fcb..4a47ce40b 100644 --- a/src/editor/core/cm/setup.ts +++ b/src/editor/core/cm/setup.ts @@ -26,6 +26,7 @@ import { highlightSelectionMatches } from "@codemirror/search"; import type { EditorConfigProps } from "@/bindings"; import type { EditorSettings } from "@/platform/ipc"; import { editorPrefs, lineCommentStyleFor, type EditorExtras } from "@/platform/editorSettings"; +import { BUNDLED_MONO_FAMILY, monoFontFamily } from "@/platform/fonts"; import { editorContributions, type EditorDocContext } from "@/editor/api"; import { distractionFreeWidth, isDistractionFree } from "@/workbench/distractionFree"; import { openideaHighlightStyle } from "./highlight"; @@ -160,9 +161,8 @@ export function fontExtension(settings: EditorSettings | undefined): Extension { const prefs = prefsOf(settings); const family = settings?.fontFamily; const fallback = prefs.fallbackFont.trim(); - if ((family && family !== "JetBrains Mono") || fallback) { - const fam = (family || "JetBrains Mono").replace(/"/g, ""); - vars.push(`--openidea-editor-font: "${fam}"${fallback ? `, "${fallback.replace(/"/g, "")}"` : ""}, var(--font-mono)`); + if ((family && family !== BUNDLED_MONO_FAMILY) || fallback) { + vars.push(`--openidea-editor-font: ${monoFontFamily(family, fallback, "var(--font-mono)")}`); } if (settings?.fontSize) vars.push(`--openidea-editor-font-size: ${settings.fontSize}px`, `--openidea-editor-base-size: ${settings.fontSize}px`); if (settings?.lineHeight) vars.push(`--openidea-editor-line-height: ${settings.lineHeight}px`, `--openidea-editor-base-line: ${settings.lineHeight}px`); diff --git a/src/features/settings/pages/editor/font.tsx b/src/features/settings/pages/editor/font.tsx index a940f17b0..b648bf5f4 100644 --- a/src/features/settings/pages/editor/font.tsx +++ b/src/features/settings/pages/editor/font.tsx @@ -1,6 +1,7 @@ /** Editor › Font: family (installed fonts, monospaced filter), size, line height, ligatures, fallback, editable preview. */ import { useMemo, useState } from "react"; import { Checkbox, type SelectOption, T } from "@/ui"; +import { monoFontFamily } from "@/platform/fonts"; import { NumberInput, Row, SearchLabel, Select } from "../../components/Form"; import { useLabelMatch, useSetting } from "../../components/context"; import { LIMITS } from "../../model/schema"; @@ -149,7 +150,7 @@ function Preview() { const [line] = useSetting("editor.lineHeight"); const [ligatures] = useSetting("editor.ligatures"); const [text, setText] = useState(SAMPLE); - const fam = `"${family.replace(/"/g, "")}"${fallback ? `, "${fallback.replace(/"/g, "")}"` : ""}, var(--font-mono)`; + const fam = monoFontFamily(family, fallback, "var(--font-mono)"); return ( <>
diff --git a/src/features/terminal/embedded.ts b/src/features/terminal/embedded.ts index f307bbdc9..d8133fc72 100644 --- a/src/features/terminal/embedded.ts +++ b/src/features/terminal/embedded.ts @@ -11,8 +11,8 @@ import type { EmbeddedTerminal, EmbeddedTerminalOptions } from "@/workbench/cont import { useProject } from "@/platform/project"; import { workflowSettings } from "@/platform/workflowSettings"; import { findTerminalLinks } from "./links"; -import { openExternal, openFileLink } from "./session"; -import { readTerminalTheme, terminalFont, withFontExtras } from "./theme"; +import { fontFor, openExternal, openFileLink } from "./session"; +import { readTerminalTheme, remeasureWhenFontsLoad } from "./theme"; const live = new Set(); @@ -24,9 +24,7 @@ export function applyThemeToEmbedded() { export function createEmbeddedTerminal(host: HTMLElement, opts: EmbeddedTerminalOptions): EmbeddedTerminal { const settings = workflowSettings().terminal; - const editor = useProject.getState().settings?.editor as - { fontFamily?: string; fontSize?: number; lineHeight?: number } | undefined; - const font = withFontExtras(terminalFont(settings, editor), settings); + const font = fontFor(settings); const term = new Terminal({ fontFamily: font.family, fontSize: font.size, @@ -89,6 +87,7 @@ export function createEmbeddedTerminal(host: HTMLElement, opts: EmbeddedTerminal const observer = new ResizeObserver(scheduleFit); observer.observe(host); scheduleFit(); + remeasureWhenFontsLoad(term, font, () => live.has(term), scheduleFit); // The owner learns the initial size even when it matches xterm's default. queueMicrotask(() => opts.onResize(term.cols, term.rows)); diff --git a/src/features/terminal/session.ts b/src/features/terminal/session.ts index e8be87d27..140bb5526 100644 --- a/src/features/terminal/session.ts +++ b/src/features/terminal/session.ts @@ -14,7 +14,8 @@ import { findTerminalLinks, resolvePath, type TerminalLink } from "./links"; import { splitCommandLine } from "@/platform/commandLine"; import { useProject } from "@/platform/project"; import { workflowSettings, type TerminalSettingsValid } from "@/platform/workflowSettings"; -import { readSearchDecorations, readTerminalTheme, terminalFont, withFontExtras } from "./theme"; +import { editorPrefs } from "@/platform/editorSettings"; +import { readSearchDecorations, readTerminalTheme, remeasureWhenFontsLoad, terminalFont, type TerminalFontSettings } from "./theme"; export interface TerminalSearchOptions { caseSensitive: boolean; @@ -77,9 +78,10 @@ function beep() { } } -function fontFor(t: TerminalSettingsValid) { +/** The terminal font for `t`, resolved against the current editor font (also used by embedded terminals). */ +export function fontFor(t: TerminalFontSettings) { const e = useProject.getState().settings?.editor as { fontFamily?: string; fontSize?: number; lineHeight?: number } | undefined; - return withFontExtras(terminalFont(t, e), t); + return terminalFont(t, { ...e, fallbackFont: editorPrefs().fallbackFont }); } const CURSOR: Record = { @@ -237,7 +239,10 @@ export class TerminalSession { applySettings(t: TerminalSettingsValid) { const font = fontFor(t); const o = this.term.options; - if (o.fontFamily !== font.family) o.fontFamily = font.family; + if (o.fontFamily !== font.family) { + o.fontFamily = font.family; + if (this.opened) remeasureWhenFontsLoad(this.term, font, () => !this.disposed, () => this.scheduleFit()); + } if (o.fontSize !== font.size) o.fontSize = font.size; if (o.lineHeight !== font.lineHeight) o.lineHeight = font.lineHeight; if (o.letterSpacing !== font.letterSpacing) o.letterSpacing = font.letterSpacing; @@ -288,6 +293,7 @@ export class TerminalSession { // xterm's DOM renderer: the WebGL one rasterizes glyphs per background, so // selected text rendered at a visibly different weight than the rest. this.term.open(this.host); + remeasureWhenFontsLoad(this.term, fontFor(workflowSettings().terminal), () => !this.disposed, () => this.scheduleFit()); this.term.textarea?.addEventListener("focus", () => this.opts.onFocus(this.key)); } this.resizeObserver?.disconnect(); diff --git a/src/features/terminal/terminal.test.ts b/src/features/terminal/terminal.test.ts index 4632c1dc7..8b89f0942 100644 --- a/src/features/terminal/terminal.test.ts +++ b/src/features/terminal/terminal.test.ts @@ -5,7 +5,7 @@ import { buildSearchDecorations } from "./theme"; import { findTerminalLinks, resolvePath, trimUrl } from "./links"; import { layoutPanes, nextTabName, removeFromLayout, splitLayout, tabFullTitle, tabTitle, trimMiddle, type TerminalTab } from "./store"; import type { SessionSnapshot } from "./session"; -import { buildTheme, mix, parseColor, toHex, withFontExtras } from "./theme"; +import { buildTheme, mix, parseColor, terminalFont, toHex } from "./theme"; describe("theme", () => { it("parses colour formats", () => { @@ -208,19 +208,36 @@ describe("split layout", () => { }); }); -describe("withFontExtras", () => { - const font = { family: '"JetBrains Mono", "Symbols Nerd Font", monospace', size: 10, lineHeight: 1 }; +describe("terminalFont", () => { + const BASE = '"JetBrains Mono Variable", "JetBrains Mono", ui-monospace, Menlo, monospace'; + const t = { useEditorFont: true, fontFamily: "JetBrains Mono", fontSize: 10, lineHeight: 1, fallbackFontFamily: "", columnWidth: 1 }; + const editor = { fontFamily: "JetBrains Mono", fontSize: 10, lineHeight: 12 }; + const head = (family: string) => family.slice(0, family.indexOf('"Symbols Nerd Font Mono"')); - it("puts the fallback family right after the main one", () => { - expect(withFontExtras(font, { fallbackFontFamily: "PingFang SC", columnWidth: 1 }).family).toBe( - '"JetBrains Mono", "PingFang SC", "Symbols Nerd Font", monospace', - ); - expect(withFontExtras(font, { fallbackFontFamily: "", columnWidth: 1 }).family).toBe(font.family); + it.each([ + // #26: "Use editor font" with the default family must reach the bundled face, not the system monospace. + ["editor font, default family", t, editor, `"JetBrains Mono Variable", "JetBrains Mono", ${BASE.replace(", monospace", "")}, `], + ["editor font, no editor settings", t, undefined, `"JetBrains Mono Variable", "JetBrains Mono", ${BASE.replace(", monospace", "")}, `], + ["editor font, custom family", t, { ...editor, fontFamily: "Fira Code" }, `"Fira Code", ${BASE.replace(", monospace", "")}, `], + ["editor font, editor fallback", t, { ...editor, fallbackFont: "PingFang SC" }, `"JetBrains Mono Variable", "JetBrains Mono", "PingFang SC", ${BASE.replace(", monospace", "")}, `], + ["own font, default family", { ...t, useEditorFont: false }, editor, `"JetBrains Mono Variable", "JetBrains Mono", ${BASE.replace(", monospace", "")}, `], + ["own font, custom + fallback", { ...t, useEditorFont: false, fontFamily: "Hack", fallbackFontFamily: "PingFang SC" }, editor, `"Hack", "PingFang SC", ${BASE.replace(", monospace", "")}, `], + ])("%s resolves through the bundled stack", (_, settings, ed, expected) => { + const font = terminalFont(settings, ed, BASE); + expect(head(font.family)).toBe(expected); + expect(font.family.endsWith(", monospace")).toBe(true); + expect(font.family).not.toContain("var("); + }); + + it("prefers the terminal's fallback over the editor's", () => { + const font = terminalFont({ ...t, fallbackFontFamily: "Sarasa Mono SC" }, { ...editor, fallbackFont: "PingFang SC" }, BASE); + expect(font.family).toContain('"Sarasa Mono SC"'); + expect(font.family).not.toContain("PingFang SC"); }); it("turns the column width into letter spacing", () => { - expect(withFontExtras(font, { fallbackFontFamily: "", columnWidth: 1 }).letterSpacing).toBe(0); - expect(withFontExtras(font, { fallbackFontFamily: "", columnWidth: 1.5 }).letterSpacing).toBe(3); + expect(terminalFont({ ...t, useEditorFont: false }, editor, BASE).letterSpacing).toBe(0); + expect(terminalFont({ ...t, useEditorFont: false, columnWidth: 1.5 }, editor, BASE).letterSpacing).toBe(3); }); }); diff --git a/src/features/terminal/theme.ts b/src/features/terminal/theme.ts index 8d296015b..944f4274a 100644 --- a/src/features/terminal/theme.ts +++ b/src/features/terminal/theme.ts @@ -4,7 +4,8 @@ * tokens (PyCharm's console colours per theme); when a theme lacks them it is * derived from the status + syntax tokens. */ -import type { ITheme } from "@xterm/xterm"; +import type { ITheme, Terminal } from "@xterm/xterm"; +import { BUNDLED_MONO_FAMILY, fontsLoaded, monoFontFamily, monoStack } from "@/platform/fonts"; export type Rgba = [number, number, number, number]; @@ -177,56 +178,82 @@ const NERD_FONT_FALLBACKS = [ .map((f) => `"${f}"`) .join(", "); -export function readTerminalFont(): { family: string; size: number; lineHeight: number } { - const style = getComputedStyle(document.documentElement); - const base = style.getPropertyValue("--font-mono").trim() || "'JetBrains Mono', monospace"; - // Prompt themes (powerlevel10k, starship) use Nerd Font private-use glyphs; - // list common installs so per-glyph fallback finds them. - const family = `${base}, ${NERD_FONT_FALLBACKS}`; - const size = parseFloat(style.getPropertyValue("--fs-code")) || 13; - const lh = parseFloat(style.getPropertyValue("--lh-code")) || 21; - // xterm's lineHeight is a multiplier of the cell height (≈ 1.2 × font size); aim for the editor's line height. - return { family, size, lineHeight: Math.max(1, Math.round((lh / (size * 1.2)) * 100) / 100) }; +export interface TerminalFontSettings { + useEditorFont: boolean; + fontFamily: string; + fontSize: number; + lineHeight: number; + fallbackFontFamily: string; + columnWidth: number; +} + +export interface EditorFont { + fontFamily?: string; + fontSize?: number; + lineHeight?: number; + fallbackFont?: string; +} + +export interface TerminalFont { + family: string; + size: number; + lineHeight: number; + letterSpacing: number; } /** - * Font from Settings › Tools › Terminal: the editor font (Editor › Font) or the - * terminal's own family / size / line height (a multiple of the cell height). + * Prompt themes (powerlevel10k, starship) use Nerd Font private-use glyphs; list + * common installs before the generic family so per-glyph fallback finds them. */ -export function terminalFont( - t: { useEditorFont: boolean; fontFamily: string; fontSize: number; lineHeight: number }, - editor: { fontFamily?: string; fontSize?: number; lineHeight?: number } | undefined, -): { family: string; size: number; lineHeight: number } { +function withNerdFonts(family: string): string { + return `${family.replace(/,\s*monospace\s*$/i, "")}, ${NERD_FONT_FALLBACKS}, monospace`; +} + +function cssNumber(name: string): number { + if (typeof document === "undefined") return NaN; + return parseFloat(getComputedStyle(document.documentElement).getPropertyValue(name)); +} + +/** + * Font from Settings › Tools › Terminal: the editor font (Editor › Font, with its + * fallback) or the terminal's own family / size / line height (a multiple of the + * cell height). The family resolves like the editor's (`monoFontFamily`), so the + * bundled JetBrains Mono is used where it isn't installed; `base` is the resolved + * `--font-mono` stack, since xterm measures on a canvas and can't use `var()`. + * The column width (a multiple of the cell width) becomes xterm's letter spacing, + * from the ≈ 0.6 em advance of monospace fonts. + */ +export function terminalFont(t: TerminalFontSettings, editor: EditorFont | undefined, base = monoStack()): TerminalFont { + let family: string, fallback: string, size: number, lineHeight: number; if (!t.useEditorFont) { - return { family: `"${t.fontFamily.replace(/"/g, "")}", ${NERD_FONT_FALLBACKS}, monospace`, size: t.fontSize, lineHeight: t.lineHeight }; + family = t.fontFamily; + fallback = t.fallbackFontFamily; + size = t.fontSize; + lineHeight = t.lineHeight; + } else { + family = editor?.fontFamily || BUNDLED_MONO_FAMILY; + fallback = t.fallbackFontFamily.trim() || editor?.fallbackFont || ""; + size = editor?.fontSize || cssNumber("--fs-code") || 13; + const lh = editor?.lineHeight ?? (editor?.fontSize ? size * 1.6 : cssNumber("--lh-code") || 21); + // xterm's lineHeight is a multiplier of the cell height (≈ 1.2 × font size); aim for the editor's line height. + lineHeight = Math.max(1, Math.round((lh / (size * 1.2)) * 100) / 100); } - if (!editor?.fontFamily || !editor.fontSize) return readTerminalFont(); - const size = editor.fontSize; - const lh = editor.lineHeight ?? size * 1.6; - return { - family: `"${editor.fontFamily.replace(/"/g, "")}", ${NERD_FONT_FALLBACKS}, monospace`, - size, - lineHeight: Math.max(1, Math.round((lh / (size * 1.2)) * 100) / 100), - }; + const letterSpacing = Math.round((t.columnWidth - 1) * size * 0.6 * 10) / 10; + return { family: withNerdFonts(monoFontFamily(family, fallback, base)), size, lineHeight, letterSpacing }; } /** - * Settings › Tools › Terminal extras over `terminalFont`: the fallback family - * goes right after the main one, and the column width (a multiple of the cell - * width) becomes xterm's letter spacing, from the ≈ 0.6 em advance of - * monospace fonts. + * xterm measures its cell once per font change; a webfont still loading (the + * bundled JetBrains Mono on first open) leaves cells sized for the swap font. + * Re-measures once the faces load (unless `alive()` turned false), then calls `after` (refit). */ -export function withFontExtras( - font: { family: string; size: number; lineHeight: number }, - t: { fallbackFontFamily: string; columnWidth: number }, -): { family: string; size: number; lineHeight: number; letterSpacing: number } { - const fallback = t.fallbackFontFamily.replace(/"/g, "").trim(); - const comma = font.family.indexOf(","); - const family = !fallback - ? font.family - : comma < 0 - ? `${font.family}, "${fallback}"` - : `${font.family.slice(0, comma)}, "${fallback}"${font.family.slice(comma)}`; - const letterSpacing = Math.round((t.columnWidth - 1) * font.size * 0.6 * 10) / 10; - return { ...font, family, letterSpacing }; +export function remeasureWhenFontsLoad(term: Terminal, font: TerminalFont, alive: () => boolean, after: () => void) { + void fontsLoaded(font.family, font.size).then(() => { + if (!alive()) return; + const o = term.options; + if (o.fontFamily !== font.family) return; + o.fontFamily = "monospace"; + o.fontFamily = font.family; + after(); + }); } diff --git a/src/platform/fonts.test.ts b/src/platform/fonts.test.ts new file mode 100644 index 000000000..f062c459d --- /dev/null +++ b/src/platform/fonts.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { monoFontFamily } from "./fonts"; + +const BASE = "var(--font-mono)"; + +describe("monoFontFamily", () => { + it.each([ + // #26: the default family names the bundled face, so it renders without a system install. + ["JetBrains Mono", "", `"JetBrains Mono Variable", "JetBrains Mono", ${BASE}`], + ["", "", `"JetBrains Mono Variable", "JetBrains Mono", ${BASE}`], + [undefined, "", `"JetBrains Mono Variable", "JetBrains Mono", ${BASE}`], + ["JetBrains Mono", "PingFang SC", `"JetBrains Mono Variable", "JetBrains Mono", "PingFang SC", ${BASE}`], + ["Fira Code", "", `"Fira Code", ${BASE}`], + ['"Cascadia Code"', " Microsoft YaHei ", `"Cascadia Code", "Microsoft YaHei", ${BASE}`], + ])("%j + fallback %j", (family, fallback, expected) => { + expect(monoFontFamily(family, fallback, BASE)).toBe(expected); + }); + + it("always ends with the bundled stack, so a missing family never reaches the system default", () => { + expect(monoFontFamily("Not Installed", "", '"JetBrains Mono Variable", monospace')).toBe( + '"Not Installed", "JetBrains Mono Variable", monospace', + ); + }); +}); diff --git a/src/platform/fonts.ts b/src/platform/fonts.ts new file mode 100644 index 000000000..b5413b753 --- /dev/null +++ b/src/platform/fonts.ts @@ -0,0 +1,45 @@ +/** + * Monospace font-family lists shared by every consumer of the editor / terminal + * font. "JetBrains Mono" is the bundled default, but the bundle registers it as + * "JetBrains Mono Variable" (`@fontsource-variable/jetbrains-mono`); a list that + * only names "JetBrains Mono" falls through to the system's generic monospace + * wherever the font isn't installed (SimSun on Chinese Windows). + */ + +export const BUNDLED_MONO_FAMILY = "JetBrains Mono"; +const BUNDLED_MONO_FACES = ['"JetBrains Mono Variable"', '"JetBrains Mono"']; +const MONO_STACK_FALLBACK = '"JetBrains Mono Variable", "JetBrains Mono", ui-monospace, Menlo, monospace'; + +const quote = (f: string) => `"${f.replace(/"/g, "").trim()}"`; + +/** The `--font-mono` stack, resolved (canvas / xterm can't use `var()`). */ +export function monoStack(): string { + if (typeof document === "undefined") return MONO_STACK_FALLBACK; + return getComputedStyle(document.documentElement).getPropertyValue("--font-mono").trim() || MONO_STACK_FALLBACK; +} + +/** + * `family`, then `fallback`, then `base` (the `--font-mono` stack: `var(--font-mono)` + * for CSS, `monoStack()` for canvas measurement). The bundled family maps to its + * bundled face, so it renders without a system install. + */ +export function monoFontFamily(family: string | undefined, fallback = "", base = monoStack()): string { + const name = (family ?? "").replace(/"/g, "").trim(); + const fb = fallback.replace(/"/g, "").trim(); + const head = !name || name === BUNDLED_MONO_FAMILY ? BUNDLED_MONO_FACES : [quote(name)]; + return [...head, ...(fb ? [quote(fb)] : []), base].join(", "); +} + +/** + * Resolves once the faces `family` uses at `size` are loaded, so code that measures + * glyphs (xterm's cell size) measures the real font rather than the swap fallback. + */ +export async function fontsLoaded(family: string, size: number): Promise { + const fonts = typeof document !== "undefined" ? document.fonts : undefined; + if (!fonts?.load) return; + try { + await Promise.all([fonts.load(`${size}px ${family}`), fonts.load(`bold ${size}px ${family}`)]); + } catch { + /* an unparsable list measures with whatever is available */ + } +}