дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02: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 <[email protected]>
Цей коміт міститься в:
1 джерело
285c7187fb
коміт
00693334f7
25 files changed
+861
-291
No files matched your search
@@ -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'],
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
@@ -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
Заблокувати користувача