From 592058acc7a757ce780fe35eee9768ee1d3569df Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:23:10 +0800 Subject: [PATCH] feat(fonts): make macOS stroke thickening configurable (#720) Add a macOS-only `font_thicken` key (default true) and a Settings row under Appearance > Terminal text. When off, AppleFontSmoothing is pinned to 0 in this process's NSArgumentDomain before gpui's text system first reads it, so glyphs render at the face's own weight. The volatile domain is in-memory only: nothing is persisted and no other app is affected. gpui caches the preference in a OnceLock, so a change applies after a restart; no gpui fork change is needed. --- Cargo.toml | 5 +++- crates/tty7-core/src/core/config.rs | 23 ++++++++++++++++++ docs/customization/fonts.mdx | 30 +++++++++++++++++++++++ docs/reference/configuration.mdx | 1 + src/main.rs | 37 +++++++++++++++++++++++++++++ src/ui/app.rs | 6 +++++ src/ui/i18n/en.rs | 7 ++++++ src/ui/i18n/ja.rs | 7 ++++++ src/ui/i18n/mod.rs | 3 +++ src/ui/i18n/zh.rs | 7 ++++++ src/ui/settings.rs | 24 +++++++++++++++++++ 11 files changed, 149 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 07ab5898..10922d17 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -222,7 +222,10 @@ objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSResponder", " # `set_dock_icon_for_bare_binary` in main.rs. # NSUserNotification is the click-to-reveal notification path — see # `terminal::remote::macos_notify` for why it is driven directly. -objc2-foundation = { version = "0.3", features = ["NSData", "NSString", "NSUserNotification"] } +# NSUserDefaults (with NSDictionary / NSValue for the domain it takes) pins +# `AppleFontSmoothing` for this process when `font_thicken` is off — see +# `apply_font_thicken` in main.rs. +objc2-foundation = { version = "0.3", features = ["NSData", "NSDictionary", "NSString", "NSUserDefaults", "NSUserNotification", "NSValue"] } # Dictionary-based Chinese word segmentation for double-click selection # (`terminal::smart_select`), as a *fallback* where the OS has no tokenizer of diff --git a/crates/tty7-core/src/core/config.rs b/crates/tty7-core/src/core/config.rs index 4a273ec6..a79fe4af 100644 --- a/crates/tty7-core/src/core/config.rs +++ b/crates/tty7-core/src/core/config.rs @@ -135,6 +135,16 @@ pub struct Config { pub font_family_bold: Option, pub font_family_italic: Option, pub font_features: Option, + /// macOS only: let CoreGraphics font smoothing thicken glyph strokes — by + /// more the lighter the text, which is why light-on-dark looks bolder. + /// On by default, because it is what every macOS app draws with. + /// + /// Off pins `AppleFontSmoothing` to `0` for this process alone, so glyphs + /// are drawn at the face's own weight. gpui reads that preference once, the + /// first time it rasterizes text, so a change applies at the next launch. + /// Ignored elsewhere, where there is no such dilation to turn off. + #[serde(default = "default_true")] + pub font_thicken: bool, pub font_size: f32, pub line_height: f32, /// The interface's root font size, in pixels — everything outside the @@ -618,6 +628,7 @@ impl Default for Config { font_family_bold: None, font_family_italic: None, font_features: None, + font_thicken: true, font_size: 15.0, line_height: 1.4, ui_font_size: default_ui_font_size(), @@ -1484,6 +1495,18 @@ mod tests { assert!(default_cfg.font_features.is_none()); } + #[test] + fn font_thicken_defaults_on_and_reads_an_explicit_off() { + // On is what tty7 drew before the key existed, so a config that never + // names it must keep rendering exactly as it did. + let cfg: Config = serde_json::from_str("{}").unwrap(); + assert!(cfg.font_thicken); + assert!(Config::default().font_thicken); + + let cfg: Config = serde_json::from_str(r#"{"font_thicken":false}"#).unwrap(); + assert!(!cfg.font_thicken); + } + #[test] fn font_features_round_trip_to_integer_valued_json() { let features: FontFeatures = diff --git a/docs/customization/fonts.mdx b/docs/customization/fonts.mdx index 7d47f4fc..1e96158b 100644 --- a/docs/customization/fonts.mdx +++ b/docs/customization/fonts.mdx @@ -15,6 +15,7 @@ description: "The bundled default, fallback chains, ligatures, and why CJK needs | **Line height** | 1.4 | A multiple of the font size | | **Bold font** / **Italic font** | — | Distinct faces, when you want them | | **Font ligatures** | off | `calt`, `liga` and `clig` all stay off unless you ask | +| **Thicken strokes** | on | macOS only — see [Stroke weight on macOS](#stroke-weight-on-macos) | ## Hack is bundled @@ -71,6 +72,35 @@ on. Name them explicitly if you want both: } ``` +## Stroke weight on macOS + +macOS font smoothing thickens glyph strokes, and by how much depends on the +text colour: the lighter the text, the bolder it draws. That is why light text +on a dark theme can look heavier in tty7 than in a terminal that turns the +effect off, and why a thinner weight of the same family does not fully make up +for it. + +**Settings → Appearance → Terminal text → Thicken strokes** (`font_thicken`) +turns it off, so glyphs draw at the face's own weight whatever their colour: + +```json +{ + "font_thicken": false +} +``` + + + The change applies the next time tty7 starts: the preference is read once, + when text is first drawn. It covers tty7 alone — no other app, and not the + system-wide setting. + + +Leaving it on changes nothing, so an existing +`defaults write com.github.tty7 AppleFontSmoothing -int 0` keeps working. This +is the same switch as Ghostty's `font-thicken` and iTerm2's thin strokes, with +tty7 defaulting to the macOS look. Windows and Linux do not thicken strokes, so +the key does nothing there. + ## CJK and the two-column grid diff --git a/docs/reference/configuration.mdx b/docs/reference/configuration.mdx index 3aa90be5..6a19f43d 100644 --- a/docs/reference/configuration.mdx +++ b/docs/reference/configuration.mdx @@ -35,6 +35,7 @@ failing the file. | `font_family_bold` | string | — | A distinct bold face. | | `font_family_italic` | string | — | A distinct italic face. | | `font_features` | object | — | OpenType tags, e.g. `{"calt": true, "liga": 1}`. Four alphanumeric characters per tag. | +| `font_thicken` | bool | `true` | macOS: let font smoothing thicken strokes, light text most. `false` draws glyphs at their own weight. Applies after a restart. | | `font_size` | number | `15` | Terminal text size in px (4–256). | | `line_height` | number | `1.4` | Multiple of the font size (0.5–4). | | `ui_font_size` | number | `16` | The interface's root size in px (12–24). | diff --git a/src/main.rs b/src/main.rs index a5793199..8a9500df 100644 --- a/src/main.rs +++ b/src/main.rs @@ -441,6 +441,41 @@ fn set_dock_icon_for_bare_binary() { } } +/// Turns CoreGraphics' stroke thickening off for this process when +/// `font_thicken` is off; with it on there is nothing to do, and whatever the +/// system (or a hand-written `defaults write`) says stands. +/// +/// gpui decides whether to dilate a glyph from `AppleFontSmoothing`, read with +/// `CFPreferencesCopyAppValue` the first time it rasterizes text and cached for +/// the life of the process. So this has to land before the application exists, +/// and a change waits for the next launch. +/// +/// The value goes into the argument domain — the volatile one that +/// `-AppleFontSmoothing 0` on the command line would fill. It outranks both this +/// app's persisted defaults and the global domain, and it lives only in this +/// process's memory: nothing reaches disk, so no other app sees it and a later +/// launch with the key back on does not inherit it. +#[cfg(target_os = "macos")] +fn apply_font_thicken(thicken: bool) { + use objc2_foundation::{ + NSArgumentDomain, NSMutableCopying, NSNumber, NSUserDefaults, ns_string, + }; + + if thicken { + return; + } + let defaults = NSUserDefaults::standardUserDefaults(); + // SAFETY: a Foundation constant, initialized before `main` runs. + let domain = unsafe { NSArgumentDomain }; + // Merged into, not replaced: the domain already holds any `-Key value` + // pairs the process was launched with. + let arguments = defaults.volatileDomainForName(domain).mutableCopy(); + arguments.insert(ns_string!("AppleFontSmoothing"), &*NSNumber::new_i32(0)); + // SAFETY: keys are `NSString` and values property-list objects, which is + // the shape a defaults domain requires. + unsafe { defaults.setVolatileDomain_forName(&arguments, domain) }; +} + /// Delivers Finder document opens and LaunchServices URL opens after gpui has /// created the application. The native callback queues requests; UI state is /// then changed on gpui's application loop. @@ -593,6 +628,8 @@ fn main() { let (config, config_outcome) = crate::core::config::Config::load_with_outcome(); let gui_language = config.gui_language.clone(); + #[cfg(target_os = "macos")] + apply_font_thicken(config.font_thicken); // After the PATH enrichment above, which is what makes the candidate scan // see the user's real PATH rather than the stub a Finder launch inherits — diff --git a/src/ui/app.rs b/src/ui/app.rs index 3c77ebe9..b49b6264 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -3497,6 +3497,12 @@ impl Tty7App { self.update_config(cx, |cfg| cfg.show_tray_icon = on); } + /// Saved only: gpui reads the preference this drives once per process, so + /// it takes hold at the next launch (see `apply_font_thicken` in main.rs). + pub(crate) fn set_font_thicken(&mut self, on: bool, cx: &mut Context) { + self.update_config(cx, |cfg| cfg.font_thicken = on); + } + pub(crate) fn set_macos_option_as_alt(&mut self, on: bool, cx: &mut Context) { self.update_config(cx, |cfg| cfg.macos_option_as_alt = on); } diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index c6de1a62..449ac872 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -133,6 +133,10 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsFontLigaturesDesc => { "Enable common programming ligature features for terminal text." } + L10nKey::SettingsFontThicken => "Thicken strokes", + L10nKey::SettingsFontThickenDesc => { + "macOS font smoothing: draws text a little bolder, light text most. Takes effect after restarting tty7." + } L10nKey::SettingsCursor => "Cursor", L10nKey::SettingsCursorShape => "Cursor shape", L10nKey::SettingsCursorShapeDesc => "How the terminal cursor is drawn.", @@ -840,6 +844,9 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsSearchFocusFollowsMouseKeywords => "pane hover activate", L10nKey::SettingsSearchFontFamilyKeywords => "typeface monospace typography", L10nKey::SettingsSearchFontLigaturesKeywords => "typography glyph fira", + L10nKey::SettingsSearchFontThickenKeywords => { + "font smoothing thicken bold weight thin dilation antialiasing AppleFontSmoothing" + } L10nKey::SettingsSearchFontSizeKeywords => "typography text bigger smaller zoom", L10nKey::SettingsSearchForwardSshLoopbackLinksKeywords => { "ssh remote port tunnel localhost forward links ports autoforward detect" diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 7b7eb124..99342e7a 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -135,6 +135,10 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsFontLigaturesDesc => { "ターミナルテキストで一般的なプログラミング用リガチャー(合字)を有効にする" } + L10nKey::SettingsFontThicken => "ストロークを太くする", + L10nKey::SettingsFontThickenDesc => { + "macOS のフォントスムージング:文字をやや太く描画し、明るい文字ほど太くなる。tty7 の再起動後に反映" + } L10nKey::SettingsCursor => "カーソル", L10nKey::SettingsCursorShape => "カーソルの形状", L10nKey::SettingsCursorShapeDesc => "ターミナルカーソルの描画方法", @@ -874,6 +878,9 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsSearchFontLigaturesKeywords => { "タイポグラフィ グリフ fira font ligatures typography glyph fira" } + L10nKey::SettingsSearchFontThickenKeywords => { + "フォントスムージング 太字 細字 ウェイト font smoothing thicken bold weight thin AppleFontSmoothing" + } L10nKey::SettingsSearchFontSizeKeywords => { "タイポグラフィ 文字 拡大 縮小 ズーム font size typography text bigger smaller zoom" } diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index f3d6b4c7..07152f4d 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -181,6 +181,8 @@ l10n_keys! { SettingsItalicFontDesc, SettingsFontLigatures, SettingsFontLigaturesDesc, + SettingsFontThicken, + SettingsFontThickenDesc, SettingsCursor, SettingsCursorShape, SettingsCursorShapeDesc, @@ -642,6 +644,7 @@ l10n_keys! { SettingsSearchFocusFollowsMouseKeywords, SettingsSearchFontFamilyKeywords, SettingsSearchFontLigaturesKeywords, + SettingsSearchFontThickenKeywords, SettingsSearchFontSizeKeywords, SettingsSearchForwardSshLoopbackLinksKeywords, SettingsSearchGeminiKeywords, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index fe153ff8..df351e0d 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -119,6 +119,10 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsItalicFontDesc => "斜体文字使用的字体;默认由主字体合成。", L10nKey::SettingsFontLigatures => "字体连字", L10nKey::SettingsFontLigaturesDesc => "为终端文字启用常见的编程连字特性。", + L10nKey::SettingsFontThicken => "笔画加粗", + L10nKey::SettingsFontThickenDesc => { + "macOS 字体平滑:文字画得稍粗,浅色文字最明显。重启 tty7 后生效。" + } L10nKey::SettingsCursor => "光标", L10nKey::SettingsCursorShape => "光标形状", L10nKey::SettingsCursorShapeDesc => "终端光标的绘制方式。", @@ -769,6 +773,9 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsSearchFontLigaturesKeywords => { "字体连字 连字 字形 typography ligatures glyph fira" } + L10nKey::SettingsSearchFontThickenKeywords => { + "字体平滑 加粗 变细 字重 笔画 font smoothing thicken bold weight thin AppleFontSmoothing" + } L10nKey::SettingsSearchFontSizeKeywords => { "字号 字体大小 文字 放大 缩小 typography font size bigger smaller zoom" } diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 59bd1bf2..3cfca7a0 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -549,6 +549,12 @@ fn settings_search_entries() -> &'static [SearchEntry] { title: SettingsFontLigatures, keywords: SettingsSearchFontLigaturesKeywords, }, + #[cfg(target_os = "macos")] + SearchEntry { + section: Appearance, + title: SettingsFontThicken, + keywords: SettingsSearchFontThickenKeywords, + }, SearchEntry { section: Appearance, title: SettingsCursorShape, @@ -910,6 +916,7 @@ impl SearchEntry { L10nKey::SettingsItalicFont => "font_family_italic", L10nKey::SettingsUiFontFamily => "ui_font_family", L10nKey::SettingsFontLigatures => "font_features", + L10nKey::SettingsFontThicken => "font_thicken", L10nKey::SettingsOpacity => "window_opacity", L10nKey::SettingsBlur => "window_blur", L10nKey::SettingsBackdrop => "window_backdrop", @@ -946,6 +953,7 @@ impl SearchEntry { L10nKey::SettingsBoldFont => t(L10nKey::SettingsBoldFontDesc), L10nKey::SettingsItalicFont => t(L10nKey::SettingsItalicFontDesc), L10nKey::SettingsFontLigatures => t(L10nKey::SettingsFontLigaturesDesc), + L10nKey::SettingsFontThicken => t(L10nKey::SettingsFontThickenDesc), L10nKey::SettingsCursorShape => t(L10nKey::SettingsCursorShapeDesc), L10nKey::SettingsCursorBlink => t(L10nKey::SettingsCursorBlinkDesc), L10nKey::SettingsBackgroundImage => t(L10nKey::SettingsBackgroundImageDesc), @@ -1113,6 +1121,7 @@ impl SearchEntry { cfg.working_directory.path != defaults.working_directory.path } L10nKey::SettingsFontLigatures => cfg.font_features != defaults.font_features, + L10nKey::SettingsFontThicken => cfg.font_thicken != defaults.font_thicken, _ => false, } } @@ -3478,6 +3487,7 @@ impl Tty7App { let cfg = cx.global::(); let cursor_style = cfg.cursor_style; let cursor_blink = cfg.cursor_blink; + let font_thicken = cfg.font_thicken; let font_ligatures = cfg.font_features.as_ref().is_some_and(|features| { features.is_calt_enabled() == Some(true) || features @@ -3581,6 +3591,19 @@ impl Tty7App { .checked(font_ligatures) .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_font_ligatures(*on, cx))) .into_any_element(); + // macOS alone dilates glyph strokes, so elsewhere there is no row. + let thicken_row = cfg!(target_os = "macos").then(|| { + let thicken_switch = crate::ui::theme::switch("font-thicken", cx) + .checked(font_thicken) + .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_font_thicken(*on, cx))) + .into_any_element(); + self.settings_row( + t(L10nKey::SettingsFontThicken), + t(L10nKey::SettingsFontThickenDesc), + thicken_switch, + cx, + ) + }); let cursor_idx = match cursor_style { CursorStyle::Block => 0, @@ -3672,6 +3695,7 @@ impl Tty7App { ligature_switch, cx, )) + .when_some(thicken_row, |v, row| v.child(row)) .child(self.section_rule(cx)) .child(self.section_header(t(L10nKey::SettingsCursor), cx)) .child(self.settings_row(