fix(terminal): 补全 Nerd Font 图标字体回退,修复提示符图标显示为豆腐块
starship / powerlevel10k 等提示符的图标取自 Nerd Font 私有区。Chromium 的 隐式系统字体回退能覆盖 BMP PUA(U+E000–U+F8FF,如 U+E0B0 powerline 分隔符), 但覆盖不到补充平面 PUA-B(U+F0000+)——Nerd Fonts v3 把 Material Design 图标集 迁到了这里,于是这部分图标渲染为豆腐块。经排查用户字体本身含完整字形(format-12 cmap 覆盖上述码点),而 macOS 系统等宽字体(Menlo / SF Mono / Monaco)对这些 码点零覆盖,根因是插件的 fontFamily 栈未显式列出任何 Nerd Font。 修复:新增 withIconFontFallbacks(),在 resolveTerminalFont 出口为胜出的基础 字体追加 Nerd Font 图标族(显式命名可使浏览器逐字符查询,两个平面都能命中)。 基础字体优先级链(用户 pref > 主题 > 内置栈)保持不变。 三条约束: - 只追加不前置:xterm 用字体栈首项测量单元格宽度,基础字体须留在最前; - 插入到第一个通用族(monospace 等)之前:通用族是 catch-all,其后的字体 永不被查询,仅判断尾项会让主题给出的 `ui-monospace, Menlo, monospace, …` 这类栈静默失效; - 按族名去重(引号/大小写/空白不敏感),用户自列的字体保持原有优先级。 另外: - 过滤 CSS 全局关键字(inherit/initial/unset/revert/revert-layer)。主题字体 取自原始 token 值,皮肤确实会设成这类值,追加后会得到非法 font-family 被 CSSOM 静默丢弃,导致主题字体与图标字体一起失效,现改为回退到下一级; - 字体栈按顶层逗号切分并感知引号与括号,避免拆坏 "Foo,Bar" 与 var(--x, y); - 不列彩色 emoji 字体:本问题的图标全为 PUA 且真 emoji 有独立回退路径,而 彩色字体会捕获提示符依赖等宽的 BMP 符号(U+26A0、U+2714 等)破坏网格。 单测 12 → 19 例,补齐中间通用族回归守卫、CSS 全局关键字、多通用族、幂等性与 回退顺序契约;全量 594 例通过。
This commit is contained in:
+180
-2
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user