mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 00:07:50 +00:00
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:
+57
-15
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
@@ -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"]} />
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
@@ -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" },
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { monoFontFamily, uiFontFamily } from "./fonts";
|
||||
import { monoFontFamily, quoteFontFamily, uiFontFamily } from "./fonts";
|
||||
|
||||
const BASE = "var(--font-mono)";
|
||||
|
||||
@@ -10,6 +10,10 @@ describe("monoFontFamily", () => {
|
||||
["", "", `"JetBrains Mono Variable", "JetBrains Mono", ${BASE}`],
|
||||
[undefined, "", `"JetBrains Mono Variable", "JetBrains Mono", ${BASE}`],
|
||||
["JetBrains Mono", "PingFang SC", `"JetBrains Mono Variable", "JetBrains Mono", "PingFang SC", ${BASE}`],
|
||||
["Inter", "", `"Inter Variable", "Inter", ${BASE}`],
|
||||
["Fira Code", "Inter", `"Fira Code", "Inter Variable", "Inter", ${BASE}`],
|
||||
["Inter", "JetBrains Mono", `"Inter Variable", "Inter", "JetBrains Mono Variable", "JetBrains Mono", ${BASE}`],
|
||||
["inter", "jetbrains mono", `"Inter Variable", "Inter", "JetBrains Mono Variable", "JetBrains Mono", ${BASE}`],
|
||||
["Fira Code", "", `"Fira Code", ${BASE}`],
|
||||
['"Cascadia Code"', " Microsoft YaHei ", `"Cascadia Code", "Microsoft YaHei", ${BASE}`],
|
||||
])("%j + fallback %j", (family, fallback, expected) => {
|
||||
@@ -23,6 +27,13 @@ describe("monoFontFamily", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("escapes CSS special characters without turning a family into a list", () => {
|
||||
expect(quoteFontFamily('A"B\\C,D')).toBe('"A\\22 B\\5c C,D"');
|
||||
expect(quoteFontFamily("A\nB")).toBe('"A\\a B"');
|
||||
expect(uiFontFamily("JetBrains Mono")).toContain('"JetBrains Mono Variable"');
|
||||
expect(uiFontFamily("Inter")).toContain('"Inter Variable"');
|
||||
});
|
||||
|
||||
describe("uiFontFamily", () => {
|
||||
it.each([
|
||||
["Inter", '"Inter Variable", "Inter", system-ui, sans-serif'],
|
||||
|
||||
+27
-20
@@ -7,10 +7,26 @@
|
||||
*/
|
||||
|
||||
export const BUNDLED_MONO_FAMILY = "JetBrains Mono";
|
||||
const BUNDLED_MONO_FACES = ['"JetBrains Mono Variable"', '"JetBrains Mono"'];
|
||||
export const BUNDLED_UI_FAMILY = "Inter";
|
||||
const BUNDLED_FACES: ReadonlyMap<string, readonly string[]> = new Map([
|
||||
[BUNDLED_MONO_FAMILY.toLowerCase(), ['"JetBrains Mono Variable"', '"JetBrains Mono"']],
|
||||
[BUNDLED_UI_FAMILY.toLowerCase(), ['"Inter Variable"', '"Inter"']],
|
||||
]);
|
||||
const MONO_STACK_FALLBACK = '"JetBrains Mono Variable", "JetBrains Mono", ui-monospace, Menlo, monospace';
|
||||
|
||||
const quote = (f: string) => `"${f.replace(/"/g, "").trim()}"`;
|
||||
/** Quote one CSS family safely (including quotes, backslashes and control characters). */
|
||||
export const quoteFontFamily = (family: string) =>
|
||||
// oxlint-disable-next-line no-control-regex -- CSS string escaping must cover controls
|
||||
`"${family.trim().replace(/["\\\x00-\x1f\x7f]/g, (char) => `\\${char.codePointAt(0)!.toString(16)} `)}"`;
|
||||
|
||||
// Preserve compatibility with previously saved names wrapped in CSS quotes.
|
||||
const familyName = (family: string) => family.trim().replace(/^"(.*)"$/, "$1");
|
||||
|
||||
/** One resolver for primary, fallback and UI families, matching the bundled CSS registrations. */
|
||||
function familyFaces(family: string): readonly string[] {
|
||||
const name = familyName(family);
|
||||
return BUNDLED_FACES.get(name.toLowerCase()) ?? [quoteFontFamily(name)];
|
||||
}
|
||||
|
||||
/** The `--font-mono` stack, resolved (canvas / xterm can't use `var()`). */
|
||||
export function monoStack(): string {
|
||||
@@ -24,24 +40,10 @@ export function monoStack(): string {
|
||||
* bundled face, so it renders without a system install.
|
||||
*/
|
||||
export function monoFontFamily(family: string | undefined, fallback = "", base = monoStack()): string {
|
||||
const name = (family ?? "").replace(/"/g, "").trim();
|
||||
const fb = fallback.replace(/"/g, "").trim();
|
||||
const head = !name || name === BUNDLED_MONO_FAMILY ? BUNDLED_MONO_FACES : [quote(name)];
|
||||
return [...head, ...(fb ? [quote(fb)] : []), base].join(", ");
|
||||
}
|
||||
|
||||
export const BUNDLED_UI_FAMILY = "Inter";
|
||||
const BUNDLED_UI_FACES = ['"Inter Variable"', '"Inter"'];
|
||||
|
||||
/**
|
||||
* The `--font-ui` stack for a custom UI font. The bundled families map to their
|
||||
* bundled faces, like `monoFontFamily`, so they render without a system install.
|
||||
*/
|
||||
export function uiFontFamily(family: string): string {
|
||||
const name = family.replace(/"/g, "").trim();
|
||||
const head =
|
||||
name === BUNDLED_UI_FAMILY ? BUNDLED_UI_FACES : name === BUNDLED_MONO_FAMILY ? BUNDLED_MONO_FACES : [quote(name)];
|
||||
return [...head, "system-ui", "sans-serif"].join(", ");
|
||||
const name = familyName(family ?? "");
|
||||
const fb = familyName(fallback);
|
||||
const head = familyFaces(name || BUNDLED_MONO_FAMILY);
|
||||
return [...head, ...(fb ? familyFaces(fb) : []), base].join(", ");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,3 +59,8 @@ export async function fontsLoaded(family: string, size: number): Promise<void> {
|
||||
/* an unparsable list measures with whatever is available */
|
||||
}
|
||||
}
|
||||
|
||||
/** Bundled aliases also apply when selected for the interface. */
|
||||
export function uiFontFamily(family: string): string {
|
||||
return [...familyFaces(family), "system-ui", "sans-serif"].join(", ");
|
||||
}
|
||||
|
||||
@@ -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.": "输入字体家族名称或搜索已安装字体。"
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1,32 +1,92 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fontNames } from "./systemFonts";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const { native, discover, detect } = vi.hoisted(() => ({
|
||||
native: vi.fn(() => true),
|
||||
discover: vi.fn(),
|
||||
detect: vi.fn(),
|
||||
}));
|
||||
vi.mock("./candidateFonts", () => ({ detectCandidateFonts: detect }));
|
||||
vi.mock("@tauri-apps/api/core", () => ({ isTauri: native }));
|
||||
vi.mock("@/bindings", () => ({ commands: { systemFontFamilies: discover } }));
|
||||
import { bundledFonts, loadSystemFonts, useSystemFontStore } from "./systemFonts";
|
||||
|
||||
const fonts = [
|
||||
{ name: "menlo", monospaced: true },
|
||||
{ name: "Arial", monospaced: false },
|
||||
{ name: "Courier New", monospaced: true },
|
||||
{ name: "Ångström Sans", monospaced: false },
|
||||
];
|
||||
|
||||
describe("fontNames", () => {
|
||||
it("sorts installed families case- and accent-insensitively", () => {
|
||||
expect(fontNames(fonts)).toEqual(["Ångström Sans", "Arial", "Courier New", "menlo"]);
|
||||
});
|
||||
|
||||
it("filters to monospaced families", () => {
|
||||
expect(fontNames(fonts, { monospacedOnly: true })).toEqual(["Courier New", "menlo"]);
|
||||
});
|
||||
|
||||
it("always includes the extra names, spelled as given, without duplicates", () => {
|
||||
expect(fontNames(fonts, { monospacedOnly: true, include: ["JetBrains Mono", "Menlo", "Arial", " "] })).toEqual([
|
||||
"Arial",
|
||||
"Courier New",
|
||||
beforeEach(() => {
|
||||
native.mockReturnValue(true);
|
||||
discover.mockReset();
|
||||
detect.mockReset().mockReturnValue([{ name: "Menlo", monospaced: true }]);
|
||||
useSystemFontStore.setState({ fonts: bundledFonts, status: "idle", source: "bundled" });
|
||||
});
|
||||
describe("local font discovery", () => {
|
||||
it("shares an in-flight request, caches families outside the old list and explicitly refreshes", async () => {
|
||||
let resolve!: (fonts: unknown) => void;
|
||||
discover.mockReturnValue(
|
||||
new Promise((done) => {
|
||||
resolve = done;
|
||||
}),
|
||||
);
|
||||
const first = loadSystemFonts();
|
||||
expect(useSystemFontStore.getState().status).toBe("loading");
|
||||
expect(loadSystemFonts()).toBe(first);
|
||||
await Promise.resolve();
|
||||
resolve({
|
||||
status: "ok",
|
||||
data: [
|
||||
{ name: "Maple Mono NF", monospaced: true },
|
||||
{ name: "maple mono nf", monospaced: true },
|
||||
],
|
||||
});
|
||||
await first;
|
||||
expect(useSystemFontStore.getState().fonts.map((f) => f.name)).toEqual([
|
||||
"Inter",
|
||||
"JetBrains Mono",
|
||||
"Menlo",
|
||||
"maple mono nf",
|
||||
]);
|
||||
await loadSystemFonts();
|
||||
expect(discover).toHaveBeenCalledTimes(1);
|
||||
discover.mockResolvedValue({ status: "ok", data: [] });
|
||||
await loadSystemFonts(true);
|
||||
expect(discover).toHaveBeenLastCalledWith(true);
|
||||
expect(useSystemFontStore.getState()).toMatchObject({ status: "empty", source: "native" });
|
||||
expect(detect).not.toHaveBeenCalled();
|
||||
expect(useSystemFontStore.getState().fonts).toEqual([bundledFonts[1], bundledFonts[0]]);
|
||||
});
|
||||
|
||||
it("keeps the current family when nothing is installed", () => {
|
||||
expect(fontNames([], { include: ["Fira Code"] })).toEqual(["Fira Code"]);
|
||||
it("reports errors without losing suggestions, then retries", async () => {
|
||||
discover.mockResolvedValueOnce({ status: "error", error: { kind: "unavailable", message: "Unavailable" } });
|
||||
await loadSystemFonts();
|
||||
expect(useSystemFontStore.getState()).toMatchObject({ status: "error", source: "limited" });
|
||||
expect(useSystemFontStore.getState().fonts.some((f) => f.name === "Menlo")).toBe(true);
|
||||
discover.mockResolvedValue({ status: "ok", data: [{ name: "思源宋体", monospaced: false }] });
|
||||
await loadSystemFonts(true);
|
||||
expect(useSystemFontStore.getState()).toMatchObject({ status: "ready", source: "native" });
|
||||
expect(useSystemFontStore.getState().fonts.some((f) => f.name === "Menlo")).toBe(false);
|
||||
expect(useSystemFontStore.getState().fonts.some((f) => f.name === "思源宋体")).toBe(true);
|
||||
});
|
||||
it("does not invoke desktop APIs in browser development", async () => {
|
||||
native.mockReturnValue(false);
|
||||
await loadSystemFonts();
|
||||
expect(discover).not.toHaveBeenCalled();
|
||||
expect(detect).toHaveBeenCalledOnce();
|
||||
expect(useSystemFontStore.getState().status).toBe("browser");
|
||||
});
|
||||
});
|
||||
|
||||
it.each([{ fonts: [] }, { fonts: [{ name: "Outside Legacy List", monospaced: true }] }])(
|
||||
"retains successful native catalogs, including empty ones, after refresh failure: %j",
|
||||
async ({ fonts }) => {
|
||||
discover.mockResolvedValueOnce({ status: "ok", data: fonts });
|
||||
await loadSystemFonts();
|
||||
const previous = useSystemFontStore.getState().fonts;
|
||||
discover.mockRejectedValueOnce(new Error("native unavailable"));
|
||||
await loadSystemFonts(true);
|
||||
expect(useSystemFontStore.getState()).toMatchObject({ status: "error", source: "native", fonts: previous });
|
||||
expect(detect).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
it("keeps bundled/manual recovery usable when candidate probing also fails", async () => {
|
||||
discover.mockRejectedValue(new Error("native unavailable"));
|
||||
detect.mockImplementation(() => {
|
||||
throw new Error("canvas unavailable");
|
||||
});
|
||||
await loadSystemFonts();
|
||||
expect(useSystemFontStore.getState()).toMatchObject({ status: "error", source: "limited" });
|
||||
expect(useSystemFontStore.getState().fonts.map((font) => font.name)).toEqual(["Inter", "JetBrains Mono"]);
|
||||
});
|
||||
|
||||
+66
-51
@@ -1,57 +1,72 @@
|
||||
/**
|
||||
* The font families installed on this machine (`system_font_families`: CoreText,
|
||||
* DirectWrite or fontconfig), for every font picker. Read once per window; the
|
||||
* backend caches the list for the run.
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { call, commands, type FontFamily } from "./ipc";
|
||||
/** Shared suggestions only: editor, UI and terminal preferences remain independent. */
|
||||
import { useEffect } from "react";
|
||||
import { detectCandidateFonts } from "./candidateFonts";
|
||||
import { BUNDLED_MONO_FAMILY, BUNDLED_UI_FAMILY } from "./fonts";
|
||||
import { create } from "zustand";
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
import { call, commands, type FontFamily } from "@/platform/ipc";
|
||||
|
||||
let cached: FontFamily[] | null = null;
|
||||
let pending: Promise<FontFamily[]> | null = null;
|
||||
|
||||
/** The installed families; empty when the font system can't be read. */
|
||||
export function loadSystemFonts(): Promise<FontFamily[]> {
|
||||
pending ??= call(commands.systemFontFamilies())
|
||||
.catch(() => [] as FontFamily[])
|
||||
.then((list) => (cached = list));
|
||||
export type FontStatus = "idle" | "loading" | "ready" | "empty" | "error" | "browser";
|
||||
export const bundledFonts: FontFamily[] = [
|
||||
{ name: BUNDLED_MONO_FAMILY, monospaced: true },
|
||||
{ name: BUNDLED_UI_FAMILY, monospaced: false },
|
||||
];
|
||||
export function mergeFontFamilies(fonts: FontFamily[]): FontFamily[] {
|
||||
const unique = new Map<string, FontFamily>();
|
||||
for (const font of [...fonts, ...bundledFonts]) {
|
||||
if (font.name.trim()) unique.set(font.name.toLocaleLowerCase(), font);
|
||||
}
|
||||
return [...unique.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
export type FontSource = "bundled" | "limited" | "native";
|
||||
export const useSystemFontStore = create<{ fonts: FontFamily[]; status: FontStatus; source: FontSource }>(() => ({
|
||||
fonts: bundledFonts,
|
||||
status: "idle",
|
||||
source: "bundled",
|
||||
}));
|
||||
let pending: Promise<void> | null = null;
|
||||
function fallback(status: "error" | "browser") {
|
||||
// Even an empty successful native catalog is authoritative. A failed refresh
|
||||
// must never replace it with the old, necessarily incomplete candidate list.
|
||||
if (useSystemFontStore.getState().source === "native") {
|
||||
useSystemFontStore.setState({ status });
|
||||
return;
|
||||
}
|
||||
let candidates: FontFamily[] = [];
|
||||
try {
|
||||
candidates = detectCandidateFonts();
|
||||
} catch {
|
||||
// A failing detector must not break settings or discard manual preferences.
|
||||
}
|
||||
useSystemFontStore.setState({ fonts: mergeFontFamilies(candidates), source: "limited", status });
|
||||
}
|
||||
export function loadSystemFonts(refresh = false): Promise<void> {
|
||||
if (pending) return pending;
|
||||
if (!refresh && useSystemFontStore.getState().status !== "idle") return Promise.resolve();
|
||||
if (!isTauri()) {
|
||||
fallback("browser");
|
||||
return Promise.resolve();
|
||||
}
|
||||
useSystemFontStore.setState({ status: "loading" });
|
||||
pending = Promise.resolve()
|
||||
.then(() => call(commands.systemFontFamilies(refresh)))
|
||||
.then((fonts) =>
|
||||
useSystemFontStore.setState({
|
||||
fonts: mergeFontFamilies(fonts),
|
||||
status: fonts.length ? "ready" : "empty",
|
||||
source: "native",
|
||||
}),
|
||||
)
|
||||
.catch(() => fallback("error"))
|
||||
.finally(() => {
|
||||
pending = null;
|
||||
});
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** The installed families, empty until the first read resolves. */
|
||||
export function useSystemFonts(): FontFamily[] {
|
||||
const [fonts, setFonts] = useState<FontFamily[]>(() => cached ?? []);
|
||||
export function useSystemFonts() {
|
||||
const state = useSystemFontStore();
|
||||
useEffect(() => {
|
||||
if (cached) return;
|
||||
let live = true;
|
||||
void loadSystemFonts().then((list) => live && setFonts(list));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
void loadSystemFonts();
|
||||
}, []);
|
||||
return fonts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Picker entries: the installed families (only monospaced ones when asked),
|
||||
* plus `include` (the bundled faces, and the current value so a family that
|
||||
* isn't installed here still shows as selected), deduplicated and sorted.
|
||||
*/
|
||||
export function fontNames(
|
||||
fonts: readonly FontFamily[],
|
||||
{ monospacedOnly = false, include = [] }: { monospacedOnly?: boolean; include?: readonly string[] } = {},
|
||||
): string[] {
|
||||
const seen = new Map<string, string>();
|
||||
const add = (name: string) => {
|
||||
const n = name.trim();
|
||||
if (n && !seen.has(n.toLowerCase())) seen.set(n.toLowerCase(), n);
|
||||
};
|
||||
include.forEach(add);
|
||||
for (const f of fonts) if (!monospacedOnly || f.monospaced) add(f.name);
|
||||
return [...seen.values()].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
|
||||
}
|
||||
|
||||
/** Test hook: forget the cached list. */
|
||||
export function resetSystemFontsForTests(): void {
|
||||
cached = null;
|
||||
pending = null;
|
||||
return state;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user