diff --git a/docs/plans/2026-08-14-terminal-font-design.md b/docs/plans/2026-08-14-terminal-font-design.md index 543ac67..3424e85 100644 --- a/docs/plans/2026-08-14-terminal-font-design.md +++ b/docs/plans/2026-08-14-terminal-font-design.md @@ -85,6 +85,12 @@ export function resolveTerminalFont(prefs, themeFontFamily): - `TerminalView` 构造 Terminal 时用解析值;主 effect 内 `store.subscribe` 回调 diff `term.options.fontFamily/fontSize`,变化则更新 options + `fit.fit()` + `sendResize()`(网格尺寸随字体变化);cleanup 退订。懒加载 chunk 无新增依赖(store 为运行时 prop)。 +> **实施偏差(2026-08-17)**:`resolveTerminalFont` 不再**逐字**返回胜出的基础字体,而是经 `withIconFontFallbacks()` 追加 Nerd Font 图标族后返回。 +> +> 起因:starship / powerlevel10k 等提示符的图标取自 Nerd Font 私有区,Chromium 的隐式系统回退能覆盖 BMP PUA(`U+E000–U+F8FF`,如 `U+E0B0`)但覆盖不到补充平面 PUA-B(`U+F0000+`,Nerd Fonts v3 把 Material Design 图标集迁到了这里),导致后者渲染为豆腐块。显式列出字体族可使浏览器逐字符查询,两个平面都能命中。 +> +> 三条约束:①**只追加不前置**——xterm 用字体栈首项测量单元格宽度;②插入到**第一个**通用族(`monospace` 等)之前——通用族是 catch-all,其后的字体永不被查询;③按族名去重(引号/大小写/空白不敏感),用户自列的字体保持其原有优先级。基础字体的优先级链(用户 pref > 主题 > 内置栈)不变,另新增 CSS 全局关键字(`inherit`/`initial`/`unset`/`revert`/`revert-layer`)过滤——这类值只在作为整条声明值时合法,追加后会得到非法 `font-family` 而被 CSSOM 静默丢弃。 + ### 4.6 i18n(`locales.ts`) zh/en 各新增:`settingsFontFamilyTitle/Desc/Placeholder`、`settingsFontSizeTitle/Desc/Suffix`(px)。 diff --git a/src/client/terminal-font.ts b/src/client/terminal-font.ts index 9da7679..9aad290 100644 --- a/src/client/terminal-font.ts +++ b/src/client/terminal-font.ts @@ -9,8 +9,184 @@ import { clampTerminalFontSize, type SidebarPrefs } from '../prefs-shared.ts' /** The built-in fallback stack when neither the user nor the theme sets one. */ export const DEFAULT_TERMINAL_FONT_FAMILY = '"SF Mono", Menlo, Consolas, "Liberation Mono", monospace' +/** + * Icon fonts appended to whichever base stack wins, so shell prompts that + * draw glyphs from the Nerd Font Private Use Areas resolve to a real glyph + * instead of the missing-glyph box (aka tofu). + * + * Why this is needed even though the OS "should" fall back automatically: + * prompt frameworks (starship, powerlevel10k, oh-my-posh) take their icons + * from the PUA. Chromium's implicit system fallback reliably covers the + * *BMP* PUA (U+E000–U+F8FF — e.g. the U+E0B0 powerline separator) but NOT + * the *supplementary-plane* PUA-B (U+F0000+) where Nerd Fonts v3 relocated + * the Material Design icon set. Naming the families explicitly makes the + * browser consult them per character, which covers both planes. + * + * Deliberately NOT listed: color-emoji families. Chromium routes genuine + * emoji code points through a dedicated emoji fallback path (which is why + * emoji already render), so naming them buys nothing here — while placing a + * color font ahead of the generic family risks capturing BMP symbols the + * prompt expects in monospace (U+26A0 ⚠, U+2714 ✔ …) and rendering them as + * wide color glyphs that break the cell grid. + * + * Ordering rationale: the symbols-only patches ship glyphs without Latin, + * so they can never hijack ASCII metrics — the safest first hop. The + * fully-patched distributions follow for users who installed one of those + * instead. Both the `… Mono` and proportional family names are listed + * because the Nerd Fonts installers register them as distinct families. + * + * These are strictly *appended*, never prepended: xterm derives its cell + * metrics from the first entry, so the base font must stay in front or the + * whole grid would be re-measured against an icon font. + */ +export const ICON_FONT_FALLBACKS: readonly string[] = [ + // Symbols-only patches (glyph coverage without Latin) — ideal fallbacks. + '"Symbols Nerd Font Mono"', + '"Symbols Nerd Font"', + // Common fully-patched Nerd Font distributions. + '"Hack Nerd Font Mono"', + '"Hack Nerd Font"', + '"JetBrainsMono Nerd Font Mono"', + '"JetBrainsMono Nerd Font"', + '"FiraCode Nerd Font Mono"', + '"FiraCode Nerd Font"', + '"CaskaydiaCove Nerd Font Mono"', + '"CaskaydiaCove Nerd Font"', + '"SauceCodePro Nerd Font Mono"', + '"UbuntuMono Nerd Font Mono"', + '"Iosevka Nerd Font Mono"', + '"MesloLGS Nerd Font Mono"', + '"MesloLGS NF"', +] + +/** + * CSS generic font families. A generic is a catch-all that always resolves, + * so icon fonts must be spliced in *before* the first one to stay reachable. + */ +const GENERIC_FAMILIES = new Set([ + 'monospace', 'serif', 'sans-serif', 'cursive', 'fantasy', + 'system-ui', 'ui-monospace', 'ui-serif', 'ui-sans-serif', 'ui-rounded', + 'math', 'emoji', 'fangsong', +]) + +/** + * CSS-wide keywords. These are only valid as an *entire* declaration value, + * so a stack cannot be appended to one. + */ +const CSS_WIDE_KEYWORDS = new Set(['inherit', 'initial', 'unset', 'revert', 'revert-layer']) + +/** Normalize one family name for comparison: unquote, collapse runs of + * whitespace, casefold. */ +function normalizeFamily(family: string): string { + return family + .trim() + .replace(/^["']|["']$/g, '') + .replace(/\s+/g, ' ') + .trim() + .toLowerCase() +} + +/** + * Split a CSS font-family stack on its top-level commas. + * + * Naive `split(',')` would corrupt quoted family names containing a comma + * and function values such as `var(--x, monospace)`, so quotes (with + * backslash escapes) and parentheses are tracked. + * + * @param stack - a CSS font-family stack. + * @returns the trimmed, non-empty family entries in source order. + */ +function splitFamilies(stack: string): string[] { + const entries: string[] = [] + let buffer = '' + let quote: string | null = null + let depth = 0 + for (let i = 0; i < stack.length; i += 1) { + const char = stack[i] as string + if (quote !== null) { + // Escapes are copied verbatim so `\"` does not close the quote. + if (char === '\\' && i + 1 < stack.length) { + buffer += char + (stack[i + 1] as string) + i += 1 + continue + } + buffer += char + if (char === quote) quote = null + continue + } + if (char === '"' || char === "'") { + quote = char + buffer += char + continue + } + if (char === '(') depth += 1 + else if (char === ')') depth = Math.max(0, depth - 1) + else if (char === ',' && depth === 0) { + entries.push(buffer) + buffer = '' + continue + } + buffer += char + } + entries.push(buffer) + return entries.map(entry => entry.trim()).filter(entry => entry !== '') +} + +/** + * Append {@link ICON_FONT_FALLBACKS} to a CSS font-family stack, keeping + * the caller's own entries and order intact. + * + * - Families already named in `stack` are not duplicated (quote-, case- and + * whitespace-insensitive), so a user who already lists their Nerd Font + * keeps their exact priority. + * - The icon fonts are spliced in ahead of the *first* generic family + * (`monospace` etc.), because a generic always resolves: anything after it + * would never be consulted. + * - Idempotent — re-applying to an already-topped-up stack is a no-op, which + * matters because `TerminalView` diffs the resolved value against the live + * `term.options.fontFamily` before reflowing. + * + * @param stack - a CSS font-family stack (base font first). + * @returns the stack with icon fallbacks merged in. + */ +export function withIconFontFallbacks(stack: string): string { + const entries = splitFamilies(stack) + if (entries.length === 0) return ICON_FONT_FALLBACKS.join(', ') + + const present = new Set(entries.map(normalizeFamily)) + const additions = ICON_FONT_FALLBACKS.filter(family => !present.has(normalizeFamily(family))) + if (additions.length === 0) return entries.join(', ') + + const firstGeneric = entries.findIndex(entry => GENERIC_FAMILIES.has(normalizeFamily(entry))) + const cut = firstGeneric === -1 ? entries.length : firstGeneric + return [...entries.slice(0, cut), ...additions, ...entries.slice(cut)].join(', ') +} + +/** + * Reduce one link of the base-family chain to a usable stack, or `''` when + * it cannot carry appended fallbacks so the next link should win. + * + * Rejecting CSS-wide keywords matters because the theme font arrives as a + * raw token value (`tokenValue('--ds-font-family-code')`); skins do set + * tokens to `initial`/`inherit`/`unset` (see `effectiveTokenValue` in + * `theme.ts`, which guards the color tokens for the same reason). Appending + * to such a value yields an invalid `font-family`, which the CSSOM discards + * silently — the terminal would lose the theme font *and* the icon fonts. + */ +function usableBase(value: string | undefined): string { + const trimmed = (value ?? '').trim() + if (trimmed === '') return '' + if (CSS_WIDE_KEYWORDS.has(trimmed.toLowerCase())) return '' + return trimmed +} + /** * Resolve the xterm font options for the given prefs. + * + * The base family keeps its existing precedence — user pref > theme code + * font > built-in stack — and then {@link withIconFontFallbacks} tops it up + * so prompt icons resolve regardless of which base won. + * * @param prefs - the current side card preferences. * @param themeFontFamily - the app's theme code font (`--ds-font-family-code` * token value, read live by the caller); undefined when the token is absent. @@ -20,9 +196,11 @@ export function resolveTerminalFont( prefs: SidebarPrefs, themeFontFamily: string | undefined, ): { fontFamily: string; fontSize: number } { - const custom = prefs.terminalFontFamily.trim() + const base = usableBase(prefs.terminalFontFamily) + || usableBase(themeFontFamily) + || DEFAULT_TERMINAL_FONT_FAMILY return { - fontFamily: custom !== '' ? custom : (themeFontFamily || DEFAULT_TERMINAL_FONT_FAMILY), + fontFamily: withIconFontFallbacks(base), fontSize: clampTerminalFontSize(prefs.terminalFontSize), } } diff --git a/tests/terminal-font.spec.ts b/tests/terminal-font.spec.ts index 3441589..01b47e2 100644 --- a/tests/terminal-font.spec.ts +++ b/tests/terminal-font.spec.ts @@ -3,10 +3,19 @@ * terminal card) resolve into xterm options with a strict fallback chain — * user family > theme code font > the built-in monospace stack, and the size * clamped into the 9–32 contract. + * + * On top of the base family, icon fonts (Nerd Font + color emoji) are + * appended so shell prompts drawing from the Private Use Areas render real + * glyphs instead of the missing-glyph box. */ import { describe, expect, it } from 'vitest' import { SIDEBAR_PREFS_DEFAULTS, clampTerminalFontSize, TERMINAL_FONT_SIZE_MAX, TERMINAL_FONT_SIZE_MIN } from '../src/prefs-shared.ts' -import { DEFAULT_TERMINAL_FONT_FAMILY, resolveTerminalFont } from '../src/client/terminal-font.ts' +import { + DEFAULT_TERMINAL_FONT_FAMILY, + ICON_FONT_FALLBACKS, + resolveTerminalFont, + withIconFontFallbacks, +} from '../src/client/terminal-font.ts' describe('clampTerminalFontSize', () => { it('rounds and clamps into the 9–32 contract', () => { @@ -17,26 +26,146 @@ describe('clampTerminalFontSize', () => { }) }) +describe('withIconFontFallbacks', () => { + /** Index of a family in a resolved stack, -1 when absent. */ + function indexOfFamily(stack: string, family: string): number { + return stack.split(',').map(part => part.trim()).indexOf(family) + } + + it('keeps the base font first so xterm measures cells against it', () => { + const stack = withIconFontFallbacks('"JetBrains Mono", monospace') + expect(stack.startsWith('"JetBrains Mono"')).toBe(true) + }) + + it('splices icon fonts ahead of a trailing generic family (a generic always resolves)', () => { + const stack = withIconFontFallbacks('"JetBrains Mono", monospace') + const parts = stack.split(',').map(part => part.trim()) + expect(parts[parts.length - 1]).toBe('monospace') + expect(parts.indexOf('"Symbols Nerd Font Mono"')).toBeGreaterThan(0) + expect(parts.indexOf('"Symbols Nerd Font Mono"')).toBeLessThan(parts.length - 1) + }) + + it('splices ahead of a generic sitting MID-stack, not just a trailing one', () => { + // Regression guard: a generic anywhere is a catch-all, so appending + // after it silently defeats the whole fix (the icon fonts would never + // be consulted). Stacks of this shape come from theme tokens. + const stack = withIconFontFallbacks('ui-monospace, Menlo, monospace, "Apple Color Emoji"') + expect(indexOfFamily(stack, '"Symbols Nerd Font Mono"')) + .toBeLessThan(indexOfFamily(stack, 'ui-monospace')) + }) + + it('splices ahead of the FIRST generic when several are present', () => { + const stack = withIconFontFallbacks('Menlo, monospace, sans-serif') + const nerd = indexOfFamily(stack, '"Symbols Nerd Font Mono"') + expect(nerd).toBeGreaterThan(indexOfFamily(stack, 'Menlo')) + expect(nerd).toBeLessThan(indexOfFamily(stack, 'monospace')) + expect(indexOfFamily(stack, 'sans-serif')).toBe(stack.split(',').length - 1) + }) + + it('appends at the end when the stack has no generic family', () => { + const stack = withIconFontFallbacks('"JetBrains Mono"') + expect(stack.startsWith('"JetBrains Mono", "Symbols Nerd Font Mono"')).toBe(true) + }) + + it('orders symbols-only patches ahead of fully-patched distributions', () => { + // Order IS the priority contract: the symbols-only families carry no + // Latin glyphs, so they can never hijack ASCII cell metrics. + const stack = withIconFontFallbacks('Menlo, monospace') + expect(indexOfFamily(stack, '"Symbols Nerd Font Mono"')) + .toBeLessThan(indexOfFamily(stack, '"Hack Nerd Font Mono"')) + }) + + it('names Nerd Font families explicitly to cover both PUA planes', () => { + const stack = withIconFontFallbacks('Menlo, monospace') + expect(stack).toContain('"Symbols Nerd Font Mono"') + expect(stack).toContain('"Hack Nerd Font Mono"') + }) + + it('omits color-emoji families (Chromium routes emoji through its own fallback)', () => { + // Listing a color font ahead of the generic would let it capture BMP + // symbols the prompt expects in monospace and break the cell grid. + const stack = withIconFontFallbacks('Menlo, monospace') + expect(stack).not.toContain('Apple Color Emoji') + expect(stack).not.toContain('Segoe UI Emoji') + expect(stack).not.toContain('Noto Color Emoji') + }) + + it('does not duplicate a family the caller already listed, preserving its priority', () => { + const stack = withIconFontFallbacks("'hack nerd font mono', monospace") + expect(stack.toLowerCase().split('hack nerd font mono').length - 1).toBe(1) + // The caller's entry keeps pole position. + expect(stack.startsWith("'hack nerd font mono'")).toBe(true) + }) + + it('treats quote, case and whitespace differences as the same family', () => { + const stack = withIconFontFallbacks('"Symbols Nerd Font Mono", monospace') + expect(stack.toLowerCase().split('symbols').length - 1).toBe(2) // Mono + proportional + }) + + it('is idempotent so TerminalView\'s option diff does not thrash', () => { + const once = withIconFontFallbacks('Menlo, monospace') + expect(withIconFontFallbacks(once)).toBe(once) + }) + + it('splits on top-level commas only, keeping quoted and function values whole', () => { + expect(withIconFontFallbacks('"Foo,Bar", monospace')).toContain('"Foo,Bar"') + expect(withIconFontFallbacks('var(--code, monospace)')).toContain('var(--code, monospace)') + }) + + it('falls back to the icon list alone for an empty stack (defensive)', () => { + expect(withIconFontFallbacks('')).toBe(ICON_FONT_FALLBACKS.join(', ')) + expect(withIconFontFallbacks(' , ')).toBe(ICON_FONT_FALLBACKS.join(', ')) + }) +}) + describe('resolveTerminalFont', () => { it('falls back to the theme code font when the family pref is empty', () => { const { fontFamily, fontSize } = resolveTerminalFont(SIDEBAR_PREFS_DEFAULTS, 'var-theme-font') - expect(fontFamily).toBe('var-theme-font') + // The theme font stays the base (first) entry; icon fonts top it up. + expect(fontFamily).toBe(withIconFontFallbacks('var-theme-font')) + expect(fontFamily.startsWith('var-theme-font')).toBe(true) expect(fontSize).toBe(13) }) it('falls back to the built-in monospace stack when neither the pref nor the theme provides one', () => { - expect(resolveTerminalFont(SIDEBAR_PREFS_DEFAULTS, undefined).fontFamily).toBe(DEFAULT_TERMINAL_FONT_FAMILY) + expect(resolveTerminalFont(SIDEBAR_PREFS_DEFAULTS, undefined).fontFamily) + .toBe(withIconFontFallbacks(DEFAULT_TERMINAL_FONT_FAMILY)) // A whitespace-only pref counts as empty (theme default). expect(resolveTerminalFont({ ...SIDEBAR_PREFS_DEFAULTS, terminalFontFamily: ' ' }, undefined).fontFamily) - .toBe(DEFAULT_TERMINAL_FONT_FAMILY) + .toBe(withIconFontFallbacks(DEFAULT_TERMINAL_FONT_FAMILY)) }) - it('prefers the custom family verbatim and clamps the size', () => { + it('skips a CSS-wide keyword instead of building an invalid declaration', () => { + // Skins do set tokens to these; appending to one yields an invalid + // font-family that the CSSOM discards, losing every font at once. + for (const keyword of ['inherit', 'initial', 'unset', 'revert', 'REVERT-LAYER']) { + const { fontFamily } = resolveTerminalFont(SIDEBAR_PREFS_DEFAULTS, keyword) + expect(fontFamily).toBe(withIconFontFallbacks(DEFAULT_TERMINAL_FONT_FAMILY)) + expect(fontFamily.toLowerCase()).not.toContain(keyword.toLowerCase()) + } + // Same guard on the user pref, which then defers to the theme font. + expect(resolveTerminalFont({ ...SIDEBAR_PREFS_DEFAULTS, terminalFontFamily: 'inherit' }, 'var-theme-font').fontFamily) + .toBe(withIconFontFallbacks('var-theme-font')) + }) + + it('prefers the custom family as the base and clamps the size', () => { const { fontFamily, fontSize } = resolveTerminalFont( { ...SIDEBAR_PREFS_DEFAULTS, terminalFontFamily: '"JetBrains Mono", monospace', terminalFontSize: 40 }, 'var-theme-font', ) - expect(fontFamily).toBe('"JetBrains Mono", monospace') + expect(fontFamily).toBe(withIconFontFallbacks('"JetBrains Mono", monospace')) + expect(fontFamily.startsWith('"JetBrains Mono"')).toBe(true) + expect(fontFamily).not.toContain('var-theme-font') expect(fontSize).toBe(TERMINAL_FONT_SIZE_MAX) }) + + it('applies icon fallbacks no matter which base family wins', () => { + for (const resolved of [ + resolveTerminalFont(SIDEBAR_PREFS_DEFAULTS, 'var-theme-font'), + resolveTerminalFont(SIDEBAR_PREFS_DEFAULTS, undefined), + resolveTerminalFont({ ...SIDEBAR_PREFS_DEFAULTS, terminalFontFamily: 'Menlo' }, undefined), + ]) { + expect(resolved.fontFamily).toContain('"Symbols Nerd Font Mono"') + } + }) })