mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 00:02:23 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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、
|
||||
|
||||
+101
-8
@@ -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
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user