дзеркало
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
@@ -152,6 +152,11 @@ export const commands = {
|
||||
* false when the clipboard stayed busy; the caller keeps working either way.
|
||||
*/
|
||||
clipboardSetFileList: (paths: string[]) => typedError<boolean, IdeError>(__TAURI_INVOKE("clipboard_set_file_list", { paths })),
|
||||
/**
|
||||
* Every installed family, sorted, each flagged monospaced or not. Read once
|
||||
* per run (off the command threads) and cached.
|
||||
*/
|
||||
systemFontFamilies: () => typedError<FontFamily[], IdeError>(__TAURI_INVOKE("system_font_families")),
|
||||
/**
|
||||
* Opens a document. Async: reading and decoding a large file must not block
|
||||
* the main thread (synchronous commands run on it).
|
||||
@@ -2979,6 +2984,15 @@ export type FoldingSettings = {
|
||||
docComments?: boolean,
|
||||
};
|
||||
|
||||
/**
|
||||
* One installed family. `monospaced` is the font system's own flag (fixed
|
||||
* pitch; on Linux also fontconfig's dual-width CJK monospace).
|
||||
*/
|
||||
export type FontFamily = {
|
||||
name: string,
|
||||
monospaced: boolean,
|
||||
};
|
||||
|
||||
export type FormatOptions = {
|
||||
tabSize: number,
|
||||
insertSpaces: boolean,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { monoFontFamily } from "./fonts";
|
||||
import { monoFontFamily, uiFontFamily } from "./fonts";
|
||||
|
||||
const BASE = "var(--font-mono)";
|
||||
|
||||
@@ -22,3 +22,13 @@ describe("monoFontFamily", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("uiFontFamily", () => {
|
||||
it.each([
|
||||
["Inter", '"Inter Variable", "Inter", system-ui, sans-serif'],
|
||||
["JetBrains Mono", '"JetBrains Mono Variable", "JetBrains Mono", system-ui, sans-serif'],
|
||||
[' "PingFang SC" ', '"PingFang SC", system-ui, sans-serif'],
|
||||
])("%j", (family, expected) => {
|
||||
expect(uiFontFamily(family)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,20 @@ export function monoFontFamily(family: string | undefined, fallback = "", base =
|
||||
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(", ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves once the faces `family` uses at `size` are loaded, so code that measures
|
||||
* glyphs (xterm's cell size) measures the real font rather than the swap fallback.
|
||||
|
||||
@@ -2038,6 +2038,8 @@
|
||||
"Template name": "模板名称",
|
||||
"Template text": "模板文本",
|
||||
"Terminal": "终端",
|
||||
"Terminal fallback font": "终端备用字体",
|
||||
"Terminal font": "终端字体",
|
||||
"Terminal font size": "终端字体大小",
|
||||
"Terminal line height": "终端行高",
|
||||
"Terminal sessions": "终端会话",
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fontNames } 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",
|
||||
"JetBrains Mono",
|
||||
"Menlo",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the current family when nothing is installed", () => {
|
||||
expect(fontNames([], { include: ["Fira Code"] })).toEqual(["Fira Code"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
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));
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** The installed families, empty until the first read resolves. */
|
||||
export function useSystemFonts(): FontFamily[] {
|
||||
const [fonts, setFonts] = useState<FontFamily[]>(() => cached ?? []);
|
||||
useEffect(() => {
|
||||
if (cached) return;
|
||||
let live = true;
|
||||
void loadSystemFonts().then((list) => live && setFonts(list));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, []);
|
||||
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;
|
||||
}
|
||||
@@ -28,6 +28,8 @@ export interface SelectProps<V extends string = string> {
|
||||
renderValue?: (option: SelectOption<V> | undefined) => ReactNode;
|
||||
}
|
||||
|
||||
const TYPE_AHEAD_MS = 800;
|
||||
|
||||
/** Dropdown select with a listbox popup (↑↓ Enter Esc, type-ahead). */
|
||||
export function Select<V extends string = string>({
|
||||
value,
|
||||
@@ -45,6 +47,7 @@ export function Select<V extends string = string>({
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const current = options.find((o) => o.value === value);
|
||||
const [active, setActive] = useState(0);
|
||||
const typeAhead = useRef({ text: "", at: -Infinity });
|
||||
const isSection = (o: SelectOption<V>) => !!o.disabled;
|
||||
|
||||
const openPopup = () => {
|
||||
@@ -76,11 +79,16 @@ export function Select<V extends string = string>({
|
||||
return;
|
||||
}
|
||||
if (e.key.length === 1 && !e.metaKey && !e.ctrlKey) {
|
||||
const ch = e.key.toLowerCase();
|
||||
// Keys typed in quick succession extend the prefix ("me" → Menlo); a lone key cycles.
|
||||
const ahead = typeAhead.current;
|
||||
ahead.text = e.timeStamp - ahead.at < TYPE_AHEAD_MS ? ahead.text + e.key.toLowerCase() : e.key.toLowerCase();
|
||||
ahead.at = e.timeStamp;
|
||||
const repeat = ahead.text.length > 1 && [...ahead.text].every((c) => c === ahead.text[0]);
|
||||
const prefix = repeat ? ahead.text[0]! : ahead.text;
|
||||
const n = options.length;
|
||||
for (let s = 1; s <= n; s++) {
|
||||
for (let s = prefix.length > 1 ? 0 : 1; s <= n; s++) {
|
||||
const i = (active + s) % n;
|
||||
if (!options[i]!.disabled && tx(options[i]!.label).toLowerCase().startsWith(ch)) {
|
||||
if (!options[i]!.disabled && tx(options[i]!.label).toLowerCase().startsWith(prefix)) {
|
||||
setActive(i);
|
||||
break;
|
||||
}
|
||||
|
||||
Reference in new issue
Заблокувати користувача