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]>
Цей коміт міститься в:
Walter Wangтаbghtyu authored and GitHub committed 2026-10-29 23:35:33 +08:00
1 джерело 285c7187fb
коміт 00693334f7
25 files changed
+861 -291

No files matched your search

+52
Переглянути файл
@@ -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");
-108
Переглянути файл
@@ -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);
});
});
+40
Переглянути файл
@@ -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();
}
});
+35
Переглянути файл
@@ -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);
}
+87
Переглянути файл
@@ -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();
});
+161
Переглянути файл
@@ -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>
);
}
-46
Переглянути файл
@@ -1,46 +0,0 @@
/**
* Font-family pickers over the families installed on this machine. The current
* value always stays listed, so a family that isn't installed here (a synced
* setting, an uninstalled font) still shows as selected.
*/
import { useMemo } from "react";
import type { SelectOption } from "@/ui";
import { BUNDLED_MONO_FAMILY } from "@/platform/fonts";
import { fontNames, useSystemFonts } from "@/platform/systemFonts";
import { Select } from "./Form";
const NO_BUNDLED: readonly string[] = [];
const MONO_BUNDLED: readonly string[] = [BUNDLED_MONO_FAMILY];
export interface FontSelectProps {
value: string;
onChange: (value: string) => void;
/** List only monospaced families (the current value stays). */
monospacedOnly?: boolean;
/** Offer "None" (empty value) first: fallback fonts. */
none?: boolean;
/** Families the app bundles, listed even when not installed. Default: the bundled monospace (none for fallbacks). */
bundled?: readonly string[];
disabled?: boolean;
"aria-label": string;
}
export function FontSelect({
value,
onChange,
monospacedOnly,
none,
bundled = none ? NO_BUNDLED : MONO_BUNDLED,
disabled,
...aria
}: FontSelectProps) {
const fonts = useSystemFonts();
const options = useMemo<SelectOption[]>(() => {
const names = fontNames(fonts, { monospacedOnly, include: [...bundled, value] });
const list = names.map((n) => ({ value: n, label: n }));
return none ? [{ value: "", label: "None" }, ...list] : list;
}, [fonts, monospacedOnly, none, bundled, value]);
return (
<Select value={value} options={options} onChange={onChange} disabled={disabled} aria-label={aria["aria-label"]} />
);
}
+3 -7
Переглянути файл
@@ -4,6 +4,8 @@
* Notifications. Every option is wired (see `appearanceRuntime`, workbench
* theme / tool windows / notifications, app title / windows, `platform/dateFormat`).
*/
import { FontPicker } from "../components/FontPicker";
import { useId, useRef, useState, type ReactNode } from "react";
import clsx from "clsx";
import { shortcutLabel } from "@/platform/keymap";
@@ -14,8 +16,6 @@ import { Checkbox, IconButton, Input, Popup, type SelectOption, T } from "@/ui";
import { NOTIFICATION_GROUPS } from "@/workbench/notificationStore";
import { ColorSwatches, NumberInput, Row, SearchLabel, Select, Toggle } from "../components/Form";
import { useLabelMatch, useSetting } from "../components/context";
import { FontSelect } from "../components/FontSelect";
import { BUNDLED_MONO_FAMILY, BUNDLED_UI_FAMILY } from "@/platform/fonts";
import {
ZOOM_LIMITS,
IDLE_LIMITS,
@@ -176,8 +176,6 @@ function ZoomRow() {
);
}
const UI_BUNDLED = [BUNDLED_UI_FAMILY, BUNDLED_MONO_FAMILY];
function CustomFontRow() {
const [on, setOn] = useSetting<boolean>("appearance.useCustomFont");
const [family, setFamily] = useSetting<string>("appearance.uiFontFamily");
@@ -187,9 +185,7 @@ function CustomFontRow() {
return (
<div className={clsx(styles.fontRow, matched && styles.matched)}>
<Checkbox checked={on} onChange={setOn} label={<SearchLabel text="Use custom font:" />} />
<span className={styles.fontInput}>
<FontSelect value={family} onChange={setFamily} bundled={UI_BUNDLED} disabled={!on} aria-label="UI font" />
</span>
<FontPicker label="UI font" disabled={!on} value={family} onChange={setFamily} />
<label className={clsx(styles.sizeLabel, !on && styles.disabled)} htmlFor={`${id}-size`}>
<SearchLabel text="Size:" />
</label>
+4 -3
Переглянути файл
@@ -2,11 +2,12 @@
* Editor › Font: family (installed fonts, monospaced filter), size, line height,
* ligatures, Typography Settings (main / bold weight, fallback font), editable preview.
*/
import { FontPicker } from "../../components/FontPicker";
import { useState } from "react";
import { Checkbox, type SelectOption, T } from "@/ui";
import { monoFontFamily } from "@/platform/fonts";
import { NumberInput, Row, SearchLabel, Select } from "../../components/Form";
import { FontSelect } from "../../components/FontSelect";
import { useLabelMatch, usePageContext, useSetting } from "../../components/context";
import { FONT_WEIGHTS, fontWeightOf, fontWeightVars } from "@/platform/editorSettings";
import { LIMITS } from "../../model/schema";
@@ -22,7 +23,7 @@ function FontRow() {
<Row label="Font">
<span className={styles.fontFamily}>
<span className={styles.fontSelect}>
<FontSelect value={family} onChange={setFamily} monospacedOnly={monoOnly} aria-label="Font" />
<FontPicker value={family} onChange={setFamily} label="Font" monoOnly={monoOnly} />
</span>
<span className={matched ? styles.matched : undefined}>
<Checkbox
@@ -55,7 +56,7 @@ function FallbackRow() {
const [fallback, setFallback] = useSetting<string>("editor.fallbackFont");
return (
<Row label="Fallback font" hint="For symbols not supported by the main font">
<FontSelect value={fallback} onChange={setFallback} none aria-label="Fallback font" />
<FontPicker value={fallback} onChange={setFallback} label="Fallback font" allowEmpty />
</Row>
);
}
+6 -5
Переглянути файл
@@ -3,6 +3,8 @@
* terminal feature, the editor save participants and the run feature
* (External Tools actions).
*/
import { FontPicker } from "../components/FontPicker";
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import clsx from "clsx";
import { commands, isTauri } from "@/platform/ipc";
@@ -14,7 +16,6 @@ import {
} from "@/platform/workflowSettings";
import { Button, Checkbox, IconButton, Input, Menu, type SelectOption, T } from "@/ui";
import { NumberInput, Row, SearchLabel, Section, Select } from "../components/Form";
import { FontSelect } from "../components/FontSelect";
import { useLabelMatch, useSetting } from "../components/context";
import { selectPage, setQuery } from "../model/staging";
import type { PageSpec } from "./spec";
@@ -248,12 +249,12 @@ function TerminalFont() {
<Indented>
<Row label="Font">
<span className={styles.fontFamily}>
<FontSelect
<FontPicker
value={family}
onChange={setFamily}
monospacedOnly={monoOnly}
monoOnly={monoOnly}
disabled={useEditor}
aria-label="Terminal font"
label="Terminal font"
/>
<span className={monoMatched ? styles.matched : undefined}>
<Checkbox
@@ -286,7 +287,7 @@ function TerminalFont() {
/>
</Row>
<Row label="Fallback font" hint="Used for characters the main font lacks.">
<FontSelect value={fallback} onChange={setFallback} none aria-label="Terminal fallback font" />
<FontPicker value={fallback} onChange={setFallback} allowEmpty label="Terminal fallback font" />
</Row>
<Row label="Column width">
<NumberInput
+44
Переглянути файл
@@ -0,0 +1,44 @@
import { expect, it, vi } from "vitest";
import type { Terminal } from "@xterm/xterm";
import { remeasureWhenFontsLoad, terminalFont } from "./theme";
import { DEFAULT_SETTINGS } from "@/features/settings/model/schema";
it("remeasures and fits after normal/bold faces load, but ignores a stale selection", async () => {
let finish!: () => void;
const loaded = new Promise<void>((resolve) => {
finish = resolve;
});
const load = vi.fn(() => loaded);
const previous = Object.getOwnPropertyDescriptor(document, "fonts");
Object.defineProperty(document, "fonts", { configurable: true, value: { load } });
try {
const font = terminalFont(DEFAULT_SETTINGS.terminal, { fontFamily: "Maple Mono NF", fontSize: 14, lineHeight: 20 });
const updates: string[] = [];
let current = font.family;
const term = {
options: {
get fontFamily() {
return current;
},
set fontFamily(value: string) {
current = value;
updates.push(value);
},
},
} as unknown as Terminal;
const fit = vi.fn();
remeasureWhenFontsLoad(term, font, () => true, fit);
expect(load).toHaveBeenCalledTimes(2);
expect(fit).not.toHaveBeenCalled();
finish();
await vi.waitFor(() => expect(fit).toHaveBeenCalledOnce());
expect(updates).toEqual(["monospace", font.family]);
current = "Another Font";
remeasureWhenFontsLoad(term, font, () => true, fit);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(fit).toHaveBeenCalledOnce();
} finally {
if (previous) Object.defineProperty(document, "fonts", previous);
else Reflect.deleteProperty(document, "fonts");
}
});
+11
Переглянути файл
@@ -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" },