дзеркало
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
@@ -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" },
|
||||
|
||||
Reference in new issue
Заблокувати користувача