дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
fix(settings): List every installed font family in the font pickers (#225)
The font pickers never read the installed fonts: they probed a hard-coded list of ~30 names with canvas width checks, so anything outside the list never appeared and the monospaced filter changed nothing. A new ide-fonts crate asks the platform font system for families and their fixed-pitch flag (CoreText on macOS, DirectWrite on Windows, fc-list on Linux/BSD), exposed as system_font_families (spawn_blocking, cached). Every font picker — editor font and fallback, terminal font and fallback, UI custom font — uses one FontSelect with prefix type-ahead, a real monospaced filter, and keeps an uninstalled selection visible. Not verified on Windows or Linux machines. Fixes #199 Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
Цей коміт міститься в:
1 джерело
e537dd43a0
коміт
aea0b33585
21 files changed
+647
-124
No files matched your search
@@ -0,0 +1,108 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@
|
||||
* pressed only" and "Save files if the IDE is idle".
|
||||
*/
|
||||
import { executeAction, type Action } from "@/platform/actions";
|
||||
import { uiFontFamily } from "@/platform/fonts";
|
||||
import { call, commands, isTauri } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { setDragRequiresAlt } from "@/ui";
|
||||
@@ -32,8 +33,7 @@ export function applyAppearanceOptions(s: ValidSettings, root: HTMLElement = doc
|
||||
if (size === DEFAULT_SETTINGS.appearance.uiFontSize) root.style.removeProperty(name);
|
||||
else root.style.setProperty(name, `${size + delta}px`);
|
||||
}
|
||||
if (a.useCustomFont)
|
||||
root.style.setProperty("--font-ui", `"${a.uiFontFamily.replace(/"/g, "")}", system-ui, sans-serif`);
|
||||
if (a.useCustomFont) root.style.setProperty("--font-ui", uiFontFamily(a.uiFontFamily));
|
||||
else root.style.removeProperty("--font-ui");
|
||||
|
||||
flag(root, "compact", a.compactMode);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 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"]} />
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,11 @@
|
||||
}
|
||||
|
||||
/* "[x] Limit history to: [1000] rows" */
|
||||
.fontFamily {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.inlineRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -14,6 +14,8 @@ 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,
|
||||
@@ -174,35 +176,20 @@ function ZoomRow() {
|
||||
);
|
||||
}
|
||||
|
||||
export const UI_FONTS = ["Inter", "SF Pro Text", "Helvetica Neue", "PingFang SC", "Arial", "Verdana", "JetBrains Mono"];
|
||||
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");
|
||||
const [size, setSize] = useSetting<number>("appearance.uiFontSize");
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const id = useId();
|
||||
const matched = useLabelMatch("Use custom font");
|
||||
return (
|
||||
<div className={clsx(styles.fontRow, matched && styles.matched)}>
|
||||
<Checkbox checked={on} onChange={setOn} label={<SearchLabel text="Use custom font:" />} />
|
||||
<Input
|
||||
list={`${id}-fonts`}
|
||||
className={styles.fontInput}
|
||||
aria-label="UI font"
|
||||
disabled={!on}
|
||||
value={draft ?? family}
|
||||
onChange={(e) => {
|
||||
setDraft(e.target.value);
|
||||
if (e.target.value.trim()) setFamily(e.target.value.trim());
|
||||
}}
|
||||
onBlur={() => setDraft(null)}
|
||||
/>
|
||||
<datalist id={`${id}-fonts`}>
|
||||
{UI_FONTS.map((f) => (
|
||||
<option key={f} value={f} />
|
||||
))}
|
||||
</datalist>
|
||||
<span className={styles.fontInput}>
|
||||
<FontSelect value={family} onChange={setFamily} bundled={UI_BUNDLED} disabled={!on} aria-label="UI font" />
|
||||
</span>
|
||||
<label className={clsx(styles.sizeLabel, !on && styles.disabled)} htmlFor={`${id}-size`}>
|
||||
<SearchLabel text="Size:" />
|
||||
</label>
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
* Editor › Font: family (installed fonts, monospaced filter), size, line height,
|
||||
* ligatures, Typography Settings (main / bold weight, fallback font), editable preview.
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
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";
|
||||
@@ -13,88 +14,15 @@ import { editorPage } from "./kit";
|
||||
import styles from "./Editor.module.css";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
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",
|
||||
];
|
||||
|
||||
interface FontInfo {
|
||||
name: string;
|
||||
mono: boolean;
|
||||
}
|
||||
|
||||
let detected: FontInfo[] | null = null;
|
||||
|
||||
/** Installed candidates, detected by comparing glyph widths against generic fallbacks. */
|
||||
export function installedFonts(): FontInfo[] {
|
||||
if (detected) return detected;
|
||||
const canvas = typeof document !== "undefined" ? document.createElement("canvas") : null;
|
||||
const ctx = canvas?.getContext?.("2d");
|
||||
if (!ctx) return (detected = CANDIDATES.map((name) => ({ name, mono: true })));
|
||||
const width = (font: string, text: string) => {
|
||||
ctx.font = `32px ${font}`;
|
||||
return ctx.measureText(text).width;
|
||||
};
|
||||
const sample = "mmmmmmmmmmlli1WQ@";
|
||||
const out: FontInfo[] = [];
|
||||
for (const name of CANDIDATES) {
|
||||
const quoted = `"${name}"`;
|
||||
const present = ["monospace", "serif", "sans-serif"].some(
|
||||
(base) => width(`${quoted}, ${base}`, sample) !== width(base, sample),
|
||||
);
|
||||
if (!present && name !== "JetBrains Mono") continue;
|
||||
out.push({
|
||||
name,
|
||||
mono: Math.abs(width(`${quoted}, monospace`, "iiiiiiiiii") - width(`${quoted}, monospace`, "WWWWWWWWWW")) < 0.5,
|
||||
});
|
||||
}
|
||||
return (detected = out);
|
||||
}
|
||||
|
||||
function FontRow() {
|
||||
const [family, setFamily] = useSetting<string>("editor.fontFamily");
|
||||
const [monoOnly, setMonoOnly] = useState(true);
|
||||
const matched = useLabelMatch("Show only monospaced fonts");
|
||||
const options = useMemo<SelectOption[]>(() => {
|
||||
const list = installedFonts().filter((f) => !monoOnly || f.mono);
|
||||
const names = list.map((f) => f.name);
|
||||
if (!names.includes(family)) names.unshift(family);
|
||||
return names.map((n) => ({ value: n, label: n }));
|
||||
}, [family, monoOnly]);
|
||||
return (
|
||||
<Row label="Font">
|
||||
<span className={styles.fontFamily}>
|
||||
<span className={styles.fontSelect}>
|
||||
<Select value={family} options={options} onChange={setFamily} aria-label="Font" />
|
||||
<FontSelect value={family} onChange={setFamily} monospacedOnly={monoOnly} aria-label="Font" />
|
||||
</span>
|
||||
<span className={matched ? styles.matched : undefined}>
|
||||
<Checkbox
|
||||
@@ -125,13 +53,9 @@ function SizeRow() {
|
||||
|
||||
function FallbackRow() {
|
||||
const [fallback, setFallback] = useSetting<string>("editor.fallbackFont");
|
||||
const options = useMemo<SelectOption[]>(
|
||||
() => [{ value: "", label: "None" }, ...installedFonts().map((f) => ({ value: f.name, label: f.name }))],
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<Row label="Fallback font" hint="For symbols not supported by the main font">
|
||||
<Select value={fallback} options={options} onChange={setFallback} aria-label="Fallback font" />
|
||||
<FontSelect value={fallback} onChange={setFallback} none aria-label="Fallback font" />
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ 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";
|
||||
@@ -231,8 +232,8 @@ function TerminalFont() {
|
||||
const [fallback, setFallback] = useSetting<string>("terminal.fallbackFontFamily");
|
||||
const [columnWidth, setColumnWidth] = useSetting<number>("terminal.columnWidth");
|
||||
const matched = useLabelMatch("Use editor font");
|
||||
const familyId = useId();
|
||||
const fallbackId = useId();
|
||||
const monoMatched = useLabelMatch("Show only monospaced fonts");
|
||||
const [monoOnly, setMonoOnly] = useState(true);
|
||||
return (
|
||||
<>
|
||||
<div className={clsx(styles.inlineRow, matched && styles.matched)}>
|
||||
@@ -245,14 +246,24 @@ function TerminalFont() {
|
||||
</span>
|
||||
</div>
|
||||
<Indented>
|
||||
<Row label="Font" htmlFor={familyId}>
|
||||
<Input
|
||||
id={familyId}
|
||||
className={styles.medium}
|
||||
disabled={useEditor}
|
||||
value={family}
|
||||
onChange={(e) => e.target.value.trim() && setFamily(e.target.value)}
|
||||
/>
|
||||
<Row label="Font">
|
||||
<span className={styles.fontFamily}>
|
||||
<FontSelect
|
||||
value={family}
|
||||
onChange={setFamily}
|
||||
monospacedOnly={monoOnly}
|
||||
disabled={useEditor}
|
||||
aria-label="Terminal font"
|
||||
/>
|
||||
<span className={monoMatched ? styles.matched : undefined}>
|
||||
<Checkbox
|
||||
checked={monoOnly}
|
||||
onChange={setMonoOnly}
|
||||
disabled={useEditor}
|
||||
label={<SearchLabel text="Show only monospaced fonts" />}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</Row>
|
||||
<Row label="Size">
|
||||
<NumberInput
|
||||
@@ -274,14 +285,8 @@ function TerminalFont() {
|
||||
aria-label="Terminal line height"
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Fallback font" htmlFor={fallbackId} hint="Used for characters the main font lacks.">
|
||||
<Input
|
||||
id={fallbackId}
|
||||
className={styles.medium}
|
||||
value={fallback}
|
||||
placeholder="None"
|
||||
onChange={(e) => setFallback(e.target.value)}
|
||||
/>
|
||||
<Row label="Fallback font" hint="Used for characters the main font lacks.">
|
||||
<FontSelect value={fallback} onChange={setFallback} none aria-label="Terminal fallback font" />
|
||||
</Row>
|
||||
<Row label="Column width">
|
||||
<NumberInput
|
||||
@@ -588,7 +593,7 @@ export const TOOLS_PAGES: PageSpec[] = [
|
||||
{
|
||||
kind: "custom",
|
||||
label: "Use editor font",
|
||||
keywords: ["Font", "Size", "Line height", "Fallback font", "Column width"],
|
||||
keywords: ["Font", "Show only monospaced fonts", "Size", "Line height", "Fallback font", "Column width"],
|
||||
component: TerminalFont,
|
||||
},
|
||||
],
|
||||
|
||||
Reference in new issue
Заблокувати користувача