fix(terminal): render the terminal in the bundled JetBrains Mono (#44)

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
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-27 10:38:18 +08:00
1 джерело 5670c2e8dd
коміт b4a3e866c6
8 files changed
+185 -66

No files matched your search

+3 -3
Переглянути файл
@@ -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`);
+2 -1
Переглянути файл
@@ -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<number>("editor.lineHeight");
const [ligatures] = useSetting<boolean>("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 (
<>
<div className={styles.previewTitle}>
+4 -5
Переглянути файл
@@ -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<Terminal>();
@@ -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));
+10 -4
Переглянути файл
@@ -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<TerminalSettingsValid["cursorShape"], "block" | "underline" | "bar"> = {
@@ -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();
+27 -10
Переглянути файл
@@ -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);
});
});
+70 -43
Переглянути файл
@@ -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();
});
}
+24
Переглянути файл
@@ -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',
);
});
});
+45
Переглянути файл
@@ -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<void> {
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 */
}
}