feat(appearance): fallback font for the custom UI font (#660)

Adds a UI fallback font under Settings › Appearance › Use custom font, so glyphs missing from the chosen UI font (e.g. CJK in Menlo) render in a font the user picks instead of the system default. The fallback joins the UI font stack used by menus, the project tree and dialogs, and resolves macOS user fonts through the shared font loader.

Also keeps the custom font controls wrapping together at narrow widths and covers save/clear/disable, stack composition and settings round-trip in tests.

Fixes #654

Co-authored-by: a-rookie-of-C-language <[email protected]>
Цей коміт міститься в:
arookieofcтаa-rookie-of-C-language authored and GitHub committed 2026-11-01 15:53:24 +08:00
1 джерело f8d6f9df77
коміт 5c0a5239fa
11 files changed
+142 -38

No files matched your search

+23 -1
Переглянути файл
@@ -1,7 +1,8 @@
import { describe, expect, it } from "vitest";
import { formatPattern, formatTimestamp } from "@/platform/dateFormat";
import { resolveThemeId } from "@/workbench/theme";
import { nextZoom } from "../appearanceRuntime";
import { applyAppearanceOptions, nextZoom } from "../appearanceRuntime";
import { validateSettings } from "../model/schema";
import { validateAppearanceExtras, validateNotifications } from "../model/behaviorSchema";
describe("appearance & behavior settings", () => {
@@ -27,6 +28,27 @@ describe("appearance & behavior settings", () => {
expect(validateAppearanceExtras({ showToolWindowBadges: 1 }).showToolWindowBadges).toBe(true);
});
it("keeps the UI fallback font empty by default and trims a stored one", () => {
expect(validateAppearanceExtras({}).uiFontFallback).toBe("");
expect(validateAppearanceExtras({ uiFontFallback: " 思源黑体 " }).uiFontFallback).toBe("思源黑体");
expect(validateAppearanceExtras({ uiFontFallback: 5 }).uiFontFallback).toBe("");
});
it("applies and clears the UI fallback, and removes custom fonts when disabled", () => {
const root = document.createElement("div");
const settings = validateSettings({
appearance: { useCustomFont: true, uiFontFamily: "Menlo", uiFontFallback: "Songti SC" },
});
applyAppearanceOptions(settings, root);
expect(root.style.getPropertyValue("--font-ui")).toBe('"Menlo", "Songti SC", system-ui, sans-serif');
settings.appearance.uiFontFallback = "";
applyAppearanceOptions(settings, root);
expect(root.style.getPropertyValue("--font-ui")).toBe('"Menlo", system-ui, sans-serif');
settings.appearance.useCustomFont = false;
applyAppearanceOptions(settings, root);
expect(root.style.getPropertyValue("--font-ui")).toBe("");
});
it("formats dates with an override pattern and pretty dates", () => {
const d = new Date(2026, 0, 5, 14, 7);
expect(formatPattern(d, "dd.MM.yyyy")).toBe("05.01.2026");
+18
Переглянути файл
@@ -222,6 +222,24 @@ describe("Settings dialog", () => {
]);
});
it("enables, saves and clears the custom UI fallback font", async () => {
const dialog = await openDialog("appearance");
const fallback = within(dialog).getByRole("combobox", { name: "UI fallback font" });
const custom = within(dialog).getByRole("checkbox", { name: "Use custom font:" });
expect(fallback).toBeDisabled();
fireEvent.click(custom);
expect(fallback).toBeEnabled();
fireEvent.change(fallback, { target: { value: "Songti SC" } });
expect(settingsSet).not.toHaveBeenCalled();
await act(async () => fireEvent.click(within(dialog).getByRole("button", { name: "Apply" })));
expect(settingsSet).toHaveBeenCalledWith("global", "appearance.uiFontFallback", JSON.stringify("Songti SC"));
fireEvent.change(fallback, { target: { value: "" } });
await act(async () => fireEvent.click(within(dialog).getByRole("button", { name: "Apply" })));
expect(settingsSet).toHaveBeenCalledWith("global", "appearance.uiFontFallback", JSON.stringify(""));
fireEvent.click(custom);
expect(fallback).toBeDisabled();
});
it("edits and saves independent custom font weights", async () => {
const dialog = await openDialog("editor.font");
for (const [label, value] of [
+1 -1
Переглянути файл
@@ -33,7 +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", uiFontFamily(a.uiFontFamily));
if (a.useCustomFont) root.style.setProperty("--font-ui", uiFontFamily(a.uiFontFamily, a.uiFontFallback));
else root.style.removeProperty("--font-ui");
flag(root, "compact", a.compactMode);
+3
Переглянути файл
@@ -20,6 +20,7 @@ export interface AppearanceExtras {
zoom: number;
useCustomFont: boolean;
uiFontFamily: string;
uiFontFallback: string;
contrastScrollbars: boolean;
redGreenVision: boolean;
differentToolWindowBackground: boolean;
@@ -72,6 +73,7 @@ export const DEFAULT_APPEARANCE_EXTRAS: AppearanceExtras = {
zoom: 100,
useCustomFont: false,
uiFontFamily: "Inter",
uiFontFallback: "",
contrastScrollbars: false,
redGreenVision: false,
differentToolWindowBackground: false,
@@ -123,6 +125,7 @@ export function validateAppearanceExtras(raw: unknown): AppearanceExtras {
zoom: int(a.zoom, d.zoom, ZOOM_LIMITS),
useCustomFont: b("useCustomFont"),
uiFontFamily: typeof a.uiFontFamily === "string" && a.uiFontFamily.trim() ? a.uiFontFamily.trim() : d.uiFontFamily,
uiFontFallback: typeof a.uiFontFallback === "string" ? a.uiFontFallback.trim() : d.uiFontFallback,
contrastScrollbars: b("contrastScrollbars"),
redGreenVision: b("redGreenVision"),
differentToolWindowBackground: b("differentToolWindowBackground"),
+12 -3
Переглянути файл
@@ -24,7 +24,7 @@
padding: 8px 14px;
}
/* "[x] Use custom font: [Inter] Size: [13]" */
/* Inline option groups, with custom font controls wrapping together. */
.fontRow {
display: flex;
align-items: center;
@@ -37,8 +37,17 @@
.fontInput {
width: 220px;
}
.sizeLabel {
margin-left: 8px;
.customFontRow {
flex-wrap: wrap;
}
.fontOption {
display: inline-flex;
align-items: center;
gap: 8px;
max-width: 100%;
}
.fontOption > label {
flex-shrink: 0;
}
.sizeInput {
width: 64px;
+43 -25
Переглянути файл
@@ -181,35 +181,53 @@ function ZoomRow() {
function CustomFontRow() {
const [on, setOn] = useSetting<boolean>("appearance.useCustomFont");
const [family, setFamily] = useSetting<string>("appearance.uiFontFamily");
const [fallback, setFallback] = useSetting<string>("appearance.uiFontFallback");
const [size, setSize] = useSetting<number>("appearance.uiFontSize");
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:" />} />
<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>
{on ? (
<NumberInput
id={`${id}-size`}
value={size}
onChange={setSize}
{...LIMITS.uiFontSize}
integer
aria-label="UI font size"
<div className={clsx(styles.fontRow, styles.customFontRow, matched && styles.matched)}>
<span className={styles.fontOption}>
<Checkbox checked={on} onChange={setOn} label={<SearchLabel text="Use custom font:" />} />
<FontPicker label="UI font" disabled={!on} value={family} onChange={setFamily} />
</span>
<span className={styles.fontOption}>
<label className={clsx(!on && styles.disabled)} htmlFor={`${id}-fallback`}>
<SearchLabel text="Fallback font:" />
</label>
<FontPicker
id={`${id}-fallback`}
label="UI fallback font"
disabled={!on}
value={fallback}
onChange={setFallback}
allowEmpty
/>
) : (
<Input
id={`${id}-size`}
className={styles.sizeInput}
value={String(size)}
disabled
aria-label="UI font size"
readOnly
/>
)}
</span>
<span className={styles.fontOption}>
<label className={clsx(!on && styles.disabled)} htmlFor={`${id}-size`}>
<SearchLabel text="Size:" />
</label>
{on ? (
<NumberInput
id={`${id}-size`}
value={size}
onChange={setSize}
{...LIMITS.uiFontSize}
integer
aria-label="UI font size"
/>
) : (
<Input
id={`${id}-size`}
className={styles.sizeInput}
value={String(size)}
disabled
aria-label="UI font size"
readOnly
/>
)}
</span>
</div>
);
}
@@ -531,7 +549,7 @@ export const BEHAVIOR_PAGES: PageSpec[] = [
{
kind: "custom",
label: "Use custom font",
keywords: ["UI font size", "Size", "font family"],
keywords: ["UI font size", "Size", "font family", "fallback font"],
component: CustomFontRow,
},
{ kind: "toggle", key: "appearance.contrastScrollbars", label: "Use contrast scrollbars" },