From 284e55e5381f69cd5f26f810201ca08c01472388 Mon Sep 17 00:00:00 2001 From: raymondginger Date: Sun, 20 Sep 2026 22:29:51 +0800 Subject: [PATCH] feat(desktop): let monospace text use its own preferred font MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Appearance → Settings offered one preferred-font list, and the picker's Monospace group fed the same key. `--font-mono` had no preference variable, so a face chosen there either wrote the interface stack or looked applied and changed nothing: code blocks, diffs and the small monospace labels kept the built-in stack either way. `fontFamilyMono` is now a row of `APPEARANCE_SETTINGS` with its own key, field and `--font-mono-preferred` variable, which `--font-mono` consumes as a prefix. The variable is declared empty rather than omitted, because `var()` without a fallback would make the whole `font-family` declaration invalid at computed-value time; an empty value also keeps the shipped rendering byte-identical until a font is chosen. The placeholder and the note moved onto the descriptor rows, so the table stays the one place that knows a preference's key, validation and copy — the renderer no longer keeps a second map of the two font keys, and each field writes the key it was reached through. The terminal pane is out of reach and the copy now says so: xterm paints its own canvas and takes its font from `TerminalPanel`, whose geometry is deliberately frozen because the fit addon sends its cols/rows to the PTY. Measured in Edge 153, the engine Tauri uses on Windows, against the built stylesheet: with no preference the stack is unchanged, and with one the built-in stack still follows it. The trailing comma `familyListCss` appends is what keeps `JetBrains Mono "SFMono-Regular"` from parsing as a single family name — without it the declaration is dropped and monospace text falls back to the interface font. Refs #155. --- desktop/src/app/appearance.test.ts | 46 +++++++++++++ desktop/src/app/appearance.ts | 67 +++++++++++++++++-- desktop/src/app/i18n.ts | 4 ++ .../features/settings/AppearanceSettings.tsx | 39 ++++++----- desktop/src/styles/tokens.css | 10 ++- desktop/src/styles/tokens.test.ts | 26 +++++-- 6 files changed, 161 insertions(+), 31 deletions(-) diff --git a/desktop/src/app/appearance.test.ts b/desktop/src/app/appearance.test.ts index 7cdb6aafb..673973aa3 100644 --- a/desktop/src/app/appearance.test.ts +++ b/desktop/src/app/appearance.test.ts @@ -147,3 +147,49 @@ describe("applyAppearance", () => { expect(root().style.getPropertyValue("--font-size-base")).toBe(""); }); }); + +describe("monospace font preference", () => { + it("reaches the monospace stack, not the interface one", () => { + applyAppearance( + { + ...APPEARANCE_DEFAULTS, + fontFamily: "Inter", + fontFamilyMono: "Cascadia Code", + }, + root(), + ); + expect(root().style.getPropertyValue("--font-ui-preferred")).toBe("Inter,"); + expect(root().style.getPropertyValue("--font-mono-preferred")).toBe( + "Cascadia Code,", + ); + }); + + it("leaves the other list's variable untouched", () => { + // Both lists are edited through the same control, so a picker wired to the + // wrong key would quietly push a monospace face into the UI stack. + applyAppearance( + { ...APPEARANCE_DEFAULTS, fontFamilyMono: "JetBrains Mono" }, + root(), + ); + expect(root().style.getPropertyValue("--font-ui-preferred")).toBe(""); + }); + + it("falls back to the default for a list of the wrong type", () => { + expect(sanitizeAppearance({ fontFamilyMono: 42 }).fontFamilyMono).toBe(""); + }); + + it("carries each list's field copy on its own table row", () => { + // The picker reads its placeholder and its note from the row it was reached + // through. Copy that moved back into the renderer, or a row that lost it, + // would render an unlabelled control instead of failing anywhere. + const rows = APPEARANCE_SETTINGS.filter((setting) => setting.noteKey); + expect(rows.map((setting) => setting.key)).toEqual([ + "fontFamily", + "fontFamilyMono", + ]); + for (const row of rows) { + expect(row.placeholder?.key).toMatch(/^settings\.appearance\.font/); + expect(row.placeholder?.fallback).toBeTruthy(); + } + }); +}); diff --git a/desktop/src/app/appearance.ts b/desktop/src/app/appearance.ts index e795b9699..fb63844f2 100644 --- a/desktop/src/app/appearance.ts +++ b/desktop/src/app/appearance.ts @@ -63,6 +63,14 @@ export interface AppearanceState { * fixed list would be both wrong and stale. Empty means "use the default". */ fontFamily: string; + /** + * The same thing for monospace text: code, diffs and the small labels that + * use the built-in monospace stack, which the interface list cannot reach. + * Kept separate because a face that reads well in prose is rarely the one + * that reads well in a code block. The terminal pane is out of reach: it + * paints its own canvas and is configured in `TerminalPanel`. + */ + fontFamilyMono: string; /** Complete, normalized palette imported from one local VS Code theme. */ importedTheme: ImportedTheme | null; } @@ -72,6 +80,7 @@ export const APPEARANCE_DEFAULTS: AppearanceState = { theme: "system", fontSize: 14, fontFamily: "", + fontFamilyMono: "", importedTheme: null, }; @@ -92,6 +101,14 @@ interface AppearanceSetting { toCss?(value: AppearanceState[K]): string; /** Present for numeric settings so the UI can build a slider. */ range?: { min: number; max: number; step: number; unit: string }; + /** Present for free-text settings: the field's placeholder, key and fallback. */ + placeholder?: { key: string; fallback: string }; + /** + * i18n key for the note under the form. Lives here so the table stays the one + * place that knows everything about a preference; `description` is its + * fallback, which keeps the English copy in this file. + */ + noteKey?: string; } function clampNumber(value: unknown, min: number, max: number, fallback: number) { @@ -111,6 +128,26 @@ export const CONVERSATION_WIDTH_RANGE = { step: 5, unit: "%", } as const; +/** A free-text family list: trimmed, bounded, and never anything but a string. */ +function familyList(value: unknown): string { + return typeof value === "string" ? value.trim().slice(0, 200) : ""; +} + +/** + * Rendered as a prefix of the built-in stack, never the whole of it, so an + * unavailable font degrades instead of leaving the text unstyled. + * + * The trailing comma carries the mechanism. The stack it is prepended to has no + * comma of its own (`var(--font-mono-preferred) "SFMono-Regular", …`), so + * without one the two become a single bogus family name and the browser drops + * the whole declaration. Measured in Edge 153, the engine Tauri uses on + * Windows: with the comma the built-in stack follows the preference as + * intended; without it the element falls back to the inherited interface font — + * no error, just the wrong font. + */ +function familyListCss(value: string): string { + return value ? `${value},` : ""; +} const CONVERSATION_WIDTH: AppearanceSetting<"conversationWidth"> = { key: "conversationWidth", @@ -164,10 +201,31 @@ const FONT_FAMILY: AppearanceSetting<"fontFamily"> = { "picking a CJK face so mixed text renders consistently. Anything the " + "system lacks is skipped, so listing several is safe.", cssVariable: "--font-ui-preferred", - sanitize: (value) => (typeof value === "string" ? value.trim().slice(0, 200) : ""), - // Trailing comma: this list is a prefix of the built-in stack, never the - // whole of it, so an unavailable font can always fall through. - toCss: (value) => (value ? `${value},` : ""), + noteKey: "settings.appearance.fontDescription", + placeholder: { + key: "settings.appearance.fontPlaceholder", + fallback: "e.g. Sarasa Mono SC, Inter", + }, + sanitize: familyList, + toCss: familyListCss, +}; + +const FONT_FAMILY_MONO: AppearanceSetting<"fontFamilyMono"> = { + key: "fontFamilyMono", + label: "Preferred monospace fonts", + description: + "Comma-separated families tried before the built-in monospace stack: " + + "code blocks, diffs and the small monospace labels follow it. A face " + + "chosen for prose is rarely the one that reads well in code, so this list " + + "is independent of the one above. The terminal pane keeps its own font.", + cssVariable: "--font-mono-preferred", + noteKey: "settings.appearance.fontMonoDescription", + placeholder: { + key: "settings.appearance.fontMonoPlaceholder", + fallback: "e.g. JetBrains Mono, Cascadia Code", + }, + sanitize: familyList, + toCss: familyListCss, }; export const APPEARANCE_SETTINGS = [ @@ -175,6 +233,7 @@ export const APPEARANCE_SETTINGS = [ THEME, FONT_SIZE, FONT_FAMILY, + FONT_FAMILY_MONO, ] as const; /** Coerce a parsed blob of unknown provenance into a complete state. */ diff --git a/desktop/src/app/i18n.ts b/desktop/src/app/i18n.ts index d84164df1..6cd176ae2 100644 --- a/desktop/src/app/i18n.ts +++ b/desktop/src/app/i18n.ts @@ -100,6 +100,7 @@ const ZH_CN: Record = { "settings.appearance.conversationWidth": "对话宽度", "settings.appearance.fontSize": "字号", "settings.appearance.fontFamily": "首选字体", + "settings.appearance.fontFamilyMono": "首选等宽字体", "settings.appearance.paper": "纸张 · 暖色低蓝光", "settings.appearance.midnight": "午夜 · 深邃冷色", "settings.appearance.claude": "Claude · 象牙白与陶土色", @@ -111,12 +112,15 @@ const ZH_CN: Record = { "settings.appearance.importHint": "读取一个本地 JSON/JSONC 颜色主题文件;暂不导入主题 include 链和语法配色。", "settings.appearance.fontPlaceholder": "例如:更纱黑体 SC、Inter", + "settings.appearance.fontMonoPlaceholder": "例如:JetBrains Mono、Cascadia Code", "settings.appearance.addInstalledFont": "添加已安装字体…", "settings.appearance.fontGroup.interface": "界面字体", "settings.appearance.fontGroup.monospace": "等宽字体", "settings.appearance.fontGroup.cjk": "中日韩字体", "settings.appearance.fontDescription": "以逗号分隔的字体会优先于内置字体尝试,便于为中英文混排选择一致的字体。系统会跳过未安装的字体,因此可以安全地列出多个字体。", + "settings.appearance.fontMonoDescription": + "以逗号分隔的字体会优先于内置等宽字体尝试,代码块、差异视图与细小的等宽标签都会跟随。适合正文的字体往往不适合代码,因此这份列表与上面的相互独立。终端面板仍使用自己的字体。", "settings.appearance.localOnly": "这些显示设置仅保存在本机,会立即生效,不属于项目配置。", "settings.appearance.reset": "恢复默认设置", diff --git a/desktop/src/features/settings/AppearanceSettings.tsx b/desktop/src/features/settings/AppearanceSettings.tsx index 898b83fad..b7e9205c6 100644 --- a/desktop/src/features/settings/AppearanceSettings.tsx +++ b/desktop/src/features/settings/AppearanceSettings.tsx @@ -73,9 +73,9 @@ const FONT_GROUPS = [ /** * Appearance controls, rendered from `APPEARANCE_SETTINGS`. * - * The table owns each preference's label, range and validation, so adding one - * is a row there rather than another block of near-identical JSX here. - * Changes apply immediately and persist locally — these are per-machine + * The table owns each preference's label, range, validation and field copy, so + * adding one is a row there rather than another block of near-identical JSX + * here. Changes apply immediately and persist locally — these are per-machine * display choices, not project configuration, so there is nothing to save. */ export function AppearanceSettings() { @@ -220,6 +220,9 @@ export function AppearanceSettings() { ); } + // The remaining settings are free-text family lists. Each edits the key + // it was reached through, so the two lists cannot write each other. + const fontKey = setting.key as "fontFamily" | "fontFamilyMono"; return (