fix(settings): List every installed font family in the font pickers (#225)

The font pickers never read the installed fonts: they probed a hard-coded list of ~30 names with canvas width checks, so anything outside the list never appeared and the monospaced filter changed nothing. A new ide-fonts crate asks the platform font system for families and their fixed-pitch flag (CoreText on macOS, DirectWrite on Windows, fc-list on Linux/BSD), exposed as system_font_families (spawn_blocking, cached). Every font picker — editor font and fallback, terminal font and fallback, UI custom font — uses one FontSelect with prefix type-ahead, a real monospaced filter, and keeps an uninstalled selection visible.

Not verified on Windows or Linux machines.

Fixes #199

Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-29 12:05:46 +08:00
1 джерело e537dd43a0
коміт aea0b33585
21 files changed
+647 -124

No files matched your search

+108
Переглянути файл
@@ -0,0 +1,108 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import type { FontFamily } from "@/platform/ipc";
const installed: FontFamily[] = [
{ name: "Andale Mono", monospaced: true },
{ name: "Arial", monospaced: false },
{ name: "Courier New", monospaced: true },
{ name: "Georgia", monospaced: false },
{ name: "Helvetica Neue", monospaced: false },
{ name: "Maple Mono NF CN", monospaced: true },
{ name: "Menlo", monospaced: true },
{ name: "Monaco", monospaced: true },
{ name: "PingFang SC", monospaced: false },
{ name: "Sarasa Term SC", monospaced: true },
];
const systemFontFamilies = vi.fn(async () => ({ status: "ok" as const, data: installed }));
vi.mock("@/platform/ipc", async (importOriginal) => {
const real = await importOriginal<typeof import("@/platform/ipc")>();
return { ...real, commands: { ...real.commands, systemFontFamilies: () => systemFontFamilies() } };
});
import { resetSystemFontsForTests } from "@/platform/systemFonts";
import { FontSelect } from "../components/FontSelect";
afterEach(() => resetSystemFontsForTests());
async function open(ui: React.ReactElement, label: string) {
render(ui);
await act(async () => {});
fireEvent.click(screen.getByRole("button", { name: label }));
return screen.getAllByRole("option").map((o) => o.textContent);
}
describe("FontSelect", () => {
it("lists every installed family plus the bundled one, sorted", async () => {
const names = await open(<FontSelect value="Menlo" onChange={() => {}} aria-label="Font" />, "Font");
expect(names).toEqual([
"Andale Mono",
"Arial",
"Courier New",
"Georgia",
"Helvetica Neue",
"JetBrains Mono",
"Maple Mono NF CN",
"Menlo",
"Monaco",
"PingFang SC",
"Sarasa Term SC",
]);
});
it("filters to monospaced families when asked, keeping the current one", async () => {
const names = await open(
<FontSelect value="PingFang SC" onChange={() => {}} monospacedOnly aria-label="Font" />,
"Font",
);
expect(names).toEqual([
"Andale Mono",
"Courier New",
"JetBrains Mono",
"Maple Mono NF CN",
"Menlo",
"Monaco",
"PingFang SC",
"Sarasa Term SC",
]);
});
it("keeps a current family that isn't installed here", async () => {
render(<FontSelect value="Fira Code" onChange={() => {}} monospacedOnly aria-label="Font" />);
await act(async () => {});
expect(screen.getByRole("button", { name: "Font" })).toHaveTextContent("Fira Code");
});
it("offers None first for fallback fonts, without the bundled family", async () => {
const names = await open(
<FontSelect value="" onChange={() => {}} none aria-label="Fallback font" />,
"Fallback font",
);
expect(names[0]).toBe("None");
expect(names).not.toContain("JetBrains Mono");
expect(names).toHaveLength(installed.length + 1);
});
it("jumps to the family typed as a prefix", async () => {
const onChange = vi.fn();
await open(<FontSelect value="Arial" onChange={onChange} aria-label="Font" />, "Font");
const popup = document.querySelector("[data-autofocus]")!;
fireEvent.keyDown(popup, { key: "m", timeStamp: 1000 });
fireEvent.keyDown(popup, { key: "e", timeStamp: 1100 });
fireEvent.keyDown(popup, { key: "Enter", timeStamp: 1200 });
expect(onChange).toHaveBeenCalledWith("Menlo");
});
it("reads the font list once", async () => {
systemFontFamilies.mockClear();
render(
<>
<FontSelect value="Menlo" onChange={() => {}} aria-label="A" />
<FontSelect value="Menlo" onChange={() => {}} aria-label="B" />
</>,
);
await act(async () => {});
expect(systemFontFamilies).toHaveBeenCalledTimes(1);
});
});
+2 -2
Переглянути файл
@@ -6,6 +6,7 @@
* pressed only" and "Save files if the IDE is idle".
*/
import { executeAction, type Action } from "@/platform/actions";
import { uiFontFamily } from "@/platform/fonts";
import { call, commands, isTauri } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { setDragRequiresAlt } from "@/ui";
@@ -32,8 +33,7 @@ export function applyAppearanceOptions(s: ValidSettings, root: HTMLElement = doc
if (size === DEFAULT_SETTINGS.appearance.uiFontSize) root.style.removeProperty(name);
else root.style.setProperty(name, `${size + delta}px`);
}
if (a.useCustomFont)
root.style.setProperty("--font-ui", `"${a.uiFontFamily.replace(/"/g, "")}", system-ui, sans-serif`);
if (a.useCustomFont) root.style.setProperty("--font-ui", uiFontFamily(a.uiFontFamily));
else root.style.removeProperty("--font-ui");
flag(root, "compact", a.compactMode);
+46
Переглянути файл
@@ -0,0 +1,46 @@
/**
* Font-family pickers over the families installed on this machine. The current
* value always stays listed, so a family that isn't installed here (a synced
* setting, an uninstalled font) still shows as selected.
*/
import { useMemo } from "react";
import type { SelectOption } from "@/ui";
import { BUNDLED_MONO_FAMILY } from "@/platform/fonts";
import { fontNames, useSystemFonts } from "@/platform/systemFonts";
import { Select } from "./Form";
const NO_BUNDLED: readonly string[] = [];
const MONO_BUNDLED: readonly string[] = [BUNDLED_MONO_FAMILY];
export interface FontSelectProps {
value: string;
onChange: (value: string) => void;
/** List only monospaced families (the current value stays). */
monospacedOnly?: boolean;
/** Offer "None" (empty value) first: fallback fonts. */
none?: boolean;
/** Families the app bundles, listed even when not installed. Default: the bundled monospace (none for fallbacks). */
bundled?: readonly string[];
disabled?: boolean;
"aria-label": string;
}
export function FontSelect({
value,
onChange,
monospacedOnly,
none,
bundled = none ? NO_BUNDLED : MONO_BUNDLED,
disabled,
...aria
}: FontSelectProps) {
const fonts = useSystemFonts();
const options = useMemo<SelectOption[]>(() => {
const names = fontNames(fonts, { monospacedOnly, include: [...bundled, value] });
const list = names.map((n) => ({ value: n, label: n }));
return none ? [{ value: "", label: "None" }, ...list] : list;
}, [fonts, monospacedOnly, none, bundled, value]);
return (
<Select value={value} options={options} onChange={onChange} disabled={disabled} aria-label={aria["aria-label"]} />
);
}
+5
Переглянути файл
@@ -23,6 +23,11 @@
}
/* "[x] Limit history to: [1000] rows" */
.fontFamily {
display: flex;
align-items: center;
gap: 8px;
}
.inlineRow {
display: flex;
align-items: center;
+6 -19
Переглянути файл
@@ -14,6 +14,8 @@ import { Checkbox, IconButton, Input, Popup, type SelectOption, T } from "@/ui";
import { NOTIFICATION_GROUPS } from "@/workbench/notificationStore";
import { ColorSwatches, NumberInput, Row, SearchLabel, Select, Toggle } from "../components/Form";
import { useLabelMatch, useSetting } from "../components/context";
import { FontSelect } from "../components/FontSelect";
import { BUNDLED_MONO_FAMILY, BUNDLED_UI_FAMILY } from "@/platform/fonts";
import {
ZOOM_LIMITS,
IDLE_LIMITS,
@@ -174,35 +176,20 @@ function ZoomRow() {
);
}
export const UI_FONTS = ["Inter", "SF Pro Text", "Helvetica Neue", "PingFang SC", "Arial", "Verdana", "JetBrains Mono"];
const UI_BUNDLED = [BUNDLED_UI_FAMILY, BUNDLED_MONO_FAMILY];
function CustomFontRow() {
const [on, setOn] = useSetting<boolean>("appearance.useCustomFont");
const [family, setFamily] = useSetting<string>("appearance.uiFontFamily");
const [size, setSize] = useSetting<number>("appearance.uiFontSize");
const [draft, setDraft] = useState<string | null>(null);
const id = useId();
const matched = useLabelMatch("Use custom font");
return (
<div className={clsx(styles.fontRow, matched && styles.matched)}>
<Checkbox checked={on} onChange={setOn} label={<SearchLabel text="Use custom font:" />} />
<Input
list={`${id}-fonts`}
className={styles.fontInput}
aria-label="UI font"
disabled={!on}
value={draft ?? family}
onChange={(e) => {
setDraft(e.target.value);
if (e.target.value.trim()) setFamily(e.target.value.trim());
}}
onBlur={() => setDraft(null)}
/>
<datalist id={`${id}-fonts`}>
{UI_FONTS.map((f) => (
<option key={f} value={f} />
))}
</datalist>
<span className={styles.fontInput}>
<FontSelect value={family} onChange={setFamily} bundled={UI_BUNDLED} disabled={!on} aria-label="UI font" />
</span>
<label className={clsx(styles.sizeLabel, !on && styles.disabled)} htmlFor={`${id}-size`}>
<SearchLabel text="Size:" />
</label>
+4 -80
Переглянути файл
@@ -2,10 +2,11 @@
* Editor › Font: family (installed fonts, monospaced filter), size, line height,
* ligatures, Typography Settings (main / bold weight, fallback font), editable preview.
*/
import { useMemo, useState } from "react";
import { useState } from "react";
import { Checkbox, type SelectOption, T } from "@/ui";
import { monoFontFamily } from "@/platform/fonts";
import { NumberInput, Row, SearchLabel, Select } from "../../components/Form";
import { FontSelect } from "../../components/FontSelect";
import { useLabelMatch, usePageContext, useSetting } from "../../components/context";
import { FONT_WEIGHTS, fontWeightOf, fontWeightVars } from "@/platform/editorSettings";
import { LIMITS } from "../../model/schema";
@@ -13,88 +14,15 @@ import { editorPage } from "./kit";
import styles from "./Editor.module.css";
import { tx } from "@/platform/i18n";
const CANDIDATES = [
"JetBrains Mono",
"SF Mono",
"Menlo",
"Monaco",
"Fira Code",
"Fira Mono",
"Source Code Pro",
"Cascadia Code",
"Cascadia Mono",
"IBM Plex Mono",
"Hack",
"Inconsolata",
"Roboto Mono",
"Ubuntu Mono",
"DejaVu Sans Mono",
"Consolas",
"Courier New",
"PT Mono",
"Andale Mono",
"Victor Mono",
"Iosevka",
"Sarasa Mono SC",
"Noto Sans Mono",
"Noto Sans Mono CJK SC",
"PingFang SC",
"Hiragino Sans GB",
"Microsoft YaHei",
"Helvetica Neue",
"Arial",
"Inter",
"Georgia",
];
interface FontInfo {
name: string;
mono: boolean;
}
let detected: FontInfo[] | null = null;
/** Installed candidates, detected by comparing glyph widths against generic fallbacks. */
export function installedFonts(): FontInfo[] {
if (detected) return detected;
const canvas = typeof document !== "undefined" ? document.createElement("canvas") : null;
const ctx = canvas?.getContext?.("2d");
if (!ctx) return (detected = CANDIDATES.map((name) => ({ name, mono: true })));
const width = (font: string, text: string) => {
ctx.font = `32px ${font}`;
return ctx.measureText(text).width;
};
const sample = "mmmmmmmmmmlli1WQ@";
const out: FontInfo[] = [];
for (const name of CANDIDATES) {
const quoted = `"${name}"`;
const present = ["monospace", "serif", "sans-serif"].some(
(base) => width(`${quoted}, ${base}`, sample) !== width(base, sample),
);
if (!present && name !== "JetBrains Mono") continue;
out.push({
name,
mono: Math.abs(width(`${quoted}, monospace`, "iiiiiiiiii") - width(`${quoted}, monospace`, "WWWWWWWWWW")) < 0.5,
});
}
return (detected = out);
}
function FontRow() {
const [family, setFamily] = useSetting<string>("editor.fontFamily");
const [monoOnly, setMonoOnly] = useState(true);
const matched = useLabelMatch("Show only monospaced fonts");
const options = useMemo<SelectOption[]>(() => {
const list = installedFonts().filter((f) => !monoOnly || f.mono);
const names = list.map((f) => f.name);
if (!names.includes(family)) names.unshift(family);
return names.map((n) => ({ value: n, label: n }));
}, [family, monoOnly]);
return (
<Row label="Font">
<span className={styles.fontFamily}>
<span className={styles.fontSelect}>
<Select value={family} options={options} onChange={setFamily} aria-label="Font" />
<FontSelect value={family} onChange={setFamily} monospacedOnly={monoOnly} aria-label="Font" />
</span>
<span className={matched ? styles.matched : undefined}>
<Checkbox
@@ -125,13 +53,9 @@ function SizeRow() {
function FallbackRow() {
const [fallback, setFallback] = useSetting<string>("editor.fallbackFont");
const options = useMemo<SelectOption[]>(
() => [{ value: "", label: "None" }, ...installedFonts().map((f) => ({ value: f.name, label: f.name }))],
[],
);
return (
<Row label="Fallback font" hint="For symbols not supported by the main font">
<Select value={fallback} options={options} onChange={setFallback} aria-label="Fallback font" />
<FontSelect value={fallback} onChange={setFallback} none aria-label="Fallback font" />
</Row>
);
}
+24 -19
Переглянути файл
@@ -14,6 +14,7 @@ import {
} from "@/platform/workflowSettings";
import { Button, Checkbox, IconButton, Input, Menu, type SelectOption, T } from "@/ui";
import { NumberInput, Row, SearchLabel, Section, Select } from "../components/Form";
import { FontSelect } from "../components/FontSelect";
import { useLabelMatch, useSetting } from "../components/context";
import { selectPage, setQuery } from "../model/staging";
import type { PageSpec } from "./spec";
@@ -231,8 +232,8 @@ function TerminalFont() {
const [fallback, setFallback] = useSetting<string>("terminal.fallbackFontFamily");
const [columnWidth, setColumnWidth] = useSetting<number>("terminal.columnWidth");
const matched = useLabelMatch("Use editor font");
const familyId = useId();
const fallbackId = useId();
const monoMatched = useLabelMatch("Show only monospaced fonts");
const [monoOnly, setMonoOnly] = useState(true);
return (
<>
<div className={clsx(styles.inlineRow, matched && styles.matched)}>
@@ -245,14 +246,24 @@ function TerminalFont() {
</span>
</div>
<Indented>
<Row label="Font" htmlFor={familyId}>
<Input
id={familyId}
className={styles.medium}
disabled={useEditor}
value={family}
onChange={(e) => e.target.value.trim() && setFamily(e.target.value)}
/>
<Row label="Font">
<span className={styles.fontFamily}>
<FontSelect
value={family}
onChange={setFamily}
monospacedOnly={monoOnly}
disabled={useEditor}
aria-label="Terminal font"
/>
<span className={monoMatched ? styles.matched : undefined}>
<Checkbox
checked={monoOnly}
onChange={setMonoOnly}
disabled={useEditor}
label={<SearchLabel text="Show only monospaced fonts" />}
/>
</span>
</span>
</Row>
<Row label="Size">
<NumberInput
@@ -274,14 +285,8 @@ function TerminalFont() {
aria-label="Terminal line height"
/>
</Row>
<Row label="Fallback font" htmlFor={fallbackId} hint="Used for characters the main font lacks.">
<Input
id={fallbackId}
className={styles.medium}
value={fallback}
placeholder="None"
onChange={(e) => setFallback(e.target.value)}
/>
<Row label="Fallback font" hint="Used for characters the main font lacks.">
<FontSelect value={fallback} onChange={setFallback} none aria-label="Terminal fallback font" />
</Row>
<Row label="Column width">
<NumberInput
@@ -588,7 +593,7 @@ export const TOOLS_PAGES: PageSpec[] = [
{
kind: "custom",
label: "Use editor font",
keywords: ["Font", "Size", "Line height", "Fallback font", "Column width"],
keywords: ["Font", "Show only monospaced fonts", "Size", "Line height", "Fallback font", "Column width"],
component: TerminalFont,
},
],