在设置页「侧边卡片」→ 终端卡片填入自定义「终端字体」后,若该族名在浏览器里 解析不到,终端不是退化成"没有想要的字体",而是整个网格崩掉:字符间距被撑开、 字形变成比例字形、Nerd Font 图标全部消失。 用户实测(macOS + Chrome,填 `Maple Mono NF CN`): | 「终端字体」的值 | DevTools Rendered Fonts | 结果 | | --- | --- | --- | | `Maple Mono NF CN` | **PingFang SC** | cell 12.09px @ 13px 字号(0.93em),网格崩坏 | | `Maple Mono NF CN, monospace` | **Menlo** | 间距恢复正常 | `resolveTerminalFont` 对用户值(以及主题 `--ds-font-family-code` token 值) 原样返回,只有内置的 `DEFAULT_TERMINAL_FONT_FAMILY` 末尾带 `monospace`。 字体栈若不以通用族收尾,浏览器解析不到任何具名字体时会落到**标准字体** (standard font,macOS 中文环境是 PingFang SC)——它是**比例字体**。 xterm 用它的推进宽度量 cell(0.93em,等宽字体应为 0.6em),网格随即失真。 这条路径比想象中容易踩:终端里跑的 shell 在 host 上,字体却由**浏览器所在机器** 提供,两者不同机时(远程/容器化的 DSH)用户按 host 侧装的字体填名字必然解析失败。 新增 `withMonospaceFallback()`,在 `resolveTerminalFont` 出口保证字体栈以通用族收尾。 - **不做字体嗅探**:终端字体设计文档 §2 把"字体合法性嗅探"列为非目标;本改动 不检测具名字体是否存在,只补一个通用族尾项。 - **已有通用族则原样保留**:包括用户主动写的非等宽通用族(`sans-serif` 等) ——那是用户的选择,不该被覆盖。 - **主题 token 值同样兜底**:它掉进比例字体的后果与用户值完全一样。 - **CSS 全局关键字单独处理**:`inherit` / `initial` / `unset` / `revert` / `revert-layer` 追加逗号后是非法 CSS,会被 CSSOM 整条丢弃,反而让 xterm 一个字体都拿不到;这类值直接回落内置栈。 - 字体栈按**顶层逗号**切分并感知引号、转义与括号,避免拆坏 `"Foo, Bar"` 与 `var(--x, y)`;通用族匹配大小写不敏感且只认未加引号的项。 - `tests/terminal-font.spec.ts` 4 → 13 例(兜底追加、已有通用族免动、大小写与 引号边界、顶层逗号切分、幂等性、CSS 全局关键字、空值) - `pnpm typecheck` 零错误 - 全量 `pnpm test`:941 passed(改动前基线 932 passed,+9 为本次新增); 另有 31 例失败在本分支基线(干净 main)上同样失败,与本改动无关 互补、不重叠。#190 解决"字体解析成功、但栈里没显式列 Nerd Font 导致补充平面 PUA 图标缺字";本 PR 解决"字体压根没解析到、栈又没有通用族尾项导致落进比例 字体"。两者都落在 `terminal-font.ts` 的同一出口,合并时按先后 rebase 即可。
249 lines
13 KiB
TypeScript
249 lines
13 KiB
TypeScript
/**
|
||
* Terminal font resolution tests: the custom font prefs (side card settings,
|
||
* 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, and the stack is terminated with
|
||
* a generic family so an unresolvable name degrades to a monospace instead
|
||
* of the proportional standard font.
|
||
*/
|
||
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,
|
||
ICON_FONT_FALLBACKS,
|
||
resolveTerminalFont,
|
||
withIconFontFallbacks,
|
||
withMonospaceFallback,
|
||
} from '../src/client/terminal-font.ts'
|
||
|
||
describe('clampTerminalFontSize', () => {
|
||
it('rounds and clamps into the 9–32 contract', () => {
|
||
expect(clampTerminalFontSize(13)).toBe(13)
|
||
expect(clampTerminalFontSize(15.6)).toBe(16)
|
||
expect(clampTerminalFontSize(5)).toBe(TERMINAL_FONT_SIZE_MIN)
|
||
expect(clampTerminalFontSize(40)).toBe(TERMINAL_FONT_SIZE_MAX)
|
||
})
|
||
})
|
||
|
||
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('keeps fully-patched fonts BEHIND a leading generic (they carry Latin and would become the base)', () => {
|
||
// A leading generic resolves, so prepending a fully-patched Nerd Font
|
||
// (which ships Latin glyphs) would make it xterm's measuring/base font
|
||
// and override the user/theme family precedence. Only the symbols-only
|
||
// patches (no Latin) may precede it.
|
||
const stack = withIconFontFallbacks('monospace')
|
||
expect(indexOfFamily(stack, '"Symbols Nerd Font Mono"')).toBeLessThan(indexOfFamily(stack, 'monospace'))
|
||
expect(indexOfFamily(stack, '"Hack Nerd Font Mono"')).toBeGreaterThan(indexOfFamily(stack, 'monospace'))
|
||
expect(indexOfFamily(stack, '"JetBrainsMono Nerd Font Mono"')).toBeGreaterThan(indexOfFamily(stack, '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')
|
||
// The theme font stays the base (first) entry; the monospace tail keeps
|
||
// the grid alive, and icon fonts top it up.
|
||
expect(fontFamily).toBe(withIconFontFallbacks(withMonospaceFallback('var-theme-font')))
|
||
expect(fontFamily.startsWith('var-theme-font')).toBe(true)
|
||
expect(fontFamily.endsWith('monospace')).toBe(true)
|
||
expect(fontSize).toBe(13)
|
||
})
|
||
|
||
it('terminates a custom family that names no generic, keeping the grid monospaced', () => {
|
||
// The reported failure: a family installed only on the machine running the
|
||
// shell resolves to nothing in the browser, and without a generic tail the
|
||
// fallback is the proportional standard font (PingFang SC on macOS), whose
|
||
// advance xterm then measures the cell from.
|
||
const { fontFamily } = resolveTerminalFont(
|
||
{ ...SIDEBAR_PREFS_DEFAULTS, terminalFontFamily: 'Maple Mono NF CN' },
|
||
undefined,
|
||
)
|
||
expect(fontFamily.startsWith('Maple Mono NF CN')).toBe(true)
|
||
expect(fontFamily.endsWith('monospace')).toBe(true)
|
||
})
|
||
|
||
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(withIconFontFallbacks(DEFAULT_TERMINAL_FONT_FAMILY))
|
||
// A whitespace-only pref counts as empty (theme default).
|
||
expect(resolveTerminalFont({ ...SIDEBAR_PREFS_DEFAULTS, terminalFontFamily: ' ' }, undefined).fontFamily)
|
||
.toBe(withIconFontFallbacks(DEFAULT_TERMINAL_FONT_FAMILY))
|
||
})
|
||
|
||
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(withMonospaceFallback('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(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"')
|
||
expect(resolved.fontFamily.endsWith('monospace')).toBe(true)
|
||
}
|
||
})
|
||
})
|
||
describe('withMonospaceFallback', () => {
|
||
it('appends a monospace tail when the stack names no generic family', () => {
|
||
expect(withMonospaceFallback('"Maple Mono NF CN"')).toBe('"Maple Mono NF CN", monospace')
|
||
expect(withMonospaceFallback('Menlo, Consolas')).toBe('Menlo, Consolas, monospace')
|
||
})
|
||
|
||
it('leaves a stack that already names a generic family untouched', () => {
|
||
expect(withMonospaceFallback('"JetBrains Mono", monospace')).toBe('"JetBrains Mono", monospace')
|
||
expect(withMonospaceFallback('ui-monospace, "SF Mono"')).toBe('ui-monospace, "SF Mono"')
|
||
// A deliberate non-monospace generic is the user's call, not ours to override.
|
||
expect(withMonospaceFallback('"Foo", sans-serif')).toBe('"Foo", sans-serif')
|
||
})
|
||
|
||
it('matches generic families case-insensitively and only when unquoted', () => {
|
||
expect(withMonospaceFallback('"Foo", MONOSPACE')).toBe('"Foo", MONOSPACE')
|
||
// A quoted name merely containing the word is a family, not a generic.
|
||
expect(withMonospaceFallback('"My monospace Font"')).toBe('"My monospace Font", monospace')
|
||
})
|
||
|
||
it('splits on top-level commas only, keeping quoted names and var() intact', () => {
|
||
expect(withMonospaceFallback('"Foo, Bar"')).toBe('"Foo, Bar", monospace')
|
||
expect(withMonospaceFallback('var(--code, Menlo)')).toBe('var(--code, Menlo), monospace')
|
||
expect(withMonospaceFallback("'Esc\\'aped, Name'")).toBe("'Esc\\'aped, Name', monospace")
|
||
})
|
||
|
||
it('drops the empty entries a stray comma produces', () => {
|
||
expect(withMonospaceFallback('"Foo",')).toBe('"Foo", monospace')
|
||
expect(withMonospaceFallback(' "Foo" ,, Menlo ')).toBe('"Foo", Menlo, monospace')
|
||
})
|
||
|
||
it('is idempotent', () => {
|
||
const once = withMonospaceFallback('"Maple Mono NF CN"')
|
||
expect(withMonospaceFallback(once)).toBe(once)
|
||
expect(withMonospaceFallback(DEFAULT_TERMINAL_FONT_FAMILY)).toBe(DEFAULT_TERMINAL_FONT_FAMILY)
|
||
})
|
||
|
||
it('falls back to the built-in stack for an empty value or a CSS-wide keyword', () => {
|
||
// Appending to a CSS-wide keyword yields invalid CSS the CSSOM drops
|
||
// wholesale, which would leave xterm with no font at all.
|
||
expect(withMonospaceFallback('')).toBe(DEFAULT_TERMINAL_FONT_FAMILY)
|
||
expect(withMonospaceFallback(' , ')).toBe(DEFAULT_TERMINAL_FONT_FAMILY)
|
||
expect(withMonospaceFallback('inherit')).toBe(DEFAULT_TERMINAL_FONT_FAMILY)
|
||
expect(withMonospaceFallback('REVERT-LAYER')).toBe(DEFAULT_TERMINAL_FONT_FAMILY)
|
||
// Only a lone keyword is one; as part of a stack it is just a family name.
|
||
expect(withMonospaceFallback('inherit, Menlo')).toBe('inherit, Menlo, monospace')
|
||
})
|
||
})
|