diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index f9390f0d..1b49cd81 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -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(); diff --git a/src/bindings.ts b/src/bindings.ts index 97ce0528..5c4e5a3b 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -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, diff --git a/src/features/settings/__tests__/behavior.test.ts b/src/features/settings/__tests__/behavior.test.ts index 3f2cff40..e17a9eb1 100644 --- a/src/features/settings/__tests__/behavior.test.ts +++ b/src/features/settings/__tests__/behavior.test.ts @@ -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"); diff --git a/src/features/settings/__tests__/dialog.test.tsx b/src/features/settings/__tests__/dialog.test.tsx index 4a11b98c..dbdb5f02 100644 --- a/src/features/settings/__tests__/dialog.test.tsx +++ b/src/features/settings/__tests__/dialog.test.tsx @@ -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 [ diff --git a/src/features/settings/appearanceRuntime.ts b/src/features/settings/appearanceRuntime.ts index 3052bf65..c60c5744 100644 --- a/src/features/settings/appearanceRuntime.ts +++ b/src/features/settings/appearanceRuntime.ts @@ -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); diff --git a/src/features/settings/model/behaviorSchema.ts b/src/features/settings/model/behaviorSchema.ts index 37fadb74..d1dacd1e 100644 --- a/src/features/settings/model/behaviorSchema.ts +++ b/src/features/settings/model/behaviorSchema.ts @@ -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"), diff --git a/src/features/settings/pages/Behavior.module.css b/src/features/settings/pages/Behavior.module.css index a1c511bb..0a36ad27 100644 --- a/src/features/settings/pages/Behavior.module.css +++ b/src/features/settings/pages/Behavior.module.css @@ -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; diff --git a/src/features/settings/pages/behavior.tsx b/src/features/settings/pages/behavior.tsx index f2a1732a..6143d3ab 100644 --- a/src/features/settings/pages/behavior.tsx +++ b/src/features/settings/pages/behavior.tsx @@ -181,35 +181,53 @@ function ZoomRow() { function CustomFontRow() { const [on, setOn] = useSetting("appearance.useCustomFont"); const [family, setFamily] = useSetting("appearance.uiFontFamily"); + const [fallback, setFallback] = useSetting("appearance.uiFontFallback"); const [size, setSize] = useSetting("appearance.uiFontSize"); const id = useId(); const matched = useLabelMatch("Use custom font"); return ( -
- } /> - - - {on ? ( - + + } /> + + + + + - ) : ( - - )} + + + + {on ? ( + + ) : ( + + )} +
); } @@ -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" }, diff --git a/src/platform/fonts.test.ts b/src/platform/fonts.test.ts index f8060c04..65cf7520 100644 --- a/src/platform/fonts.test.ts +++ b/src/platform/fonts.test.ts @@ -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); diff --git a/src/platform/fonts.ts b/src/platform/fonts.ts index 161a9873..9acf9eb3 100644 --- a/src/platform/fonts.ts +++ b/src/platform/fonts.ts @@ -140,7 +140,8 @@ export async function fontsLoaded(family: string, size: number): Promise { } } -/** 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(", "); } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index a7637c16..8204ccd5 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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}",