diff --git a/src/core/config.rs b/src/core/config.rs index 18611eb8..f86e5454 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -9,7 +9,7 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::OnceLock; -use gpui::{Global, Hsla, Rgba, rgb}; +use gpui::{FontFeatures, Global, Hsla, Rgba, rgb}; use serde::{Deserialize, Serialize}; /// Top-level configuration. Stored as a GPUI global so any view can read it. @@ -26,6 +26,10 @@ pub struct Config { /// Optional distinct face for italic cells. `None` reuses `font_family` with a /// synthesized italic slant. pub font_family_italic: Option, + /// Optional OpenType font features for terminal text. When absent, tty7 keeps + /// terminal-safe defaults and disables contextual ligatures; when present, + /// this map is passed through to gpui as-is (for example `{ "calt": true }`). + pub font_features: Option, /// Base font size in pixels. pub font_size: f32, /// Line height as a multiple of the font size (e.g. 1.35 → a 13px font gets @@ -230,6 +234,7 @@ impl Default for Config { ], font_family_bold: None, font_family_italic: None, + font_features: None, font_size: 15.0, line_height: 1.4, theme: "light".to_string(), @@ -667,6 +672,23 @@ mod tests { assert_eq!(color_or(&Some("#ffffff".to_string()), 0x000000), white); } + #[test] + fn font_features_are_optional_and_parse_as_gpui_features() { + let cfg: Config = + serde_json::from_str(r#"{"font_features":{"calt":true,"liga":1}}"#).unwrap(); + let features = cfg.font_features.expect("font features should parse"); + assert_eq!(features.is_calt_enabled(), Some(true)); + assert!( + features + .tag_value_list() + .iter() + .any(|(tag, value)| tag == "liga" && *value == 1) + ); + + let default_cfg = Config::default(); + assert!(default_cfg.font_features.is_none()); + } + #[test] fn ansi_color_overrides_parse_and_default_independently() { let cfg: Config = diff --git a/src/terminal/element.rs b/src/terminal/element.rs index 56c492b6..46a0b29a 100644 --- a/src/terminal/element.rs +++ b/src/terminal/element.rs @@ -151,10 +151,13 @@ fn build_font(base: &Font, bold: bool, italic: bool) -> Font { FontStyle::Normal }; // Batched runs shape several chars in one line, where a programming font's - // contextual ligatures (`calt`, e.g. Fira Code's "->") would fuse cells into - // one glyph and break `force_width`'s one-glyph-per-column snapping. A cell - // grid can't ligate — Zed's terminal disables the same feature. - f.features = gpui::FontFeatures::disable_ligatures(); + // contextual ligatures (`calt`, e.g. Fira Code's "->") can fuse cells into + // one glyph and stress `force_width`'s one-glyph-per-column snapping. Keep + // the terminal-safe default unless the user explicitly configured OpenType + // features on the base font. + if f.features.tag_value_list().is_empty() { + f.features = gpui::FontFeatures::disable_ligatures(); + } f } @@ -1896,6 +1899,23 @@ mod tests { assert_eq!(seg_clip_width(true, 1, cell), px(20.)); } + #[test] + fn build_font_disables_ligatures_unless_features_are_configured() { + let font = build_font(&gpui::font("Test"), false, false); + assert_eq!(font.features.is_calt_enabled(), Some(false)); + + let mut configured = gpui::font("Test"); + configured.features = serde_json::from_str(r#"{"calt":true,"liga":1}"#).unwrap(); + let font = build_font(&configured, false, false); + assert_eq!(font.features.is_calt_enabled(), Some(true)); + assert!( + font.features + .tag_value_list() + .iter() + .any(|(tag, value)| tag == "liga" && *value == 1) + ); + } + #[test] fn segment_row_keeps_powerline_separators_solo() { // The native-draw intercept lives in the Solo arm of `paint_glyphs`; diff --git a/src/terminal/view.rs b/src/terminal/view.rs index a2a57ee3..af97e694 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -73,6 +73,9 @@ pub struct TerminalView { /// Optional distinct base face for italic cells (from `font_family_italic`). /// `None` → synthesize italic from `font`. pub font_italic: Option, + /// User-configured OpenType features for terminal fonts. `None` preserves + /// tty7's terminal-safe default (ligatures disabled); `Some` is opt-in. + font_features: Option, pub font_size: Pixels, /// Line height as a multiple of `font_size`; the element turns it into the /// concrete row height each frame. Sourced from `Config::line_height`. @@ -438,14 +441,21 @@ impl TerminalView { let fallbacks = fallback_chain(&font_family, &config.font_fallbacks); let font_size = px(config.font_size); let line_height_mul = config.line_height; + let font_features = config.font_features.clone(); let mut font = gpui::font(font_family); font.fallbacks = Some(gpui::FontFallbacks::from_fonts(fallbacks.clone())); + if let Some(features) = &font_features { + font.features = features.clone(); + } // Optional distinct bold/italic faces, each carrying the same fallback // chain so glyph coverage matches the primary face. let alt_font = |family: &Option| { family.as_ref().map(|f| { let mut af = gpui::font(f.clone()); af.fallbacks = Some(gpui::FontFallbacks::from_fonts(fallbacks.clone())); + if let Some(features) = &font_features { + af.features = features.clone(); + } af }) }; @@ -591,6 +601,7 @@ impl TerminalView { font, font_bold, font_italic, + font_features, font_size, line_height_mul, cell_width: px(8.), @@ -1647,6 +1658,9 @@ impl TerminalView { let fallbacks = self.font.fallbacks.clone(); let mut font = gpui::font(family); font.fallbacks = fallbacks; + if let Some(features) = &self.font_features { + font.features = features.clone(); + } self.font = font; cx.notify(); } @@ -1664,12 +1678,37 @@ impl TerminalView { cx.notify(); } + /// Apply OpenType features to the live terminal fonts. `None` restores the + /// terminal-safe default path, where the renderer disables contextual + /// ligatures while building paint faces. + pub fn set_font_features( + &mut self, + features: Option, + cx: &mut Context, + ) { + self.font_features = features.clone(); + let apply = |font: &mut Font| { + font.features = features.clone().unwrap_or_default(); + }; + apply(&mut self.font); + if let Some(font) = &mut self.font_bold { + apply(font); + } + if let Some(font) = &mut self.font_italic { + apply(font); + } + cx.notify(); + } + /// Build an alternate face from a family name, reusing the primary's /// fallbacks. `None` → `None` (fall back to synthesizing from `self.font`). fn alt_font(&self, family: Option) -> Option { family.map(|f| { let mut af = gpui::font(f); af.fallbacks = self.font.fallbacks.clone(); + if let Some(features) = &self.font_features { + af.features = features.clone(); + } af }) } diff --git a/src/ui/app.rs b/src/ui/app.rs index dbb60a68..d2584d65 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -10,6 +10,7 @@ use gpui_component::input::{InputEvent, InputState}; use gpui_component::select::{SearchableVec, SelectEvent, SelectState}; use gpui_component::slider::{SliderEvent, SliderState}; use gpui_component::{ActiveTheme as _, IndexPath, TitleBar}; +use std::sync::Arc; use crate::core::actions::*; use crate::core::config::{Config, NewTabPosition, ShellConfig, color_or, hsla_to_hex6}; @@ -100,6 +101,9 @@ pub struct Tty7App { /// tracked so the hot-reload observer can diff them like `font_family`. pub(crate) font_family_bold: Option, pub(crate) font_family_italic: Option, + /// Currently-applied OpenType features for terminal fonts. `None` means the + /// terminal-safe default (ligatures disabled). + pub(crate) font_features: Option, /// Keeps the `observe_global::` subscription alive for the app's /// lifetime so external edits to `config.json` (swapped in by the watcher in /// `main.rs`) live-apply font size / line height / family. Never read. @@ -150,6 +154,7 @@ impl Tty7App { let font_family = cx.global::().font_family.clone(); let font_family_bold = cx.global::().font_family_bold.clone(); let font_family_italic = cx.global::().font_family_italic.clone(); + let font_features = cx.global::().font_features.clone(); // Live-apply hot-reloaded config: the watcher in `main.rs` swaps the // `Config` global on every `config.json` change, which fires this. Theme // and colors are handled separately by `apply_theme`; here we cover the @@ -189,6 +194,7 @@ impl Tty7App { font_family, font_family_bold, font_family_italic, + font_features, _config_watch: config_watch, _keystroke_watch: keystroke_watch, palette: None, @@ -545,6 +551,29 @@ impl Tty7App { cx.notify(); } + /// Toggle terminal font ligatures through the generic `font_features` + /// config. On enables the common programming-font features; off restores + /// tty7's terminal-safe default (contextual ligatures disabled). + pub(crate) fn set_font_ligatures(&mut self, on: bool, cx: &mut Context) { + let features = on.then(|| { + gpui::FontFeatures(Arc::new(vec![ + ("calt".to_string(), 1), + ("liga".to_string(), 1), + ])) + }); + self.font_features = features.clone(); + for tab in &self.tabs { + for leaf in tab.pane.leaves() { + let features = features.clone(); + leaf.update(cx, |v, cx| v.set_font_features(features, cx)); + } + } + let cfg = cx.global_mut::(); + cfg.font_features = features; + cfg.save(); + cx.notify(); + } + /// Apply a picked ANSI color override and re-paint the terminal palette live. /// `None` clears the slot back to the active preset's ANSI value. pub(crate) fn set_ansi_color_override( @@ -1580,9 +1609,14 @@ impl Tty7App { /// writes it), and — because we never write the global or `save()` from here /// — closes the save → watch → reload loop that would otherwise oscillate. fn reload_from_config(&mut self, cx: &mut Context) { - let (font_size, line_height, font_family) = { + let (font_size, line_height, font_family, font_features) = { let cfg = cx.global::(); - (cfg.font_size, cfg.line_height, cfg.font_family.clone()) + ( + cfg.font_size, + cfg.line_height, + cfg.font_family.clone(), + cfg.font_features.clone(), + ) }; if font_size != self.font_size { self.font_size = font_size; @@ -1616,6 +1650,15 @@ impl Tty7App { } } } + if font_features != self.font_features { + self.font_features = font_features.clone(); + for tab in &self.tabs { + for leaf in tab.pane.leaves() { + let features = font_features.clone(); + leaf.update(cx, |v, cx| v.set_font_features(features, cx)); + } + } + } let (bold, italic) = { let cfg = cx.global::(); (cfg.font_family_bold.clone(), cfg.font_family_italic.clone()) diff --git a/src/ui/settings.rs b/src/ui/settings.rs index e6369511..45cec713 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -504,6 +504,13 @@ impl Tty7App { let cfg = cx.global::(); let cursor_style = cfg.cursor_style; let cursor_blink = cfg.cursor_blink; + let font_ligatures = cfg.font_features.as_ref().is_some_and(|features| { + features.is_calt_enabled() == Some(true) + || features + .tag_value_list() + .iter() + .any(|(tag, value)| tag == "liga" && *value != 0) + }); let colors = cfg.colors.clone(); let ansi_colors = cfg.ansi_colors.clone(); @@ -614,6 +621,10 @@ impl Tty7App { let font_family_control = font_dropdown(&font_select); let font_bold_control = font_dropdown(&font_bold_select); let font_italic_control = font_dropdown(&font_italic_select); + let ligature_switch = Switch::new("font-ligatures") + .checked(font_ligatures) + .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_font_ligatures(*on, cx))) + .into_any_element(); let cursor_idx = match cursor_style { CursorStyle::Block => 0, @@ -680,6 +691,12 @@ impl Tty7App { font_italic_control, cx, )) + .child(self.settings_row( + "Font ligatures", + "Enable common programming ligature features for terminal text.", + ligature_switch, + cx, + )) .child(self.section_rule(cx)) .child(self.section_header("Cursor", cx)) .child(self.settings_row(