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

+11 -1
Переглянути файл
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { monoFontFamily } from "./fonts";
import { monoFontFamily, uiFontFamily } from "./fonts";
const BASE = "var(--font-mono)";
@@ -22,3 +22,13 @@ describe("monoFontFamily", () => {
);
});
});
describe("uiFontFamily", () => {
it.each([
["Inter", '"Inter Variable", "Inter", system-ui, sans-serif'],
["JetBrains Mono", '"JetBrains Mono Variable", "JetBrains Mono", system-ui, sans-serif'],
[' "PingFang SC" ', '"PingFang SC", system-ui, sans-serif'],
])("%j", (family, expected) => {
expect(uiFontFamily(family)).toBe(expected);
});
});
+14
Переглянути файл
@@ -30,6 +30,20 @@ export function monoFontFamily(family: string | undefined, fallback = "", base =
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(", ");
}
/**
* 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.
+2
Переглянути файл
@@ -2038,6 +2038,8 @@
"Template name": "模板名称",
"Template text": "模板文本",
"Terminal": "终端",
"Terminal fallback font": "终端备用字体",
"Terminal font": "终端字体",
"Terminal font size": "终端字体大小",
"Terminal line height": "终端行高",
"Terminal sessions": "终端会话",
+32
Переглянути файл
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { fontNames } 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",
"JetBrains Mono",
"Menlo",
]);
});
it("keeps the current family when nothing is installed", () => {
expect(fontNames([], { include: ["Fira Code"] })).toEqual(["Fira Code"]);
});
});
+57
Переглянути файл
@@ -0,0 +1,57 @@
/**
* 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";
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));
return pending;
}
/** The installed families, empty until the first read resolves. */
export function useSystemFonts(): FontFamily[] {
const [fonts, setFonts] = useState<FontFamily[]>(() => cached ?? []);
useEffect(() => {
if (cached) return;
let live = true;
void loadSystemFonts().then((list) => live && setFonts(list));
return () => {
live = false;
};
}, []);
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;
}