mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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 <186315048+a-rookie-of-C-language@users.noreply.github.com>
This commit is contained in:
co-authored by
a-rookie-of-C-language
parent
f8d6f9df77
commit
5c0a5239fa
@@ -276,6 +276,8 @@ pub struct AppearanceSettings {
|
||||
/// Apply `ui_font_family` / `ui_font_size` (otherwise the theme's).
|
||||
pub use_custom_font: bool,
|
||||
pub ui_font_family: String,
|
||||
/// Font tried when `ui_font_family` lacks a glyph; empty = none.
|
||||
pub ui_font_fallback: String,
|
||||
pub contrast_scrollbars: bool,
|
||||
/// Adjust colours for red-green vision deficiency.
|
||||
pub red_green_vision: bool,
|
||||
@@ -326,6 +328,7 @@ impl Default for AppearanceSettings {
|
||||
zoom: 100,
|
||||
use_custom_font: false,
|
||||
ui_font_family: "Inter".into(),
|
||||
ui_font_fallback: String::new(),
|
||||
contrast_scrollbars: false,
|
||||
red_green_vision: false,
|
||||
different_tool_window_background: false,
|
||||
@@ -721,6 +724,20 @@ impl Settings {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ui_font_fallback_defaults_empty_and_round_trips() {
|
||||
let old: Settings = serde_json::from_str(r#"{"appearance":{"useCustomFont":true}}"#).unwrap();
|
||||
assert!(old.appearance.ui_font_fallback.is_empty());
|
||||
for family in ["", "Songti SC", "Maple Mono NF CN"] {
|
||||
let mut settings = Settings::default();
|
||||
settings.appearance.ui_font_fallback = family.into();
|
||||
let json = serde_json::to_value(&settings).unwrap();
|
||||
assert_eq!(json["appearance"]["uiFontFallback"], family);
|
||||
let restored: Settings = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(restored.appearance.ui_font_fallback, family);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_window_badges_default_on_and_round_trip() {
|
||||
let old: Settings = serde_json::from_str(r#"{"appearance":{"showToolWindowNames":true}}"#).unwrap();
|
||||
|
||||
@@ -1422,6 +1422,8 @@ export type AppearanceSettings = {
|
||||
/** Apply `ui_font_family` / `ui_font_size` (otherwise the theme's). */
|
||||
useCustomFont?: boolean,
|
||||
uiFontFamily?: string,
|
||||
/** Font tried when `ui_font_family` lacks a glyph; empty = none. */
|
||||
uiFontFallback?: string,
|
||||
contrastScrollbars?: boolean,
|
||||
/** Adjust colours for red-green vision deficiency. */
|
||||
redGreenVision?: boolean,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -36,11 +36,13 @@ it("escapes CSS special characters without turning a family into a list", () =>
|
||||
|
||||
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);
|
||||
["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'],
|
||||
["Inter", "思源黑体", '"Inter Variable", "Inter", "思源黑体", system-ui, sans-serif'],
|
||||
["Inter", " ", '"Inter Variable", "Inter", system-ui, sans-serif'],
|
||||
])("%j + %j", (family, fallback, expected) => {
|
||||
expect(uiFontFamily(family, fallback)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,6 +128,16 @@ describe("macOS user font resolver", () => {
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loads a UI fallback through the shared macOS user font resolver", async () => {
|
||||
const { fonts, invoke, registered } = await resolver(false);
|
||||
const family = fonts.uiFontFamily("Inter", "Maple Mono NF CN");
|
||||
expect(family).toBe('"Inter Variable", "Inter", "OpenIDEA User Font 0", "Maple Mono NF CN", system-ui, sans-serif');
|
||||
expect(fonts.monoFontFamily("Inter", "Maple Mono NF CN")).toContain('"OpenIDEA User Font 0"');
|
||||
await fonts.fontsLoaded(family, 13);
|
||||
expect(invoke).toHaveBeenCalledExactlyOnceWith("user_font_data", { family: "Maple Mono NF CN" });
|
||||
expect(registered).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("detects user fonts that WKWebView exposes only to connected canvases without loading bytes", async () => {
|
||||
const { fonts, invoke, canvases } = await resolver("connected");
|
||||
expect(fonts.fontAvailable("Maple Mono NF CN")).toBe(true);
|
||||
|
||||
@@ -140,7 +140,8 @@ export async function fontsLoaded(family: string, size: number): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Bundled aliases also apply when selected for the interface. */
|
||||
export function uiFontFamily(family: string): string {
|
||||
return [...familyFaces(family), "system-ui", "sans-serif"].join(", ");
|
||||
/** Bundled aliases also apply when selected for the interface; `fallback` covers glyphs `family` lacks (empty = none). */
|
||||
export function uiFontFamily(family: string, fallback = ""): string {
|
||||
const fb = familyName(fallback);
|
||||
return [...familyFaces(family), ...(fb ? familyFaces(fb) : []), "system-ui", "sans-serif"].join(", ");
|
||||
}
|
||||
|
||||
@@ -760,6 +760,7 @@
|
||||
"Failed": "失败",
|
||||
"Failed to restart language server": "重启语言服务器失败",
|
||||
"Fallback font": "备用字体",
|
||||
"Fallback font:": "备用字体:",
|
||||
"Fetch": "获取",
|
||||
"Fetches remotes in the background so incoming commits show in the Branches popup. Auto stops after an authentication failure.": "在后台获取远程,以便传入的提交显示在“分支”弹出窗口中。“自动”在身份验证失败后停止。",
|
||||
"Field access": "字段访问",
|
||||
@@ -2223,6 +2224,7 @@
|
||||
"Merged with main toolbar": "与主工具栏合并",
|
||||
"UI Options": "UI 选项",
|
||||
"UI elements take up less screen space": "UI 元素占用更少的屏幕空间",
|
||||
"UI fallback font": "UI 备用字体",
|
||||
"UI font": "UI 字体",
|
||||
"UI font size": "UI 字体大小",
|
||||
"Uninstalled {name}": "已卸载 {name}",
|
||||
|
||||
Reference in New Issue
Block a user