merge #190: terminal Nerd Font icon fallback (ShelbyTang)

This commit is contained in:
HuanLinOTO
2026-08-24 01:34:10 +08:00
3 changed files with 321 additions and 8 deletions
@@ -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)。
+180 -2
View File
@@ -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),
}
}
+135 -6
View File
@@ -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"')
}
})
})