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 <3931591+bghtyu@users.noreply.github.com>
This commit is contained in:
Walter Wang
2026-10-29 23:35:33 +08:00
committed by GitHub
co-authored by bghtyu
parent 285c7187fb
commit 00693334f7
25 changed files with 861 additions and 291 deletions
+57 -15
View File
@@ -4,13 +4,14 @@
//! Each platform asks the font system the WebView itself resolves CSS
//! `font-family` names against, so every listed name renders: CoreText on
//! macOS, DirectWrite on Windows, fontconfig (`fc-list`) on Linux and the BSDs.
//! The list is read once per run and cached; the first read can take a few
//! hundred milliseconds, so callers run it off the UI and command threads.
//! The list is read on first use and cached until explicitly refreshed; the
//! query can take a few hundred milliseconds, so callers run it off the UI and
//! command threads.
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::BTreeMap;
use std::sync::OnceLock;
use std::sync::{Arc, Mutex};
/// One installed family. `monospaced` is the font system's own flag (fixed
/// pitch; on Linux also fontconfig's dual-width CJK monospace).
@@ -21,10 +22,23 @@ pub struct FontFamily {
pub monospaced: bool,
}
/// The installed families, sorted case-insensitively, read on first use.
pub fn families() -> &'static [FontFamily] {
static CACHE: OnceLock<Vec<FontFamily>> = OnceLock::new();
CACHE.get_or_init(|| normalize(imp::list()))
type Cache = Mutex<Option<Arc<[FontFamily]>>>;
/// The installed families, sorted case-insensitively, read on first use and
/// re-queried when `refresh` is true.
pub fn families(refresh: bool) -> Arc<[FontFamily]> {
static CACHE: Cache = Mutex::new(None);
cached(&CACHE, refresh, imp::list)
}
fn cached(cache: &Cache, refresh: bool, list: impl FnOnce(bool) -> Vec<(String, bool)>) -> Arc<[FontFamily]> {
let mut guard = cache.lock().unwrap_or_else(|e| e.into_inner());
if !refresh && let Some(families) = guard.as_ref() {
return Arc::clone(families);
}
let families: Arc<[FontFamily]> = normalize(list(refresh)).into();
*guard = Some(Arc::clone(&families));
families
}
/// Merges the per-face records a font system reports into one entry per
@@ -79,7 +93,7 @@ mod imp {
};
/// Every face CoreText makes available to this process, as (family, fixed pitch).
pub(crate) fn list() -> Vec<(String, bool)> {
pub(crate) fn list(_refresh: bool) -> Vec<(String, bool)> {
// SAFETY: CoreText's documented types: the matching descriptors array
// holds CTFontDescriptors and the traits dictionary maps CFString keys.
unsafe {
@@ -116,15 +130,15 @@ mod imp {
/// The system font collection's families, named in English when the
/// family has an English name (what CSS matches), else its first name.
pub(crate) fn list() -> Vec<(String, bool)> {
pub(crate) fn list(refresh: bool) -> Vec<(String, bool)> {
// SAFETY: plain DirectWrite COM calls on interfaces we own.
unsafe { read() }.unwrap_or_default()
unsafe { read(refresh) }.unwrap_or_default()
}
unsafe fn read() -> windows::core::Result<Vec<(String, bool)>> {
unsafe fn read(refresh: bool) -> windows::core::Result<Vec<(String, bool)>> {
let factory: IDWriteFactory = unsafe { DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED) }?;
let mut collection = None;
unsafe { factory.GetSystemFontCollection(&mut collection, false) }?;
unsafe { factory.GetSystemFontCollection(&mut collection, refresh) }?;
let Some(collection) = collection else {
return Ok(Vec::new());
};
@@ -171,7 +185,7 @@ mod imp {
/// fontconfig's view, which WebKitGTK resolves names with. Empty when
/// `fc-list` isn't installed.
pub(crate) fn list() -> Vec<(String, bool)> {
pub(crate) fn list(_refresh: bool) -> Vec<(String, bool)> {
Command::new("fc-list")
.args(["-f", "%{family[0]}\t%{spacing}\n"])
.stdin(Stdio::null())
@@ -186,7 +200,7 @@ mod imp {
#[cfg(not(any(unix, windows)))]
mod imp {
pub(crate) fn list() -> Vec<(String, bool)> {
pub(crate) fn list(_refresh: bool) -> Vec<(String, bool)> {
Vec::new()
}
}
@@ -194,6 +208,7 @@ mod imp {
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
fn fam(name: &str, monospaced: bool) -> FontFamily {
FontFamily { name: name.into(), monospaced }
@@ -243,12 +258,38 @@ mod tests {
);
}
#[test]
fn caches_until_refresh_requested() {
let cache = Mutex::new(None);
let calls = AtomicUsize::new(0);
let lister = |refresh: bool| {
let n = calls.fetch_add(1, Ordering::SeqCst);
vec![(format!("Font {n} (refresh={refresh})"), n.is_multiple_of(2))]
};
let first = cached(&cache, false, lister);
assert_eq!(&*first, &[fam("Font 0 (refresh=false)", true)]);
assert_eq!(calls.load(Ordering::SeqCst), 1);
let second = cached(&cache, false, lister);
assert!(Arc::ptr_eq(&first, &second));
assert_eq!(calls.load(Ordering::SeqCst), 1);
let refreshed = cached(&cache, true, lister);
assert_eq!(&*refreshed, &[fam("Font 1 (refresh=true)", false)]);
assert_eq!(calls.load(Ordering::SeqCst), 2);
let after_refresh = cached(&cache, false, lister);
assert!(Arc::ptr_eq(&refreshed, &after_refresh));
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
/// The real font system: every desktop OS ships some fonts, at least one
/// of them monospaced, and the list comes back sorted without duplicates.
#[cfg(any(target_os = "macos", windows))]
#[test]
fn lists_installed_families() {
let list = families();
let list = families(false);
assert!(list.len() > 20, "only {} families", list.len());
assert!(list.iter().any(|f| f.monospaced));
assert!(list.iter().any(|f| !f.monospaced));
@@ -260,5 +301,6 @@ mod tests {
assert!(menlo.monospaced);
assert!(list.iter().any(|f| f.name == "Helvetica Neue" && !f.monospaced));
}
assert_eq!(&*families(true), &*list);
}
}
+33
View File
@@ -41,3 +41,36 @@ test("Language Servers page installs a tool with progress (in-memory installer)"
await list.locator('[data-tool="solargraph"]').click();
await expect(dialog.getByLabel("solargraph details")).toContainText("gem install solargraph");
});
test("font picker supports manual families and CJK fallback in browser development", async ({ workbench: page }) => {
await page.keyboard.press("Meta+,");
const dialog = page.getByRole("dialog", { name: "Settings" });
await dialog.getByRole("textbox", { name: "Search settings" }).fill("Typography Settings");
await dialog.getByRole("treeitem", { name: "Font", exact: true }).click();
const family = dialog.getByRole("combobox", { name: "Font", exact: true });
await family.click();
await expect(page.getByRole("option", { name: /JetBrains Mono/ })).toBeVisible();
await family.fill("Maple Mono NF");
await family.press("Enter");
await expect(family).toHaveValue("Maple Mono NF");
// The shipped face is Inter Variable, not a system-installed family named Inter.
const interLoaded = await page.evaluate(async () => {
const faces = await document.fonts.load('14px "Inter Variable"');
return faces.some((face) => face.family.includes("Inter Variable") && face.status === "loaded");
});
expect(interLoaded).toBe(true);
await family.fill("Inter");
await family.press("Enter");
await expect(dialog.getByLabel("Font preview")).toHaveCSS("font-family", /^"Inter Variable",/);
const fallback = dialog.getByRole("combobox", { name: "Fallback font", exact: true });
await family.fill("Maple Mono NF");
await family.press("Enter");
await fallback.fill("Inter");
await fallback.press("Enter");
await expect(dialog.getByLabel("Font preview")).toHaveCSS("font-family", /Maple Mono NF.*Inter Variable/);
await fallback.fill("思源宋体");
await fallback.press("Enter");
await expect(dialog.getByLabel("Font preview")).toHaveCSS("font-family", /思源宋体/);
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(dialog).toBeHidden();
});
+5 -4
View File
@@ -1,14 +1,15 @@
//! Font families installed on this machine, for the font pickers in Settings.
//! Enumerates fonts on the local desktop, including in remote-development windows.
use ide_core::{IdeError, IdeResult};
use ide_fonts::FontFamily;
/// Every installed family, sorted, each flagged monospaced or not. Read once
/// per run (off the command threads) and cached.
/// Every installed family, sorted, each flagged monospaced or not. Read on
/// first use (off the command threads), cached, and re-queried on `refresh`.
#[tauri::command]
#[specta::specta]
pub async fn system_font_families() -> IdeResult<Vec<FontFamily>> {
tauri::async_runtime::spawn_blocking(|| ide_fonts::families().to_vec())
pub async fn system_font_families(refresh: bool) -> IdeResult<Vec<FontFamily>> {
tauri::async_runtime::spawn_blocking(move || ide_fonts::families(refresh).to_vec())
.await
.map_err(|e| IdeError::internal(e.to_string()))
}
+3 -3
View File
@@ -153,10 +153,10 @@ export const commands = {
*/
clipboardSetFileList: (paths: string[]) => typedError<boolean, IdeError>(__TAURI_INVOKE("clipboard_set_file_list", { paths })),
/**
* Every installed family, sorted, each flagged monospaced or not. Read once
* per run (off the command threads) and cached.
* Every installed family, sorted, each flagged monospaced or not. Read on
* first use (off the command threads), cached, and re-queried on `refresh`.
*/
systemFontFamilies: () => typedError<FontFamily[], IdeError>(__TAURI_INVOKE("system_font_families")),
systemFontFamilies: (refresh: boolean) => typedError<FontFamily[], IdeError>(__TAURI_INVOKE("system_font_families", { refresh })),
/**
* Opens a document. Async: reading and decoding a large file must not block
* the main thread (synchronous commands run on it).
@@ -108,6 +108,58 @@ describe("Settings dialog", () => {
expect(within(dialog).getByRole("button", { name: "Apply" })).toBeDisabled();
});
it("stages manual editor and proportional CJK fallback names and applies them", async () => {
const dialog = await openDialog("editor.font");
fireEvent.change(within(dialog).getByRole("combobox", { name: "Font" }), { target: { value: "Maple Mono NF" } });
fireEvent.change(within(dialog).getByRole("combobox", { name: "Fallback font" }), {
target: { value: "思源宋体" },
});
expect(settingsSet).not.toHaveBeenCalled();
expect(within(dialog).getByLabelText("Font preview").style.fontFamily).toContain("思源宋体");
await act(async () => fireEvent.click(within(dialog).getByRole("button", { name: "Apply" })));
expect(settingsSet.mock.calls).toEqual([
["global", "editor.fallbackFont", '"思源宋体"'],
["global", "editor.fontFamily", '"Maple Mono NF"'],
]);
});
it("cancels manual font edits without changing saved preferences", async () => {
const dialog = await openDialog("editor.font");
fireEvent.change(within(dialog).getByRole("combobox", { name: "Font" }), { target: { value: "Unsaved Font" } });
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
expect(settingsSet).not.toHaveBeenCalled();
expect(useSettingsDialog.getState().open).toBe(false);
expect(useProject.getState().settings?.editor?.fontFamily).toBe("JetBrains Mono");
});
it("keeps global and project font edits separate when switching scope", async () => {
useSettingsDialog.setState({ scopes: {} });
const global = setAt(structuredClone(DEFAULT_SETTINGS) as Settings, "editor.fontFamily", "Global Font");
current = setAt(global, "editor.fontFamily", "Project Font");
settingsGetGlobal.mockResolvedValue(global);
useProject.setState({
settings: current,
project: { root: "/synthetic", name: "synthetic", settings: current } as never,
});
const dialog = await openDialog("editor.font");
const font = () => within(dialog).getByRole("combobox", { name: "Font" });
fireEvent.click(within(dialog).getByRole("radio", { name: "This project" }));
expect(font()).toHaveValue("Project Font");
fireEvent.change(font(), { target: { value: "Project Maple" } });
fireEvent.click(within(dialog).getByRole("radio", { name: "Global" }));
expect(font()).toHaveValue("Global Font");
fireEvent.change(font(), { target: { value: "Global Maple" } });
fireEvent.click(within(dialog).getByRole("radio", { name: "This project" }));
expect(font()).toHaveValue("Project Maple");
await act(async () => fireEvent.click(within(dialog).getByRole("button", { name: "Apply" })));
expect(settingsSet.mock.calls).toEqual(
expect.arrayContaining([
["project", "editor.fontFamily", '"Project Maple"'],
["global", "editor.fontFamily", '"Global Maple"'],
]),
);
});
it("Typography Settings weights preview live and apply as editor settings", async () => {
const dialog = await openDialog("editor.font");
expect(within(dialog).getByRole("button", { name: "Main font weight" })).toHaveTextContent("Regular");
@@ -1,108 +0,0 @@
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);
});
});
@@ -0,0 +1,40 @@
import { afterEach, expect, it, vi } from "vitest";
const { handlers, settingsGet } = vi.hoisted(() => ({ handlers: new Map<string, () => void>(), settingsGet: vi.fn() }));
vi.mock("@/platform/ipc", async (original) => {
const real = await original<typeof import("@/platform/ipc")>();
return {
...real,
isTauri: () => true,
commands: { ...real.commands, settingsGet },
onIdeEvent: (event: string, handler: () => void) => {
handlers.set(event, handler);
return () => handlers.delete(event);
},
};
});
import { useProject } from "@/platform/project";
import { trackRuntimeSettings } from "../runtime";
import { DEFAULT_SETTINGS, setAt } from "../model/schema";
afterEach(() => vi.useRealTimers());
it("reloads independent font preferences after standalone settings-window Apply events", async () => {
vi.useFakeTimers();
useProject.setState({ settings: structuredClone(DEFAULT_SETTINGS), project: null });
const stop = trackRuntimeSettings();
try {
let next = setAt(structuredClone(DEFAULT_SETTINGS), "editor.fontFamily", "Maple Mono NF");
next = setAt(next, "appearance.useCustomFont", true);
next = setAt(next, "appearance.uiFontFamily", "思源黑体");
next = setAt(next, "terminal.fontFamily", "Independent Terminal");
settingsGet.mockResolvedValue(next);
handlers.get("settingsChanged")!();
handlers.get("settingsChanged")!();
await vi.advanceTimersByTimeAsync(40);
expect(settingsGet).toHaveBeenCalledOnce();
expect(useProject.getState().settings?.editor?.fontFamily).toBe("Maple Mono NF");
expect(useProject.getState().settings?.terminal?.fontFamily).toBe("Independent Terminal");
expect(document.documentElement.style.getPropertyValue("--font-ui")).toContain("思源黑体");
} finally {
stop();
}
});
@@ -0,0 +1,35 @@
.picker {
display: inline-flex;
flex-direction: column;
gap: 4px;
max-width: 420px;
min-width: 200px;
}
.controls {
display: flex;
gap: 6px;
}
.controls input {
min-width: 0;
flex: 1;
}
.hint {
color: var(--fg-muted);
font-size: 11px;
}
.list {
max-height: 260px;
overflow-y: auto;
min-width: 250px;
padding: 4px;
}
.option {
display: flex;
justify-content: space-between;
gap: 20px;
padding: 5px 8px;
cursor: default;
}
.option[aria-selected="true"] {
background: var(--bg-tree-selected);
}
@@ -0,0 +1,87 @@
import { beforeEach, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { useState } from "react";
import { FontPicker } from "./FontPicker";
import { bundledFonts, useSystemFontStore } from "@/platform/systemFonts";
beforeEach(() =>
useSystemFontStore.setState({
status: "ready",
source: "native",
fonts: [...bundledFonts, { name: "Maple Mono NF", monospaced: true }, { name: "思源宋体", monospaced: false }],
}),
);
function Harness({ monoOnly = false, initial = "Saved Missing Font" }) {
const [value, setValue] = useState(initial);
return <FontPicker label="Font" value={value} onChange={setValue} monoOnly={monoOnly} allowEmpty />;
}
it("keeps missing saved names, searches outside the old list, and accepts manual names", () => {
render(<Harness />);
const input = screen.getByRole("combobox");
expect(input).toHaveValue("Saved Missing Font");
expect(screen.getByRole("status")).toHaveTextContent("not found");
fireEvent.focus(input);
expect(screen.getByRole("option", { name: /JetBrains Mono/ })).toBeInTheDocument();
fireEvent.change(input, { target: { value: "maple" } });
expect(screen.getAllByRole("option")).toHaveLength(1);
fireEvent.keyDown(input, { key: "ArrowDown" });
fireEvent.keyDown(input, { key: "Enter" });
expect(input).toHaveValue("Maple Mono NF");
fireEvent.change(input, { target: { value: "My Custom Typeface" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(input).toHaveValue("My Custom Typeface");
});
it("filters only suggestions, permits CJK proportional fallback and empty fallback", () => {
const view = render(<Harness monoOnly />);
fireEvent.focus(screen.getByRole("combobox"));
expect(screen.queryByRole("option", { name: /思源宋体/ })).toBeNull();
view.rerender(<Harness monoOnly={false} />);
fireEvent.click(screen.getByRole("option", { name: /思源宋体/ }));
expect(screen.getByRole("combobox")).toHaveValue("思源宋体");
fireEvent.change(screen.getByRole("combobox"), { target: { value: "" } });
fireEvent.blur(screen.getByRole("combobox"));
expect(screen.getByRole("combobox")).toHaveValue("");
});
it("reflects external scope/saved-value changes and disables inherited selection", () => {
const change = vi.fn();
const view = render(<FontPicker label="Font" value="Global" onChange={change} />);
fireEvent.change(screen.getByRole("combobox"), { target: { value: "" } });
view.rerender(<FontPicker label="Font" value="Project" onChange={change} disabled />);
expect(screen.getByRole("combobox")).toHaveValue("Project");
expect(screen.getByRole("combobox")).toBeDisabled();
expect(change).not.toHaveBeenCalled();
});
it.each([
["loading", "Loading system fonts"],
["error", "Type a family name"],
["empty", "No system fonts"],
["browser", "Type a family name"],
] as const)("explains %s without declaring saved fonts missing", (status, message) => {
useSystemFontStore.setState({ status });
render(<Harness />);
expect(screen.getByRole("status")).toHaveTextContent(message);
expect(screen.getByRole("combobox")).toHaveValue("Saved Missing Font");
});
it("quietly keeps selections and manual input through fallback and native retry", () => {
useSystemFontStore.setState({ status: "error", source: "limited", fonts: bundledFonts });
render(<Harness />);
const input = screen.getByRole("combobox");
expect(input).toHaveValue("Saved Missing Font");
expect(screen.getByRole("status")).toHaveTextContent("Type a family name");
expect(screen.getByRole("button", { name: "Refresh system fonts" })).toBeEnabled();
fireEvent.change(input, { target: { value: "Manual CJK Family" } });
fireEvent.keyDown(input, { key: "Enter" });
act(() => useSystemFontStore.setState({ status: "loading" }));
expect(input).toHaveValue("Manual CJK Family");
act(() =>
useSystemFontStore.setState({
status: "ready",
source: "native",
fonts: [...bundledFonts, { name: "New Native Family", monospaced: false }],
}),
);
expect(input).toHaveValue("Manual CJK Family");
fireEvent.focus(input);
expect(screen.getByRole("option", { name: /New Native Family/ })).toBeInTheDocument();
});
@@ -0,0 +1,161 @@
import { useId, useRef, useState } from "react";
import { Button, Input, Popup, T } from "@/ui";
import { useTx } from "@/platform/i18n";
import { loadSystemFonts, useSystemFonts } from "@/platform/systemFonts";
import styles from "./FontPicker.module.css";
/** Editable family name, not a CSS list. Search never restricts manual input. */
export function FontPicker({
value,
onChange,
label,
id,
disabled,
monoOnly = false,
allowEmpty = false,
}: {
value: string;
onChange: (value: string) => void;
label: string;
id?: string;
disabled?: boolean;
monoOnly?: boolean;
allowEmpty?: boolean;
}) {
const tx = useTx();
const { fonts, status } = useSystemFonts();
const listId = useId();
const input = useRef<HTMLInputElement>(null);
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState<{ source: string; text: string } | null>(null);
const [active, setActive] = useState(-1);
const text = draft?.source === value ? draft.text : value;
const query = draft?.source === value ? text.trim().toLocaleLowerCase() : "";
const suggestions = fonts.filter((f) => (!monoOnly || f.monospaced) && f.name.toLocaleLowerCase().includes(query));
const known = fonts.some((f) => f.name.toLocaleLowerCase() === value.trim().toLocaleLowerCase());
const message =
status === "loading"
? "Loading system fonts…"
: status === "empty"
? "No system fonts were returned. Bundled fonts and manual input are available."
: value && !known && status === "ready"
? "This name was not found on this desktop. The saved preference is kept."
: "Type a family name or search installed fonts.";
const choose = (name: string) => {
onChange(name);
setDraft(null);
setOpen(false);
setActive(-1);
};
return (
<span className={styles.picker}>
<span className={styles.controls}>
<Input
ref={input}
id={id}
role="combobox"
aria-label={label}
aria-autocomplete="list"
aria-expanded={open && !disabled}
aria-controls={open ? listId : undefined}
aria-activedescendant={open && active >= 0 && suggestions[active] ? `${listId}-${active}` : undefined}
aria-describedby={`${listId}-status`}
value={text}
disabled={disabled}
placeholder={allowEmpty ? "None" : undefined}
onFocus={() => {
setOpen(true);
setActive(-1);
}}
onClick={() => setOpen(true)}
onBlur={() => {
setOpen(false);
setDraft(null);
}}
onChange={(e) => {
const next = e.target.value;
const valid = allowEmpty || !!next.trim();
setDraft({ source: valid ? next : value, text: next });
if (valid) onChange(next);
setActive(-1);
setOpen(true);
}}
onKeyDown={(e) => {
if (e.nativeEvent.isComposing) return;
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
setOpen(true);
const next = Math.max(0, Math.min(suggestions.length - 1, active + (e.key === "ArrowDown" ? 1 : -1)));
setActive(next);
document.getElementById(`${listId}-${next}`)?.scrollIntoView({ block: "nearest" });
} else if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
if (open && active >= 0 && suggestions[active]) choose(suggestions[active].name);
else if (allowEmpty || text.trim()) choose(text.trim());
} else if (e.key === "Escape" && open) {
e.preventDefault();
e.stopPropagation();
setOpen(false);
}
}}
/>
<Button
disabled={disabled || status === "loading"}
onClick={() => void loadSystemFonts(true)}
aria-label={tx("Refresh system fonts")}
>
<T>Refresh</T>
</Button>
</span>
<span id={`${listId}-status`} className={styles.hint} role="status">
{tx(message)}
</span>
<Popup
open={open && !disabled}
anchor={input}
onClose={() => setOpen(false)}
autoFocus={false}
restoreFocus={false}
matchWidth
role="presentation"
>
<div
id={listId}
role="listbox"
aria-label={tx(label)}
className={styles.list}
onMouseDown={(e) => e.preventDefault()}
>
{suggestions.map((font, index) => (
<div
key={font.name}
id={`${listId}-${index}`}
role="option"
aria-selected={active === index}
className={styles.option}
onMouseMove={() => setActive(index)}
onClick={() => choose(font.name)}
>
<span>{font.name}</span>
<span className={styles.hint}>
{tx(
font.name === "JetBrains Mono" || font.name === "Inter"
? "Bundled"
: font.monospaced
? "Monospaced"
: "Proportional",
)}
</span>
</div>
))}
{!suggestions.length && (
<span className={styles.hint}>
<T>No matching fonts. Enter a family name manually.</T>
</span>
)}
</div>
</Popup>
</span>
);
}
@@ -1,46 +0,0 @@
/**
* 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"]} />
);
}
+3 -7
View File
@@ -4,6 +4,8 @@
* Notifications. Every option is wired (see `appearanceRuntime`, workbench
* theme / tool windows / notifications, app title / windows, `platform/dateFormat`).
*/
import { FontPicker } from "../components/FontPicker";
import { useId, useRef, useState, type ReactNode } from "react";
import clsx from "clsx";
import { shortcutLabel } from "@/platform/keymap";
@@ -14,8 +16,6 @@ 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,
@@ -176,8 +176,6 @@ function ZoomRow() {
);
}
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");
@@ -187,9 +185,7 @@ function CustomFontRow() {
return (
<div className={clsx(styles.fontRow, matched && styles.matched)}>
<Checkbox checked={on} onChange={setOn} label={<SearchLabel text="Use custom font:" />} />
<span className={styles.fontInput}>
<FontSelect value={family} onChange={setFamily} bundled={UI_BUNDLED} disabled={!on} aria-label="UI font" />
</span>
<FontPicker label="UI font" disabled={!on} value={family} onChange={setFamily} />
<label className={clsx(styles.sizeLabel, !on && styles.disabled)} htmlFor={`${id}-size`}>
<SearchLabel text="Size:" />
</label>
+4 -3
View File
@@ -2,11 +2,12 @@
* Editor › Font: family (installed fonts, monospaced filter), size, line height,
* ligatures, Typography Settings (main / bold weight, fallback font), editable preview.
*/
import { FontPicker } from "../../components/FontPicker";
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";
@@ -22,7 +23,7 @@ function FontRow() {
<Row label="Font">
<span className={styles.fontFamily}>
<span className={styles.fontSelect}>
<FontSelect value={family} onChange={setFamily} monospacedOnly={monoOnly} aria-label="Font" />
<FontPicker value={family} onChange={setFamily} label="Font" monoOnly={monoOnly} />
</span>
<span className={matched ? styles.matched : undefined}>
<Checkbox
@@ -55,7 +56,7 @@ function FallbackRow() {
const [fallback, setFallback] = useSetting<string>("editor.fallbackFont");
return (
<Row label="Fallback font" hint="For symbols not supported by the main font">
<FontSelect value={fallback} onChange={setFallback} none aria-label="Fallback font" />
<FontPicker value={fallback} onChange={setFallback} label="Fallback font" allowEmpty />
</Row>
);
}
+6 -5
View File
@@ -3,6 +3,8 @@
* terminal feature, the editor save participants and the run feature
* (External Tools actions).
*/
import { FontPicker } from "../components/FontPicker";
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import clsx from "clsx";
import { commands, isTauri } from "@/platform/ipc";
@@ -14,7 +16,6 @@ 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";
@@ -248,12 +249,12 @@ function TerminalFont() {
<Indented>
<Row label="Font">
<span className={styles.fontFamily}>
<FontSelect
<FontPicker
value={family}
onChange={setFamily}
monospacedOnly={monoOnly}
monoOnly={monoOnly}
disabled={useEditor}
aria-label="Terminal font"
label="Terminal font"
/>
<span className={monoMatched ? styles.matched : undefined}>
<Checkbox
@@ -286,7 +287,7 @@ function TerminalFont() {
/>
</Row>
<Row label="Fallback font" hint="Used for characters the main font lacks.">
<FontSelect value={fallback} onChange={setFallback} none aria-label="Terminal fallback font" />
<FontPicker value={fallback} onChange={setFallback} allowEmpty label="Terminal fallback font" />
</Row>
<Row label="Column width">
<NumberInput
+44
View File
@@ -0,0 +1,44 @@
import { expect, it, vi } from "vitest";
import type { Terminal } from "@xterm/xterm";
import { remeasureWhenFontsLoad, terminalFont } from "./theme";
import { DEFAULT_SETTINGS } from "@/features/settings/model/schema";
it("remeasures and fits after normal/bold faces load, but ignores a stale selection", async () => {
let finish!: () => void;
const loaded = new Promise<void>((resolve) => {
finish = resolve;
});
const load = vi.fn(() => loaded);
const previous = Object.getOwnPropertyDescriptor(document, "fonts");
Object.defineProperty(document, "fonts", { configurable: true, value: { load } });
try {
const font = terminalFont(DEFAULT_SETTINGS.terminal, { fontFamily: "Maple Mono NF", fontSize: 14, lineHeight: 20 });
const updates: string[] = [];
let current = font.family;
const term = {
options: {
get fontFamily() {
return current;
},
set fontFamily(value: string) {
current = value;
updates.push(value);
},
},
} as unknown as Terminal;
const fit = vi.fn();
remeasureWhenFontsLoad(term, font, () => true, fit);
expect(load).toHaveBeenCalledTimes(2);
expect(fit).not.toHaveBeenCalled();
finish();
await vi.waitFor(() => expect(fit).toHaveBeenCalledOnce());
expect(updates).toEqual(["monospace", font.family]);
current = "Another Font";
remeasureWhenFontsLoad(term, font, () => true, fit);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(fit).toHaveBeenCalledOnce();
} finally {
if (previous) Object.defineProperty(document, "fonts", previous);
else Reflect.deleteProperty(document, "fonts");
}
});
+11
View File
@@ -298,6 +298,17 @@ describe("terminalFont", () => {
expect(font.family).not.toContain("var(");
});
it("resolves bundled Inter in inherited, independent and fallback selections", () => {
expect(terminalFont(t, { ...editor, fontFamily: "Inter" }, BASE).family).toMatch(/^"Inter Variable", "Inter",/);
expect(terminalFont({ ...t, useEditorFont: false, fontFamily: "Inter" }, editor, BASE).family).toMatch(
/^"Inter Variable", "Inter",/,
);
expect(terminalFont({ ...t, fallbackFontFamily: "Inter" }, editor, BASE).family).toContain(
'"Inter Variable", "Inter"',
);
expect(terminalFont(t, { ...editor, fallbackFont: "Inter" }, BASE).family).toContain('"Inter Variable", "Inter"');
});
it("prefers the terminal's fallback over the editor's", () => {
const font = terminalFont(
{ ...t, fallbackFontFamily: "Sarasa Mono SC" },
+36
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}