feat(settings): add editable font search and refresh (#251)

The font pickers (editor, fallback, UI, terminal) become editable comboboxes: type to search installed families or enter any family name, and Refresh re-reads the system fonts. ide-fonts' cached family list is now refreshable (DirectWrite re-queries the system collection with checkForUpdates; CoreText and fontconfig are read anew), exposed through system_font_families(refresh). Before the first native result the pickers fall back to Canvas detection of common families; saved names and the last catalog are kept when a refresh fails. Fonts are enumerated on the local desktop in remote-development windows too.

Co-authored-by: bghtyu <[email protected]>
Цей коміт міститься в:
Walter Wangтаbghtyu authored and GitHub committed 2026-10-29 23:35:33 +08:00
1 джерело 285c7187fb
коміт 00693334f7
25 files changed
+861 -291

No files matched your search

+36
Переглянути файл
@@ -0,0 +1,36 @@
import { afterEach, expect, it, vi } from "vitest";
import { detectCandidateFonts } from "./candidateFonts";
afterEach(() => vi.restoreAllMocks());
it("probes the legacy list and classifies measured mono and proportional families", () => {
const ctx = {
font: "",
measureText(text: string) {
if (this.font.includes('"Menlo"')) return { width: 120 };
if (this.font.includes('"Georgia"')) return { width: text.startsWith("i") ? 60 : 180 };
return { width: 100 };
},
};
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(ctx as unknown as CanvasRenderingContext2D);
expect(detectCandidateFonts()).toEqual([
{ name: "Menlo", monospaced: true },
{ name: "Georgia", monospaced: false },
]);
});
it("returns no inferred families when Canvas is absent", () => {
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
expect(detectCandidateFonts()).toEqual([]);
});
it("contains failures from Canvas and never claims candidates are installed", () => {
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => {
throw new Error("disabled");
});
expect(detectCandidateFonts()).toEqual([]);
});
it("does not treat invalid measurements as available fonts", () => {
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({
font: "",
measureText: () => ({ width: Number.NaN }),
} as unknown as CanvasRenderingContext2D);
expect(detectCandidateFonts()).toEqual([]);
});
+74
Переглянути файл
@@ -0,0 +1,74 @@
/**
* Bounded legacy fallback, never the primary desktop catalog. Width comparisons
* can miss installed families and are only hints; they say nothing about coverage.
*/
import type { FontFamily } from "@/bindings";
import { monoFontFamily } from "./fonts";
// Keep the former editor's 31 candidates for recovery, not as an installed-font whitelist.
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",
];
export function detectCandidateFonts(): FontFamily[] {
try {
if (typeof document === "undefined") return [];
const ctx = document.createElement("canvas").getContext("2d");
if (!ctx) return [];
const width = (font: string, text: string) => {
ctx.font = `32px ${font}`;
return ctx.measureText(text).width;
};
const sample = "mmmmmmmmmmlli1WQ@";
const out: FontFamily[] = [];
for (const name of CANDIDATES) {
const present = ["monospace", "serif", "sans-serif"].some((base) => {
const measured = width(monoFontFamily(name, "", base), sample);
const baseline = width(base, sample);
return Number.isFinite(measured) && Number.isFinite(baseline) && measured !== baseline;
});
if (!present) continue;
const narrow = width(monoFontFamily(name, "", "monospace"), "iiiiiiiiii");
const wide = width(monoFontFamily(name, "", "monospace"), "WWWWWWWWWW");
out.push({
name,
monospaced: Number.isFinite(narrow) && Number.isFinite(wide) && Math.abs(narrow - wide) < 0.5,
});
}
return out;
} catch {
// Canvas can be absent or disabled in browser development / hardened webviews.
// Never mark unmeasurable candidates as installed. The service adds bundled faces.
return [];
}
}
+12 -1
Переглянути файл
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { monoFontFamily, uiFontFamily } from "./fonts";
import { monoFontFamily, quoteFontFamily, uiFontFamily } from "./fonts";
const BASE = "var(--font-mono)";
@@ -10,6 +10,10 @@ describe("monoFontFamily", () => {
["", "", `"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}`],
["Inter", "", `"Inter Variable", "Inter", ${BASE}`],
["Fira Code", "Inter", `"Fira Code", "Inter Variable", "Inter", ${BASE}`],
["Inter", "JetBrains Mono", `"Inter Variable", "Inter", "JetBrains Mono Variable", "JetBrains Mono", ${BASE}`],
["inter", "jetbrains mono", `"Inter Variable", "Inter", "JetBrains Mono Variable", "JetBrains Mono", ${BASE}`],
["Fira Code", "", `"Fira Code", ${BASE}`],
['"Cascadia Code"', " Microsoft YaHei ", `"Cascadia Code", "Microsoft YaHei", ${BASE}`],
])("%j + fallback %j", (family, fallback, expected) => {
@@ -23,6 +27,13 @@ describe("monoFontFamily", () => {
});
});
it("escapes CSS special characters without turning a family into a list", () => {
expect(quoteFontFamily('A"B\\C,D')).toBe('"A\\22 B\\5c C,D"');
expect(quoteFontFamily("A\nB")).toBe('"A\\a B"');
expect(uiFontFamily("JetBrains Mono")).toContain('"JetBrains Mono Variable"');
expect(uiFontFamily("Inter")).toContain('"Inter Variable"');
});
describe("uiFontFamily", () => {
it.each([
["Inter", '"Inter Variable", "Inter", system-ui, sans-serif'],
+27 -20
Переглянути файл
@@ -7,10 +7,26 @@
*/
export const BUNDLED_MONO_FAMILY = "JetBrains Mono";
const BUNDLED_MONO_FACES = ['"JetBrains Mono Variable"', '"JetBrains Mono"'];
export const BUNDLED_UI_FAMILY = "Inter";
const BUNDLED_FACES: ReadonlyMap<string, readonly string[]> = new Map([
[BUNDLED_MONO_FAMILY.toLowerCase(), ['"JetBrains Mono Variable"', '"JetBrains Mono"']],
[BUNDLED_UI_FAMILY.toLowerCase(), ['"Inter Variable"', '"Inter"']],
]);
const MONO_STACK_FALLBACK = '"JetBrains Mono Variable", "JetBrains Mono", ui-monospace, Menlo, monospace';
const quote = (f: string) => `"${f.replace(/"/g, "").trim()}"`;
/** Quote one CSS family safely (including quotes, backslashes and control characters). */
export const quoteFontFamily = (family: string) =>
// oxlint-disable-next-line no-control-regex -- CSS string escaping must cover controls
`"${family.trim().replace(/["\\\x00-\x1f\x7f]/g, (char) => `\\${char.codePointAt(0)!.toString(16)} `)}"`;
// Preserve compatibility with previously saved names wrapped in CSS quotes.
const familyName = (family: string) => family.trim().replace(/^"(.*)"$/, "$1");
/** One resolver for primary, fallback and UI families, matching the bundled CSS registrations. */
function familyFaces(family: string): readonly string[] {
const name = familyName(family);
return BUNDLED_FACES.get(name.toLowerCase()) ?? [quoteFontFamily(name)];
}
/** The `--font-mono` stack, resolved (canvas / xterm can't use `var()`). */
export function monoStack(): string {
@@ -24,24 +40,10 @@ export function monoStack(): string {
* 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(", ");
}
export const BUNDLED_UI_FAMILY = "Inter";
const BUNDLED_UI_FACES = ['"Inter Variable"', '"Inter"'];
/**
* The `--font-ui` stack for a custom UI font. The bundled families map to their
* bundled faces, like `monoFontFamily`, so they render without a system install.
*/
export function uiFontFamily(family: string): string {
const name = family.replace(/"/g, "").trim();
const head =
name === BUNDLED_UI_FAMILY ? BUNDLED_UI_FACES : name === BUNDLED_MONO_FAMILY ? BUNDLED_MONO_FACES : [quote(name)];
return [...head, "system-ui", "sans-serif"].join(", ");
const name = familyName(family ?? "");
const fb = familyName(fallback);
const head = familyFaces(name || BUNDLED_MONO_FAMILY);
return [...head, ...(fb ? familyFaces(fb) : []), base].join(", ");
}
/**
@@ -57,3 +59,8 @@ export async function fontsLoaded(family: string, size: number): Promise<void> {
/* an unparsable list measures with whatever is available */
}
}
/** Bundled aliases also apply when selected for the interface. */
export function uiFontFamily(family: string): string {
return [...familyFaces(family), "system-ui", "sans-serif"].join(", ");
}
+9 -1
Переглянути файл
@@ -4755,5 +4755,13 @@
"Target": "目标",
"Directory, file or test id; empty runs the whole suite": "目录、文件或测试 ID;留空则运行整个测试套件",
"pytest options": "pytest 选项",
"e.g. -x -q --tb=short": "例如 -x -q --tb=short"
"e.g. -x -q --tb=short": "例如 -x -q --tb=short",
"Monospaced": "等宽",
"Proportional": "非等宽",
"Loading system fonts…": "正在加载系统字体…",
"Refresh system fonts": "刷新系统字体",
"No matching fonts. Enter a family name manually.": "没有匹配的字体。可手动输入字体家族名称。",
"This name was not found on this desktop. The saved preference is kept.": "此桌面未发现该字体,已保留保存的设置。",
"No system fonts were returned. Bundled fonts and manual input are available.": "未发现系统字体。可选择内置字体或手动输入。",
"Type a family name or search installed fonts.": "输入字体家族名称或搜索已安装字体。"
}
+9 -1
Переглянути файл
@@ -18,10 +18,18 @@ describe("remote invoke", () => {
});
it("routes served commands, keeps opening / closing local and sets global settings on both sides", () => {
const served = new Set(["vcs_add_to_ignore", "fs_read_dir", "open_project", "close_project", "settings_set"]);
const served = new Set([
"vcs_add_to_ignore",
"fs_read_dir",
"open_project",
"close_project",
"settings_set",
"system_font_families",
]);
expect(remoteRouting("vcs_add_to_ignore", { paths: ["/remote/file"], destination: "localExclude" }, served)).toBe(
"remote",
);
expect(remoteRouting("system_font_families", { refresh: true }, served)).toBe("local");
expect(remoteRouting("fs_read_dir", { path: "/a" }, served)).toBe("remote");
expect(remoteRouting("window_open_settings", {}, served)).toBe("local");
expect(remoteRouting("open_project", { path: "/a" }, served)).toBe("local");
+1
Переглянути файл
@@ -55,6 +55,7 @@ async function remoteInvoke(cmd: string, args?: InvokeArgs, options?: InvokeOpti
* the server's language servers and the local windows read.
*/
export function remoteRouting(cmd: string, args: InvokeArgs | undefined, served: ReadonlySet<string>) {
if (cmd === "system_font_families") return "local";
if (!served.has(cmd) || cmd === "open_project" || cmd === "close_project") return "local";
if (cmd === "settings_set" && (args as { scope?: string } | undefined)?.scope === "global") return "both";
return "remote";
+86 -26
Переглянути файл
@@ -1,32 +1,92 @@
import { describe, expect, it } from "vitest";
import { fontNames } from "./systemFonts";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { native, discover, detect } = vi.hoisted(() => ({
native: vi.fn(() => true),
discover: vi.fn(),
detect: vi.fn(),
}));
vi.mock("./candidateFonts", () => ({ detectCandidateFonts: detect }));
vi.mock("@tauri-apps/api/core", () => ({ isTauri: native }));
vi.mock("@/bindings", () => ({ commands: { systemFontFamilies: discover } }));
import { bundledFonts, loadSystemFonts, useSystemFontStore } from "./systemFonts";
const fonts = [
{ name: "menlo", monospaced: true },
{ name: "Arial", monospaced: false },
{ name: "Courier New", monospaced: true },
{ name: "Ångström Sans", monospaced: false },
];
describe("fontNames", () => {
it("sorts installed families case- and accent-insensitively", () => {
expect(fontNames(fonts)).toEqual(["Ångström Sans", "Arial", "Courier New", "menlo"]);
});
it("filters to monospaced families", () => {
expect(fontNames(fonts, { monospacedOnly: true })).toEqual(["Courier New", "menlo"]);
});
it("always includes the extra names, spelled as given, without duplicates", () => {
expect(fontNames(fonts, { monospacedOnly: true, include: ["JetBrains Mono", "Menlo", "Arial", " "] })).toEqual([
"Arial",
"Courier New",
beforeEach(() => {
native.mockReturnValue(true);
discover.mockReset();
detect.mockReset().mockReturnValue([{ name: "Menlo", monospaced: true }]);
useSystemFontStore.setState({ fonts: bundledFonts, status: "idle", source: "bundled" });
});
describe("local font discovery", () => {
it("shares an in-flight request, caches families outside the old list and explicitly refreshes", async () => {
let resolve!: (fonts: unknown) => void;
discover.mockReturnValue(
new Promise((done) => {
resolve = done;
}),
);
const first = loadSystemFonts();
expect(useSystemFontStore.getState().status).toBe("loading");
expect(loadSystemFonts()).toBe(first);
await Promise.resolve();
resolve({
status: "ok",
data: [
{ name: "Maple Mono NF", monospaced: true },
{ name: "maple mono nf", monospaced: true },
],
});
await first;
expect(useSystemFontStore.getState().fonts.map((f) => f.name)).toEqual([
"Inter",
"JetBrains Mono",
"Menlo",
"maple mono nf",
]);
await loadSystemFonts();
expect(discover).toHaveBeenCalledTimes(1);
discover.mockResolvedValue({ status: "ok", data: [] });
await loadSystemFonts(true);
expect(discover).toHaveBeenLastCalledWith(true);
expect(useSystemFontStore.getState()).toMatchObject({ status: "empty", source: "native" });
expect(detect).not.toHaveBeenCalled();
expect(useSystemFontStore.getState().fonts).toEqual([bundledFonts[1], bundledFonts[0]]);
});
it("keeps the current family when nothing is installed", () => {
expect(fontNames([], { include: ["Fira Code"] })).toEqual(["Fira Code"]);
it("reports errors without losing suggestions, then retries", async () => {
discover.mockResolvedValueOnce({ status: "error", error: { kind: "unavailable", message: "Unavailable" } });
await loadSystemFonts();
expect(useSystemFontStore.getState()).toMatchObject({ status: "error", source: "limited" });
expect(useSystemFontStore.getState().fonts.some((f) => f.name === "Menlo")).toBe(true);
discover.mockResolvedValue({ status: "ok", data: [{ name: "思源宋体", monospaced: false }] });
await loadSystemFonts(true);
expect(useSystemFontStore.getState()).toMatchObject({ status: "ready", source: "native" });
expect(useSystemFontStore.getState().fonts.some((f) => f.name === "Menlo")).toBe(false);
expect(useSystemFontStore.getState().fonts.some((f) => f.name === "思源宋体")).toBe(true);
});
it("does not invoke desktop APIs in browser development", async () => {
native.mockReturnValue(false);
await loadSystemFonts();
expect(discover).not.toHaveBeenCalled();
expect(detect).toHaveBeenCalledOnce();
expect(useSystemFontStore.getState().status).toBe("browser");
});
});
it.each([{ fonts: [] }, { fonts: [{ name: "Outside Legacy List", monospaced: true }] }])(
"retains successful native catalogs, including empty ones, after refresh failure: %j",
async ({ fonts }) => {
discover.mockResolvedValueOnce({ status: "ok", data: fonts });
await loadSystemFonts();
const previous = useSystemFontStore.getState().fonts;
discover.mockRejectedValueOnce(new Error("native unavailable"));
await loadSystemFonts(true);
expect(useSystemFontStore.getState()).toMatchObject({ status: "error", source: "native", fonts: previous });
expect(detect).not.toHaveBeenCalled();
},
);
it("keeps bundled/manual recovery usable when candidate probing also fails", async () => {
discover.mockRejectedValue(new Error("native unavailable"));
detect.mockImplementation(() => {
throw new Error("canvas unavailable");
});
await loadSystemFonts();
expect(useSystemFontStore.getState()).toMatchObject({ status: "error", source: "limited" });
expect(useSystemFontStore.getState().fonts.map((font) => font.name)).toEqual(["Inter", "JetBrains Mono"]);
});
+66 -51
Переглянути файл
@@ -1,57 +1,72 @@
/**
* The font families installed on this machine (`system_font_families`: CoreText,
* DirectWrite or fontconfig), for every font picker. Read once per window; the
* backend caches the list for the run.
*/
import { useEffect, useState } from "react";
import { call, commands, type FontFamily } from "./ipc";
/** Shared suggestions only: editor, UI and terminal preferences remain independent. */
import { useEffect } from "react";
import { detectCandidateFonts } from "./candidateFonts";
import { BUNDLED_MONO_FAMILY, BUNDLED_UI_FAMILY } from "./fonts";
import { create } from "zustand";
import { isTauri } from "@tauri-apps/api/core";
import { call, commands, type FontFamily } from "@/platform/ipc";
let cached: FontFamily[] | null = null;
let pending: Promise<FontFamily[]> | null = null;
/** The installed families; empty when the font system can't be read. */
export function loadSystemFonts(): Promise<FontFamily[]> {
pending ??= call(commands.systemFontFamilies())
.catch(() => [] as FontFamily[])
.then((list) => (cached = list));
export type FontStatus = "idle" | "loading" | "ready" | "empty" | "error" | "browser";
export const bundledFonts: FontFamily[] = [
{ name: BUNDLED_MONO_FAMILY, monospaced: true },
{ name: BUNDLED_UI_FAMILY, monospaced: false },
];
export function mergeFontFamilies(fonts: FontFamily[]): FontFamily[] {
const unique = new Map<string, FontFamily>();
for (const font of [...fonts, ...bundledFonts]) {
if (font.name.trim()) unique.set(font.name.toLocaleLowerCase(), font);
}
return [...unique.values()].sort((a, b) => a.name.localeCompare(b.name));
}
export type FontSource = "bundled" | "limited" | "native";
export const useSystemFontStore = create<{ fonts: FontFamily[]; status: FontStatus; source: FontSource }>(() => ({
fonts: bundledFonts,
status: "idle",
source: "bundled",
}));
let pending: Promise<void> | null = null;
function fallback(status: "error" | "browser") {
// Even an empty successful native catalog is authoritative. A failed refresh
// must never replace it with the old, necessarily incomplete candidate list.
if (useSystemFontStore.getState().source === "native") {
useSystemFontStore.setState({ status });
return;
}
let candidates: FontFamily[] = [];
try {
candidates = detectCandidateFonts();
} catch {
// A failing detector must not break settings or discard manual preferences.
}
useSystemFontStore.setState({ fonts: mergeFontFamilies(candidates), source: "limited", status });
}
export function loadSystemFonts(refresh = false): Promise<void> {
if (pending) return pending;
if (!refresh && useSystemFontStore.getState().status !== "idle") return Promise.resolve();
if (!isTauri()) {
fallback("browser");
return Promise.resolve();
}
useSystemFontStore.setState({ status: "loading" });
pending = Promise.resolve()
.then(() => call(commands.systemFontFamilies(refresh)))
.then((fonts) =>
useSystemFontStore.setState({
fonts: mergeFontFamilies(fonts),
status: fonts.length ? "ready" : "empty",
source: "native",
}),
)
.catch(() => fallback("error"))
.finally(() => {
pending = null;
});
return pending;
}
/** The installed families, empty until the first read resolves. */
export function useSystemFonts(): FontFamily[] {
const [fonts, setFonts] = useState<FontFamily[]>(() => cached ?? []);
export function useSystemFonts() {
const state = useSystemFontStore();
useEffect(() => {
if (cached) return;
let live = true;
void loadSystemFonts().then((list) => live && setFonts(list));
return () => {
live = false;
};
void loadSystemFonts();
}, []);
return fonts;
}
/**
* Picker entries: the installed families (only monospaced ones when asked),
* plus `include` (the bundled faces, and the current value so a family that
* isn't installed here still shows as selected), deduplicated and sorted.
*/
export function fontNames(
fonts: readonly FontFamily[],
{ monospacedOnly = false, include = [] }: { monospacedOnly?: boolean; include?: readonly string[] } = {},
): string[] {
const seen = new Map<string, string>();
const add = (name: string) => {
const n = name.trim();
if (n && !seen.has(n.toLowerCase())) seen.set(n.toLowerCase(), n);
};
include.forEach(add);
for (const f of fonts) if (!monospacedOnly || f.monospaced) add(f.name);
return [...seen.values()].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
}
/** Test hook: forget the cached list. */
export function resetSystemFontsForTests(): void {
cached = null;
pending = null;
return state;
}