Merge pull request #195 from l0ng-ai/fix/windows-font-fallbacks

fix(fonts): name a CJK and emoji fallback the host platform actually ships
This commit is contained in:
l0ng-ai
2026-07-26 21:03:33 +08:00
committed by GitHub
5 changed files with 241 additions and 29 deletions
+17
View File
@@ -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
+25
View File
@@ -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,
+23
View File
@@ -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 EmojiWindows 用 Microsoft YaHei / Segoe UI EmojiLinux 用 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 agentClaude Code、Codex、Gemini CLI、
+101 -8
View File
@@ -495,6 +495,70 @@ pub struct ShellConfig {
pub args: Vec<String>,
}
/// 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<String> {
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)].
+75 -21
View File
@@ -784,19 +784,34 @@ fn transcode_to_png(bytes: &[u8], format: gpui::ImageFormat) -> Option<Vec<u8>>
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<String> {
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]