From 00693334f70d130651244beb65bb3058bd9caee4 Mon Sep 17 00:00:00 2001 From: Walter Wang Date: Thu, 29 Oct 2026 23:35:33 +0800 Subject: [PATCH] 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> --- crates/ide-fonts/src/lib.rs | 72 ++++++-- e2e/settings.spec.ts | 33 ++++ src-tauri/src/commands/fonts.rs | 9 +- src/bindings.ts | 6 +- .../settings/__tests__/dialog.test.tsx | 52 ++++++ .../settings/__tests__/fontSelect.test.tsx | 108 ------------ .../settings/__tests__/fontSync.test.ts | 40 +++++ .../settings/components/FontPicker.module.css | 35 ++++ .../settings/components/FontPicker.test.tsx | 87 ++++++++++ .../settings/components/FontPicker.tsx | 161 ++++++++++++++++++ .../settings/components/FontSelect.tsx | 46 ----- src/features/settings/pages/behavior.tsx | 10 +- src/features/settings/pages/editor/font.tsx | 7 +- src/features/settings/pages/tools.tsx | 11 +- src/features/terminal/fontLoading.test.ts | 44 +++++ src/features/terminal/terminal.test.ts | 11 ++ src/platform/candidateFonts.test.ts | 36 ++++ src/platform/candidateFonts.ts | 74 ++++++++ src/platform/fonts.test.ts | 13 +- src/platform/fonts.ts | 47 ++--- src/platform/locales/text/zh.json | 10 +- src/platform/remote.test.ts | 10 +- src/platform/remote.ts | 1 + src/platform/systemFonts.test.ts | 112 +++++++++--- src/platform/systemFonts.ts | 117 +++++++------ 25 files changed, 861 insertions(+), 291 deletions(-) delete mode 100644 src/features/settings/__tests__/fontSelect.test.tsx create mode 100644 src/features/settings/__tests__/fontSync.test.ts create mode 100644 src/features/settings/components/FontPicker.module.css create mode 100644 src/features/settings/components/FontPicker.test.tsx create mode 100644 src/features/settings/components/FontPicker.tsx delete mode 100644 src/features/settings/components/FontSelect.tsx create mode 100644 src/features/terminal/fontLoading.test.ts create mode 100644 src/platform/candidateFonts.test.ts create mode 100644 src/platform/candidateFonts.ts diff --git a/crates/ide-fonts/src/lib.rs b/crates/ide-fonts/src/lib.rs index 92a6f521..7274544e 100644 --- a/crates/ide-fonts/src/lib.rs +++ b/crates/ide-fonts/src/lib.rs @@ -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> = OnceLock::new(); - CACHE.get_or_init(|| normalize(imp::list())) +type Cache = Mutex>>; + +/// 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> { + unsafe fn read(refresh: bool) -> windows::core::Result> { 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); } } diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts index 88fd1aea..7b69b037 100644 --- a/e2e/settings.spec.ts +++ b/e2e/settings.spec.ts @@ -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(); +}); diff --git a/src-tauri/src/commands/fonts.rs b/src-tauri/src/commands/fonts.rs index 5b15079c..705da8e2 100644 --- a/src-tauri/src/commands/fonts.rs +++ b/src-tauri/src/commands/fonts.rs @@ -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> { - tauri::async_runtime::spawn_blocking(|| ide_fonts::families().to_vec()) +pub async fn system_font_families(refresh: bool) -> IdeResult> { + tauri::async_runtime::spawn_blocking(move || ide_fonts::families(refresh).to_vec()) .await .map_err(|e| IdeError::internal(e.to_string())) } diff --git a/src/bindings.ts b/src/bindings.ts index c0df5986..f6eeaa3d 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -153,10 +153,10 @@ export const commands = { */ clipboardSetFileList: (paths: string[]) => typedError(__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(__TAURI_INVOKE("system_font_families")), + systemFontFamilies: (refresh: boolean) => typedError(__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). diff --git a/src/features/settings/__tests__/dialog.test.tsx b/src/features/settings/__tests__/dialog.test.tsx index a4bc899e..63857e20 100644 --- a/src/features/settings/__tests__/dialog.test.tsx +++ b/src/features/settings/__tests__/dialog.test.tsx @@ -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"); diff --git a/src/features/settings/__tests__/fontSelect.test.tsx b/src/features/settings/__tests__/fontSelect.test.tsx deleted file mode 100644 index 6e0fd87d..00000000 --- a/src/features/settings/__tests__/fontSelect.test.tsx +++ /dev/null @@ -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(); - 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( {}} 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( - {}} 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( {}} 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( - {}} 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(, "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( - <> - {}} aria-label="A" /> - {}} aria-label="B" /> - , - ); - await act(async () => {}); - expect(systemFontFamilies).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/features/settings/__tests__/fontSync.test.ts b/src/features/settings/__tests__/fontSync.test.ts new file mode 100644 index 00000000..44d7e00d --- /dev/null +++ b/src/features/settings/__tests__/fontSync.test.ts @@ -0,0 +1,40 @@ +import { afterEach, expect, it, vi } from "vitest"; +const { handlers, settingsGet } = vi.hoisted(() => ({ handlers: new Map void>(), settingsGet: vi.fn() })); +vi.mock("@/platform/ipc", async (original) => { + const real = await original(); + 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(); + } +}); diff --git a/src/features/settings/components/FontPicker.module.css b/src/features/settings/components/FontPicker.module.css new file mode 100644 index 00000000..280ba608 --- /dev/null +++ b/src/features/settings/components/FontPicker.module.css @@ -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); +} diff --git a/src/features/settings/components/FontPicker.test.tsx b/src/features/settings/components/FontPicker.test.tsx new file mode 100644 index 00000000..eaf386ad --- /dev/null +++ b/src/features/settings/components/FontPicker.test.tsx @@ -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 ; +} +it("keeps missing saved names, searches outside the old list, and accepts manual names", () => { + render(); + 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(); + fireEvent.focus(screen.getByRole("combobox")); + expect(screen.queryByRole("option", { name: /思源宋体/ })).toBeNull(); + view.rerender(); + 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(); + fireEvent.change(screen.getByRole("combobox"), { target: { value: "" } }); + view.rerender(); + 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(); + 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(); + 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(); +}); diff --git a/src/features/settings/components/FontPicker.tsx b/src/features/settings/components/FontPicker.tsx new file mode 100644 index 00000000..9ebc4d75 --- /dev/null +++ b/src/features/settings/components/FontPicker.tsx @@ -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(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 ( + + + = 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); + } + }} + /> + + + + {tx(message)} + + setOpen(false)} + autoFocus={false} + restoreFocus={false} + matchWidth + role="presentation" + > +
e.preventDefault()} + > + {suggestions.map((font, index) => ( +
setActive(index)} + onClick={() => choose(font.name)} + > + {font.name} + + {tx( + font.name === "JetBrains Mono" || font.name === "Inter" + ? "Bundled" + : font.monospaced + ? "Monospaced" + : "Proportional", + )} + +
+ ))} + {!suggestions.length && ( + + No matching fonts. Enter a family name manually. + + )} +
+
+
+ ); +} diff --git a/src/features/settings/components/FontSelect.tsx b/src/features/settings/components/FontSelect.tsx deleted file mode 100644 index c5ecb75f..00000000 --- a/src/features/settings/components/FontSelect.tsx +++ /dev/null @@ -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(() => { - 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 ( -