Files
apes-sidebar/docs/plans/2026-08-14-terminal-font-design.md
T
ShelbyTang dd5a3dda07 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 例通过。
2026-08-18 10:50:30 +08:00

8.0 KiB
Raw Blame History

终端自定义字体(设置页二级设置)设计

日期:2026-08-14 状态:已实施(本文档含实施偏差记录) 目标版本:v0.11.0

1. 目标

在 DSH 设置页「侧边卡片」分区中,终端 tab 卡片的二级设置弹窗(齿轮按钮,README 所称"终端卡片二级设置")新增两行:

  1. 终端字体(text 输入):自定义 CSS font-family stack,如 "JetBrains Mono", monospace;留空 = 跟随主题等宽字体(--ds-font-family-code token)。
  2. 终端字号(number 输入):9–32px,默认 13,带 px 后缀。

配置持久化到 SidebarPrefs(dsh-better-sidebar 命名空间,host 侧 PrefsSchema 校验),对所有已打开终端实时生效(UI tab 与 agent tab 共用 TerminalView):设置页提交 → store.setPrefs 通知 → xterm options.fontFamily/fontSize 更新 + 重排网格。

2. 非目标

  • 不在终端视图内加菜单/按钮(用户明确选择仅设置页齿轮弹窗)。
  • 不做 per-tab / per-terminal 隔离(全局偏好,与 SidebarPrefs 现有语义一致)。
  • 不自动给字体族加引号、不做字体合法性嗅探(用户负责合法 CSS font-family;placeholder 示范格式)。
  • 不改 pty / WS / 终端连接生命周期:字体变化不重连、不重启 shell。

3. 现状回顾

  • 终端描述符(builtins/tabs.tsx)的 settings.toggles 只支持布尔开关(SidebarSettingToggle 无类型字段),FeatureSettingsRows(SideCardSection.tsx)只渲染 Switch 行。
  • SidebarPrefs / PrefsSchema / parsePrefs 三处同步维护(prefs-shared.ts / config.ts / client/prefs.ts)。
  • TerminalView 硬编码 fontSize: 13 + fontFamily: tokenValue('--ds-font-family-code') || 内置栈,且不响应偏好变化。

4. 设计

4.1 偏好扩展(prefs-shared.ts / config.ts / client/prefs.ts)

interface SidebarPrefs {
  // …
  terminalFontFamily: string   // '' = 跟随主题等宽字体
  terminalFontSize: number     // 9–32,默认 13
}

// prefs-shared.ts
export const TERMINAL_FONT_SIZE_MIN = 9
export const TERMINAL_FONT_SIZE_MAX = 32
export const TERMINAL_FONT_SIZE_DEFAULT = 13
export function clampTerminalFontSize(value: number): number // round + clamp(仿 clampWidthPercent)
  • PrefsSchema:terminalFontFamily: z.string().default('')、terminalFontSize: z.number().step(1).min(9).max(32).default(13) —— 旧文档解析自动补默认,无迁移。
  • parsePrefs:family 需 string 否则默认;size 需有限数并 clampTerminalFontSize 否则默认。

4.2 声明式 API 扩展(client/service.ts,向后兼容)

type SidebarSettingToggleType = 'switch' | 'text' | 'number'

interface SidebarSettingToggle {
  key: string
  title: string | (() => string)
  desc?: string | (() => string)
  type?: SidebarSettingToggleType   // 缺省 'switch',外部插件现有用法不变
  min?: number                      // number 行的下限(提交时钳制)
  max?: number                      // number 行的上限
  placeholder?: string              // text 行的占位符
  unit?: string                     // 输入框后的单位后缀(如 'px')
}

4.3 终端描述符声明(builtins/tabs.tsx)

settings.toggles 追加两行:{ key: 'terminalFontFamily', type: 'text', placeholder: … } 与 { key: 'terminalFontSize', type: 'number', min: 9, max: 32, unit: 'px' }(标题/描述走 locales)。

4.4 设置行渲染(SideCardSection.tsx + CSS)

  • FeatureSettingsRows 按 toggle.type 分支:switch 走现有 Switch 行;text/number 走新 TypedRow(局部 draft state,Input 原语,blur/Enter 提交,number 行带 unit 后缀)。
  • TypedRow 的 key 含已提交值:提交失败 → prefs 回滚 → key 变化 → 行重挂载为存储值(输入中 draft 不因 key 变化被打断,因为 key 只随已提交值变)。
  • 父级 onCommitSetting(toggle, raw): string:number 解析+钳制(非法/空 → 走 Number('')=0 钳到下限,与宽度行 commitWidth 先例一致);text 原样;乐观 setPrefs → commit → 失败 applyOutcome 回滚(复用既有 applyPref 管道,原 togglePref 更名)。

4.5 终端消费(client/terminal-font.ts 新模块 + TerminalView.tsx)

// terminal-font.ts(纯函数,可单测)
export const DEFAULT_TERMINAL_FONT_FAMILY = '"SF Mono", Menlo, Consolas, "Liberation Mono", monospace'
export function resolveTerminalFont(prefs, themeFontFamily):
  { fontFamily: 非空用户值 ?? themeFontFamily ?? 内置栈, fontSize: clampTerminalFontSize(prefs.terminalFontSize) }
  • 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)。

5. 测试

  • tests/unit.spec.ts:parsePrefs/loadPrefs 新字段(合法 string 直通、越界钳制 9/32、非 number 回退、缺省默认);全等字面量补字段。
  • tests/plugin-shape.spec.ts:PrefsSchema 默认值 + overridden 全等。
  • tests/builtins.spec.ts:终端 toggles keys = 4 个,后两个 type/min/max 断言。
  • tests/smoke.spec.ts:settings.get 全默认字面量补字段。
  • tests/side-card-section.spec.tsx(SSR):text/number 行渲染(value/min/max/placeholder/unit,非 checkbox)。
  • tests/side-card-section-rows.spec.tsx(新,jsdom + createRoot):blur 提交回调、number 钳制、空输入钳到下限、提交失败回滚草稿(key 重挂载)。
  • tests/terminal-font.spec.ts(新):resolveTerminalFont 回退链 + clampTerminalFontSize。

6. 限制与取舍

  • 外部插件的自定义 toggle key 照旧被 host PrefsSchema 校验丢弃(现有 seam 行为);type: 'text'/'number' 是公开 API 扩展,外部插件可用但 key 仍须是宿主 schema 字段。
  • number 输入的"非法值"在浏览器里只会以空串出现(number input 拒绝非数字字符),因此 Number('')=0 → 钳到下限 是唯一可达路径,与宽度行先例一致。
  • 字号实时生效依赖 xterm 的 options setter + re-fit;若未来 xterm 版本行为变化,回退方案是重建 Terminal(不推荐,会丢滚动缓冲)。

7. 实施偏差记录

  • 计划中的"非法输入回退当前存储值"用例在实现中发现不可达(浏览器 number input 的值清洗),改为断言"空输入钳到下限"(与宽度行一致)。
  • togglePref 更名为 applyPref(它本就接受任意 patch,不只是布尔翻转)。