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]