From 8bdcaf4290679359d5285d8e079e9244b22353d1 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:36:14 +0800 Subject: [PATCH] fix(fonts): name a CJK and emoji fallback the host platform actually ships The default `font_fallbacks` list was macOS-only -- Menlo, Hasklug Nerd Font Mono, Maple Mono NF CN, Apple Color Emoji. Fallbacks resolve by family name against installed fonts, so off macOS the whole chain matched nothing and every glyph the primary lacked was left to the platform's own cascade. Bundled Hack maps 1548 codepoints and zero ideographs, so on Windows that was every Chinese character in every pane, and every emoji. The fall-through is not only a matter of which face you get. `element.rs` pins each wide cell to `2 x cell_width`, and Hack advances 0.60205em, so a two-column slot is 1.2041em -- while every stock CJK face (Microsoft YaHei, PingFang SC, Noto Sans CJK) advances 1.0em. `force_width` left-aligns, so the ideograph hugs the left of its slot and the remaining 0.2em shows up as a gap on the right of every character. Measured on Windows at font_size 15: left bearing 1.49px, right bearing 4.90px. Branch the defaults per platform, keeping Maple Mono NF CN first everywhere -- 0.6em Latin, 1.2em CJK, the one exact two-cell fit against Hack (bearings 3.06px / 3.62px, ink centered). It stays referenced by name only, never bundled, at ~20MB per weight. Changing `Config::default` alone would reach nobody who already has a `config.json`, which is every existing user. So `fallback_chain` appends the platform's stock faces the same way it already pins Hack: a fallback is consulted only after everything ahead of it has missed, so appending can never displace a face the user chose, and the file is never rewritten. Verified by driving two builds against one config naming only absent macOS faces: before, the CJK line differed from an explicit Microsoft YaHei chain by 3571 pixels (the cascade picked something else); after, it is pixel-identical. --- CHANGELOG.md | 17 +++++++ docs/features.md | 25 ++++++++++ docs/features.zh-CN.md | 23 +++++++++ src/core/config.rs | 109 ++++++++++++++++++++++++++++++++++++++--- src/terminal/view.rs | 96 ++++++++++++++++++++++++++++-------- 5 files changed, 241 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89092532..7210b8d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **CJK and emoji stop falling through to the OS on Windows and Linux** — the + default `font_fallbacks` named only faces that ship with macOS (Menlo, Apple + Color Emoji), so off macOS the entire chain matched nothing and every + ideograph and pictograph was resolved by the platform's own cascade instead. + The bundled Hack primary carries no CJK at all, so on Windows this was every + Chinese character in every pane. Defaults are now chosen per platform — + PingFang SC / Apple Color Emoji on macOS, Microsoft YaHei / Segoe UI Emoji on + Windows, Noto on Linux — and those stock names are appended to a + hand-written list as well, so a `config.json` that predates this change (or + was copied from another machine) is repaired at use time without being + rewritten. Maple Mono NF CN stays first on every platform: its 1.2em CJK + advance is the only exact fit for the two-column slot Hack's 0.60205em cell + produces, so a 1.0em stock face is left-aligned there with the remaining + ~0.2em showing as a gap on the right of each character. + ## [26.7.4] - 2026-07-26 ### Added diff --git a/docs/features.md b/docs/features.md index 4249caf5..4f778752 100644 --- a/docs/features.md +++ b/docs/features.md @@ -24,6 +24,31 @@ - **Window opacity & blur** — Settings → Appearance → Window; applies to every theme, *Follow theme* returns to the theme's own `opacity` / `blur` - **CJK / IME input** +## Fonts + +- **Hack is bundled** — it ships inside the binary, so the default renders identically everywhere without relying on a system install +- **Primary + ordered fallbacks** — `font_family` and `font_fallbacks` in `config.json`; optional `font_family_bold` / `font_family_italic` for distinct faces, and `font_features` to pass OpenType features through (contextual ligatures stay off unless you ask for them) +- **Platform-aware defaults** — the fallback list names faces the host OS actually ships (PingFang SC / Apple Color Emoji on macOS, Microsoft YaHei / Segoe UI Emoji on Windows, Noto on Linux). Those stock names are appended to a hand-written list too, so a `config.json` written on another platform still resolves + +### CJK and the two-column grid + +A cell is one advance of the primary face, and a wide (CJK) character is pinned +to exactly two of them. A CJK fallback therefore sits flush in its slot only if +its ideographs advance **twice** the primary's Latin advance. + +Bundled Hack advances 0.60205em, so a two-column slot is 1.2041em — while every +stock CJK face (Microsoft YaHei, PingFang SC, Noto Sans CJK) advances 1.0em. +Those glyphs get left-aligned in the slot and the leftover ~0.2em lands as a gap +on the right of every character. + +[Maple Mono NF CN](https://github.com/subframe7536/maple-font) is tried first on +every platform for exactly this reason — 0.6em Latin, 1.2em CJK, an exact +two-cell fit against Hack. It is referenced by name only, never bundled (~20MB +per weight): install it and tty7 picks it up with no config change. + +For CJK set *tight* rather than merely even, change the primary face instead — +one that advances 0.5em (Sarasa Mono SC, say) makes two columns exactly 1.0em. + ## Coding agents tty7 recognizes third-party coding agents running in a pane (Claude Code, diff --git a/docs/features.zh-CN.md b/docs/features.zh-CN.md index 11325ca7..9c59192f 100644 --- a/docs/features.zh-CN.md +++ b/docs/features.zh-CN.md @@ -24,6 +24,29 @@ - **窗口透明与模糊** — 设置 → Appearance → Window;对所有主题生效,*Follow theme* 恢复主题自带的 `opacity` / `blur` - **CJK / 输入法输入** +## 字体 + +- **内置 Hack** —— 打包进二进制,默认配置在各平台渲染完全一致,不依赖系统安装 +- **主字体 + 有序 fallback** —— `config.json` 里的 `font_family` 和 `font_fallbacks`;可选 `font_family_bold` / `font_family_italic` 指定独立字面,`font_features` 透传 OpenType 特性(上下文连字默认关闭) +- **默认列表按平台分支** —— fallback 只写宿主系统真正自带的字体(macOS 用 PingFang SC / Apple Color Emoji,Windows 用 Microsoft YaHei / Segoe UI Emoji,Linux 用 Noto)。这些名字也会追加到你手写的列表后面,所以在别的平台写出来的 `config.json` 一样能落地 + +### 中文与两列网格 + +一个格子等于主字体的一个 advance,宽字符(CJK)被钉死在正好两格上。所以中文 +fallback 只有在**汉字 advance 等于主字体西文 advance 的两倍**时,才能严丝合缝地 +填满自己的槽。 + +内置 Hack 的 advance 是 0.60205em,两格就是 1.2041em —— 而系统自带的中文字体 +(Microsoft YaHei、PingFang SC、Noto Sans CJK)全都是 1.0em。这些字形在槽里左 +对齐,多出来的约 0.2em 就变成每个字右边的一道空隙。 + +[Maple Mono NF CN](https://github.com/subframe7536/maple-font) 在所有平台都排在 +第一位正是因为这个 —— 西文 0.6em、中文 1.2em,对上 Hack 正好两格。它只按名字引 +用,不打包(每字重约 20MB):装上即生效,不用改配置。 + +想让中文排得**紧**而不只是均匀,要换的是主字体:选一个 advance 为 0.5em 的 +(比如 Sarasa Mono SC 更纱黑体等宽),两格就正好 1.0em。 + ## Coding agent tty7 能识别 pane 里跑着的第三方 coding agent(Claude Code、Codex、Gemini CLI、 diff --git a/src/core/config.rs b/src/core/config.rs index d136c30e..02a2ee30 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -495,6 +495,70 @@ pub struct ShellConfig { pub args: Vec, } +/// The default fallback chain for the platform we were built for. +/// +/// Fallbacks are resolved by *family name* against installed fonts, so a list +/// that reads well on one OS can miss entirely on another: every name in the +/// original list (Menlo, Apple Color Emoji) shipped with macOS, which left +/// Windows and Linux with a chain that matched nothing and fell straight +/// through to the platform's own cascade. +/// +/// That fall-through is not merely cosmetic. `element.rs` pins each wide cell +/// to `2 × cell_width`, and with the bundled Hack primary a cell is 0.60205em, +/// so a two-column slot is 1.2041em. The OS cascade serves a *1.0em* CJK face +/// (Microsoft YaHei, PingFang, Noto Sans CJK), and `force_width` left-aligns — +/// the ideograph hugs the left of its slot and dumps the whole 0.2em remainder +/// on the right. Naming a 1.2em face first (Maple Mono NF CN: 0.6em Latin, +/// 1.2em CJK — an exact two-cell fit) keeps the ink centered instead. +/// +/// Maple is referenced by name only, never bundled — it is ~20MB per weight. +/// Users who lack it land on the stock CJK face below, which still renders; +/// it just carries the left-hugging tracking described above. +pub fn default_font_fallbacks() -> Vec { + let names: &[&str] = if cfg!(target_os = "macos") { + &[ + "Menlo", + "Hasklug Nerd Font Mono", + "Maple Mono NF CN", + "PingFang SC", + "Apple Color Emoji", + ] + } else if cfg!(target_os = "windows") { + &[ + "Maple Mono NF CN", + "Cascadia Mono", + "Microsoft YaHei", + "Segoe UI Emoji", + ] + } else { + &[ + "Maple Mono NF CN", + "DejaVu Sans Mono", + "Noto Sans CJK SC", + "Noto Color Emoji", + ] + }; + names.iter().map(|n| n.to_string()).collect() +} + +/// Stock faces this platform is expected to ship, appended to whatever the user +/// configured (see `terminal::view::fallback_chain`). +/// +/// [`default_font_fallbacks`] only helps a *fresh* config. Anyone who already +/// has a `config.json` carries the old macOS-only list forever, so the same +/// repair has to happen at use time. Appending is safe by construction: a +/// fallback is consulted only once everything ahead of it has missed, so these +/// can never displace a face the user chose. +pub fn platform_last_resort_fallbacks() -> &'static [&'static str] { + if cfg!(target_os = "macos") { + &["PingFang SC", "Apple Color Emoji"] + } else if cfg!(target_os = "windows") { + &["Microsoft YaHei", "Segoe UI Emoji"] + } else { + &["Noto Sans CJK SC", "Noto Color Emoji"] + } +} + impl Default for Config { fn default() -> Self { // These defaults match the values that used to be hardcoded in @@ -504,14 +568,7 @@ impl Default for Config { // main.rs), so this default renders identically everywhere without // relying on a system install. Menlo stays as a safety net. font_family: "Hack".to_string(), - font_fallbacks: vec![ - "Menlo".to_string(), - "Hasklug Nerd Font Mono".to_string(), - // CJK 兜底:Hack 不含中文,缺字时退到等宽中文字体。仅按名字 - // 引用(不打包,该字体每字重 ~20MB),用户未安装则跳到下一项。 - "Maple Mono NF CN".to_string(), - "Apple Color Emoji".to_string(), - ], + font_fallbacks: default_font_fallbacks(), font_family_bold: None, font_family_italic: None, font_features: None, @@ -1268,6 +1325,42 @@ mod tests { assert_eq!(cfg.prefix, "ctrl-a"); } + /// A fresh config must name a CJK face the *host* platform actually ships. + /// The pre-fix list was macOS-only, so Windows and Linux wrote a chain that + /// matched nothing and left every ideograph to the OS cascade. + #[test] + fn default_font_fallbacks_are_platform_appropriate() { + let defaults = default_font_fallbacks(); + assert!(!defaults.is_empty()); + + for name in platform_last_resort_fallbacks() { + assert!( + defaults.iter().any(|f| f == name), + "default chain {defaults:?} omits stock face {name}" + ); + } + + // Maple Mono NF CN is the only face whose CJK advance (1.2em) is an exact + // two-cell fit against the bundled Hack primary, so it must be tried + // before the stock face on every platform. + let maple = defaults.iter().position(|f| f == "Maple Mono NF CN"); + let maple = maple.expect("the exact-fit CJK face must stay in the chain"); + for name in platform_last_resort_fallbacks() { + let stock = defaults.iter().position(|f| f == name).unwrap(); + assert!(maple < stock, "{name} must not preempt Maple Mono NF CN"); + } + + // macOS-only names must not leak into the other platforms' defaults. + if !cfg!(target_os = "macos") { + for name in ["Menlo", "Apple Color Emoji"] { + assert!( + !defaults.iter().any(|f| f == name), + "{name} ships only with macOS" + ); + } + } + } + #[test] fn config_deserialize_fills_missing_fields_from_defaults() { // Only one field present; the rest must fall back via #[serde(default)]. diff --git a/src/terminal/view.rs b/src/terminal/view.rs index e7ee2388..36b78b74 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -784,19 +784,34 @@ fn transcode_to_png(bytes: &[u8], format: gpui::ImageFormat) -> Option> Some(out) } -/// The font fallback chain: the user's configured list with the bundled "Hack" -/// pinned to the end. Hack ships inside the binary (`register_bundled_fonts`) -/// and covers the symbols prompt themes lean on — `❯`, `➜`, box drawing, the -/// sharp powerline wedges — with ink that fits a monospace advance. Without -/// this anchor, a custom `font_family` that lacks one of those codepoints -/// falls through the whole configured list into the OS cascade, which happily -/// serves a proportional glyph wider than the cell that `paint_glyphs`' -/// per-cell clip then truncates (issue #17's severed `➜`). +/// The font fallback chain: the user's configured list, then this platform's +/// stock faces, then the bundled "Hack" pinned to the end. +/// +/// Hack ships inside the binary (`register_bundled_fonts`) and covers the +/// symbols prompt themes lean on — `❯`, `➜`, box drawing, the sharp powerline +/// wedges — with ink that fits a monospace advance. Without this anchor, a +/// custom `font_family` that lacks one of those codepoints falls through the +/// whole configured list into the OS cascade, which happily serves a +/// proportional glyph wider than the cell that `paint_glyphs`' per-cell clip +/// then truncates (issue #17's severed `➜`). +/// +/// Hack carries no CJK at all (1548 codepoints mapped, zero ideographs), so on +/// a chain that names only macOS faces every Chinese character falls through to +/// the OS cascade too. [`platform_last_resort_fallbacks`] is appended for the +/// same reason the Hack anchor exists — to keep the last word ours rather than +/// the cascade's — and it repairs already-persisted configs, which a change to +/// `Config::default` alone would never reach. fn fallback_chain(family: &str, configured: &[String]) -> Vec { let mut chain = configured.to_vec(); - if family != "Hack" && !chain.iter().any(|f| f == "Hack") { - chain.push("Hack".to_string()); + let mut pin = |name: &str| { + if family != name && !chain.iter().any(|f| f == name) { + chain.push(name.to_string()); + } + }; + for name in crate::core::config::platform_last_resort_fallbacks() { + pin(name); } + pin("Hack"); chain } @@ -6040,29 +6055,68 @@ mod tests { let configured = vec!["Menlo".to_string(), "Apple Color Emoji".to_string()]; // A custom primary that may lack the prompt symbols → Hack appended. - assert_eq!( - fallback_chain("JetBrains Mono", &configured), - ["Menlo", "Apple Color Emoji", "Hack"] - ); + let chain = fallback_chain("JetBrains Mono", &configured); + assert_eq!(chain[..2], ["Menlo", "Apple Color Emoji"]); + assert_eq!(chain.last().unwrap(), "Hack"); // Hack as the primary face already covers everything it could add. - assert_eq!( - fallback_chain("Hack", &configured), - ["Menlo", "Apple Color Emoji"] - ); + let chain = fallback_chain("Hack", &configured); + assert_eq!(chain[..2], ["Menlo", "Apple Color Emoji"]); + assert!(!chain.iter().any(|f| f == "Hack")); // A user who lists Hack explicitly keeps their chosen position. let with_hack = vec!["Hack".to_string(), "Menlo".to_string()]; - assert_eq!(fallback_chain("SF Mono", &with_hack), ["Hack", "Menlo"]); + let chain = fallback_chain("SF Mono", &with_hack); + assert_eq!(chain[..2], ["Hack", "Menlo"]); // "Hack Nerd Font" is a different family — the bundled face still lands. assert_eq!( - fallback_chain("Hack Nerd Font", &[]), - ["Hack"], + fallback_chain("Hack Nerd Font", &[]).last().unwrap(), + "Hack", "a Hack-prefixed family name must not suppress the bundled anchor" ); } + /// Hack has no ideographs, so a chain naming only faces this OS lacks sends + /// every CJK glyph into the platform cascade — where a 1.0em face gets + /// left-aligned inside `element.rs`'s 1.2041em two-column slot. The stock + /// names have to be in the chain even for a config written before the fix. + #[test] + fn fallback_chain_appends_platform_stock_faces() { + let stock = crate::core::config::platform_last_resort_fallbacks(); + assert!(!stock.is_empty(), "every platform needs a CJK last resort"); + + // The pre-fix default: macOS-only names, nothing Windows/Linux can match. + let legacy = vec![ + "Menlo".to_string(), + "Hasklug Nerd Font Mono".to_string(), + "Maple Mono NF CN".to_string(), + "Apple Color Emoji".to_string(), + ]; + let chain = fallback_chain("Hack", &legacy); + for name in stock { + assert!( + chain.iter().any(|f| f == name), + "{name} missing from repaired chain {chain:?}" + ); + } + + // The user's own order is never displaced — stock faces land behind it. + assert_eq!(chain[..legacy.len()], legacy[..]); + + // Already-listed stock faces aren't duplicated. + let explicit = vec![stock[0].to_string()]; + let chain = fallback_chain("Hack", &explicit); + assert_eq!( + chain.iter().filter(|f| *f == stock[0]).count(), + 1, + "stock face duplicated in {chain:?}" + ); + + // A stock face chosen as the *primary* isn't re-added as its own fallback. + assert!(!fallback_chain(stock[0], &[]).iter().any(|f| f == stock[0])); + } + /// The wheel reaches the app only through the modes it negotiated: mouse /// reporting first, alternate scroll second, local scrollback otherwise. #[test]