fix(terminal): enable ligatures with a persistent settings menu

This commit is contained in:
kuddev committed 2026-09-20 14:18:26 +08:00
1 parent b8fd6e5f5c
commit 7956989485
21 files changed
+619 -47

No files matched your search

@@ -0,0 +1,90 @@
# Ligatures on a fixed terminal grid
## Status
Implemented for issue #220.
## Context
The terminal enabled `calt` on fonts but shaped every cell separately. A font
could not form a ligature from adjacent cells. The theme's existing ligature
preference was also not consumed by the GPUI renderer.
Terminal hit testing, cursor movement, selection and PTY dimensions use fixed
columns. Natural text advances cannot become the source of terminal geometry.
The previous renderer deliberately avoided GPUI's `force_width` tolerance:
small deviations could alternate between natural and snapped positions after
an edit, visibly moving unrelated characters.
## Evidence
- [`element.rs`](../../../../../nebula_app/src/gpui_shell/terminal/element.rs)
owns viewport projection, colors, cursor and selection boundaries.
- [`ligatures.rs`](../../../../../nebula_app/src/gpui_shell/terminal/ligatures.rs)
maps the shaper's UTF-8 cluster indices to columns for ASCII spans. Multiple
glyphs can share an index, and a ligature can consume multiple source cells;
glyph counts cannot be used as column counts.
- The pinned GPUI API returns shared natural layouts. Mutating a cached layout
would also change other terminal views using it.
## Decision
The settings core owns `ligatures=on|off|theme`. Missing or invalid values and
reset use `on`, as requested for the product default. Explicit `theme` follows
the selected theme's existing boolean; built-in themes default to enabled.
The settings pane exposes a localized menu and uses the existing persistence
and global settings update path, including updates to open terminal views.
The renderer shapes contiguous, identically styled ASCII graphic cells together
when enabled. Whitespace, wide cells, combining sequences, colors, font styles,
cursor inversion, selection and formula projection delimit these spans. Other
text retains per-cell rendering. A disabled setting uses per-cell rendering
and explicitly disables `calt`, `liga` and `clig` for all four font faces.
For a shaped span, each cluster starts at its source column times cell width;
glyphs within the same cluster keep their relative offsets. The renderer makes
a private layout with the fixed span width, preserving the cached natural
layout, glyph identities, vertical positions and decorations. Neither path
passes `force_width`. Grid content, copying and cursor coordinates remain owned
by the terminal model.
## Rejected alternatives
- Merely toggling font features cannot join characters shaped in separate calls.
- Natural line layout or `force_width` would reintroduce variable positioning.
- Treating every glyph as a cell breaks fonts that substitute several glyphs
in one cluster or one glyph for multiple characters.
- General complex-script shaping requires a separate policy for wide cells,
combining sequences and bidi order; it is not inferred from ASCII offsets.
## Consequences
The implementation adds no dependency or thread. It scans visible cells and
copies glyph layouts only for joined spans. Shaping uses GPUI's existing line
cache; there is no additional persistent document or terminal cache. Costs are
linear in visible cells and shaped glyphs, but this is not a measured claim of
better frame time. Font-provided ligature outlines still determine their ink
bounds; proportional fonts do not become monospaced fonts.
## Validation
- Settings tests cover old/invalid configuration, all menu values, precedence,
persistence and reset to enabled.
- Rendered settings tests use the actual menu with mouse opening, keyboard
confirmation, cancellation, reopening and the effective global setting.
- Layout regressions cover style/overlay boundaries, combining and wide text,
multi-glyph clusters, deletion and fractional cell widths.
- A Linux headless test uses the real platform text system and bundled font to
compare enabled/disabled glyphs and verify cached layouts stay unchanged.
The normal GPUI test context uses a no-op text system and cannot prove this.
- These tests do not replace visual inspection of every user-supplied font or
Windows/macOS text-rasterization behavior.
## Supersedes
None.
## Revisit when
Supporting complex-script ligatures, changing GPUI's shaping/layout API, or
profiling demonstrates that copying visible glyph layouts is a material cost.
+5
View File
@@ -99,6 +99,11 @@
"background_cover_description": "The image will extend beneath the title bar, window buttons, tabs, and SSH sidebar. Low-contrast images may make controls harder to see; the interface retains a minimum-opacity safeguard."
},
"font": {
"ligatures": "Font ligatures",
"ligatures_description": "Combine characters such as -> and != when supported by the font. Applies immediately to open terminals.",
"ligatures_on": "On",
"ligatures_off": "Off",
"ligatures_theme": "Follow theme",
"ui_family": "Interface font",
"ui_family_description": "Enter an installed font family; leave empty for the default. Applies on Enter or leaving the field.",
"ui_size": "Interface font size",
+5
View File
@@ -99,6 +99,11 @@
"background_cover_description": "背景图会延伸到标题栏、窗口按钮、Tab 与 SSH 侧栏下方,低对比度图片可能影响操作可见性;界面仍会保留最低不透明度保护。"
},
"font": {
"ligatures": "字体连字",
"ligatures_description": "使用字体支持的连字显示 ->、!= 等字符组合,立即应用于已打开的终端。",
"ligatures_on": "开启",
"ligatures_off": "关闭",
"ligatures_theme": "跟随主题",
"ui_family": "界面字体",
"ui_family_description": "输入已安装的字体名称;留空使用默认字体。按回车或离开输入框后生效。",
"ui_size": "界面字号",
+4
View File
@@ -49,6 +49,7 @@ pub struct Settings {
pub font_bold_italic_family: String,
/// GPUI 逻辑像素(配置里是 pt,1pt = 4/3 px @96dpi)。
pub font_size_px: f32,
pub ligatures: bool,
/// 配置文件的基准字号,不含设置页/Ctrl+滚轮持久化的终端缩放。
/// 启动窗口按它定形,和旧壳的 `window_size` 契约一致。
pub base_font_size_px: f32,
@@ -251,6 +252,9 @@ impl Settings {
font_italic_family: secondary(&raw.font.italic),
font_bold_italic_family: secondary(&raw.font.bold_italic),
font_size_px,
ligatures: runtime
.ligatures
.enabled(resolved_theme.typography().map(|typography| typography.ligatures)),
base_font_size_px,
ui_font_size_px: runtime.ui_font_size_px.unwrap_or(base_font_size_px),
ui_font_family: runtime.ui_font_family.clone(),
@@ -758,6 +758,7 @@ impl SettingsPane {
"new_tab_position" => pick!(new_tab_position),
"windowing_behavior" => pick!(windowing_behavior),
"cell_width_mode" => pick!(cell_width_mode),
"ligatures" => pick!(ligatures),
"scrollback_lines" => Some((
cur.scrollback_lines != def.scrollback_lines,
def.scrollback_lines.to_string(),
@@ -126,6 +126,12 @@ impl SettingsPane {
),
)
.child(self.font_size_row(false, cx))
.child(self.select_row(
"ligatures",
language.text(crate::i18n::Message::SettingsFontLigatures),
language.text(crate::i18n::Message::SettingsFontLigaturesDescription),
cx,
))
.child(self.select_row(
"cell_width_mode",
language.pick("字体间距", "Character spacing"),
@@ -128,6 +128,13 @@ impl SettingsPane {
window,
cx,
);
add_select(
"ligatures",
nebula_settings::Ligatures::VALUES,
runtime.ligatures.settings_value(),
window,
cx,
);
add_select(
"cell_width_mode",
&["compact", "relaxed"],
@@ -67,6 +67,11 @@ pub(super) fn localized_select_labels(
language.pick("仅 Git", "Git only"),
language.pick("仅 SVN", "SVN only"),
],
"ligatures" => vec![
language.text(crate::i18n::Message::SettingsFontLigaturesOn),
language.text(crate::i18n::Message::SettingsFontLigaturesOff),
language.text(crate::i18n::Message::SettingsFontLigaturesTheme),
],
"cell_width_mode" => {
vec![language.pick("紧凑", "Compact"), language.pick("宽松", "Relaxed")]
},
@@ -27,7 +27,7 @@ pub(super) const SECTION_IDS: [&str; 10] = [
/// section that owns the control instead of merely filtering the current page.
pub(super) const SECTION_SEARCH_TERMS: [&str; 10] = [
"application app 应用 update 更新 version 版本 github support 支持",
"appearance 外观 theme 主题 custom 自定义 template 模板 import 导入 export 导出 font 字体 opacity 透明度 background 背景 cursor 光标 icon 图标 dim inactive panes 调暗非活动窗格 分屏变暗 scrollback scrolling speed history 回滚 滚动 速度 历史",
"appearance ligatures 连字 外观 theme 主题 custom 自定义 template 模板 import 导入 export 导出 font 字体 opacity 透明度 background 背景 cursor 光标 icon 图标 dim inactive panes 调暗非活动窗格 分屏变暗 scrollback scrolling speed history 回滚 滚动 速度 历史",
"profiles 配置文件 shell terminal 终端 completion 补全 startup 启动 ai message notifications toast alerts bell 提醒 通知 弹窗 消息 右下角 ai消息通知 ai 消息通知 ai消息弹窗 ai 消息弹窗 铃声 duration timeout persistent auto-dismiss 时长 秒 常驻 自动关闭",
"providers provider ai 供应商 模型 api",
"ssh host 主机 remote 远程 connection 连接",
@@ -37,6 +37,48 @@ fn test_runtime() -> RuntimeSettings {
RuntimeSettings::from_raw(&RawSettings::from_text(TEST_SETTINGS))
}
#[gpui::test]
fn ligature_menu_defaults_on_and_persists_keyboard_choices(cx: &mut TestAppContext) {
use crate::gpui_shell::config::Settings;
use nebula_settings::Ligatures;
let _fixture_guard = lock_theme_studio();
let _settings_guard = SettingsBytesGuard::capture();
std::fs::create_dir_all(nebula_settings::settings_dir()).unwrap();
std::fs::write(nebula_settings::settings_path(), TEST_SETTINGS).unwrap();
let (pane, mut window) = open_settings(cx);
pane.update(&mut window, |pane, cx| {
pane.active_section = 1;
cx.notify();
});
window.simulate_resize(size(px(1280.0), px(1800.0)));
draw(&mut window);
assert_eq!(RuntimeSettings::load().ligatures, Ligatures::On);
assert!(window.read(|cx| cx.global::<Settings>().ligatures));
for (preference, enabled) in [(Ligatures::Off, false), (Ligatures::Theme, true)] {
click("settings-select-ligatures", &mut window);
press("down", &mut window);
press("enter", &mut window);
assert_eq!(RuntimeSettings::load().ligatures, preference);
assert_eq!(window.read(|cx| cx.global::<Settings>().ligatures), enabled);
let reopened = window.update(|window, cx| cx.new(|cx| SettingsPane::new(window, cx)));
reopened.read_with(&mut window, |pane, cx| {
assert_eq!(pane.runtime.ligatures, preference);
assert_eq!(
pane.select_of("ligatures").unwrap().read(cx).selected_index(cx).unwrap().row,
if preference == Ligatures::Off { 1 } else { 2 }
);
});
}
click("settings-select-ligatures", &mut window);
press("up", &mut window);
press("escape", &mut window);
assert_eq!(RuntimeSettings::load().ligatures, Ligatures::Theme, "Escape cancels the choice");
pane.read_with(&mut window, |pane, _| {
assert_eq!(pane.setting_override("ligatures"), Some((true, "on".into())));
});
}
#[gpui::test]
fn scrolling_controls_persist_dropdown_and_slider_without_changing_existing_history(
cx: &mut TestAppContext,
+62 -25
View File
@@ -4,13 +4,10 @@
//! (`nebula_terminal::render::RenderSnapshot`)把可见网格快照成纯数据后立刻
//! 放锁;颜色解析(调色板 + OSC 覆盖表)与绘制都在锁外进行。
//!
//! 定位合同(与旧壳 `Renderer::draw_string` 相同):每个 cell 的字形单独
//! 塑形、从 `列号 × cell_width` 的整数 cell 原点起笔。绝不把整段文本交给
//! `force_width` 批量塑形——GPUI 只在字形偏离目标格超过 1px 时才吸附
//! (line_layout.rs),1px 内保留字体自然 advance;删除字符或分段变化引发
//! 整行重塑形时,每个字形都可能在"自然位置/吸附位置"间翻转,肉眼即为
//! 字符左右跳动。逐 cell 塑形时首字形天然落在 x=0,排版引擎没有移动
//! 字形的权力;单字符行在 GPUI 行缓存中按 (字符, 字体) 去重,命中率极高。
//! 定位合同:字形落在 `列号 × cell_width`,不使用 `force_width` 的
//! 1px 吸附容差。关闭连字时逐格塑形;开启时,同样式的相邻 ASCII 格
//! 共同塑形,再由 `ligatures` 把字形簇映射回固定列号。其余字符逐格
//! 绘制,组合字符跟随基字;编辑、光标和选区不能改变无关格的原点。
use std::collections::HashSet;
@@ -479,7 +476,7 @@ impl Element for TerminalElement {
}
}
let (font, bold_font, italic_font, bold_italic_font, font_size) = {
let (font, bold_font, italic_font, bold_italic_font, font_size, ligatures) = {
let view = self.view.read(cx);
(
view.font.clone(),
@@ -487,6 +484,7 @@ impl Element for TerminalElement {
view.font_italic.clone(),
view.font_bold_italic.clone(),
view.font_size,
view.ligatures,
)
};
// 全宽(CJK 等)bold run 的字形策略(设置 `cjk_bold_regular`,默认
@@ -584,12 +582,13 @@ impl Element for TerminalElement {
}
}
// 旧壳定位合同:每个 cell 单独塑形,从自己的整数 cell 原点起笔。
// 不传 force_width——单 cell 首字形天然落在 x=0,位置完全由列号决定,
// 组合字符(零宽)跟随基字自然排布,不会被按字形序号吸附到邻格。
// 光标反色在这里只是换色,不影响任何字形位置。
// 连字仅合并同一行内同样式的窄 ASCII 格,光标、选区和公式
// 投影边界仍逐格裁定。字形簇的原点始终由固定网格决定。
for seg in &snap.segments {
for cell in &seg.cells {
let mut cells = seg.cells.as_slice();
while let Some(cell) = cells.first() {
let remaining = cells;
cells = &cells[1..];
// 被公式覆盖的源格不画原文(公式直接落在卡底上,与旧壳
// CoverageMask 合同一致;计划失败的公式不进掩码、原文保留)。
if math_frame.covers(seg.row as usize, cell.col as usize) {
@@ -628,15 +627,53 @@ impl Element for TerminalElement {
bounds.origin.x + layout.cell_width * visual_col as f32,
bounds.origin.y + layout.line_height * seg.row as f32,
);
paint_cell_text(
window,
cx,
SharedString::from(cell.text.clone()),
run,
font_size,
origin,
layout.line_height,
);
let count = if ligatures && !seg.wide && !dashed_link {
super::ligatures::span_len(remaining, |next, offset| {
!math_frame.covers(seg.row as usize, next.col as usize)
&& math_frame.project_cell(
seg.row as usize,
next.col as usize,
layout.cols,
) == Some(visual_col + offset)
&& cursor_inverts(seg.row, next.col)
== cursor_inverts(seg.row, cell.col)
&& selected_foreground(seg.row, next.col)
== selected_foreground(seg.row, cell.col)
&& !dashed.contains(&(seg.row, next.col))
})
} else {
1
};
if count > 1 {
let text: String =
remaining[..count].iter().map(|cell| cell.text.as_str()).collect();
let shaped = super::ligatures::shape_ascii_span(
window.text_system(),
text.into(),
font_size,
run,
layout.cell_width,
);
let _ = shaped.paint(
origin,
layout.line_height,
gpui::TextAlign::Left,
None,
window,
cx,
);
cells = &remaining[count..];
} else {
paint_cell_text(
window,
cx,
SharedString::from(cell.text.clone()),
run,
font_size,
origin,
layout.line_height,
);
}
if dashed_link {
paint_dashed_underline(
window,
@@ -991,10 +1028,10 @@ fn paint_link_preview(
);
}
/// 唯一的 cell 文本落笔原语:单 cell 文本塑形后从调用方给定的整数 cell
/// 单格文本落笔原语:单 cell 文本塑形后从调用方给定的整数 cell
/// 原点起笔,不传 `force_width`(首字形天然在 x=0,位置只由列号决定)。
/// 网格、ghost、弹窗全部经由此处——定位合同只此一份,禁止绕开它直接
/// `shape_line` 网格对齐文本。
/// 网格的单格回退、ghost 和弹窗经由此处;ASCII 连字由 `ligatures`
/// 显式映射字形簇到列号。两条路径都不依赖自然 advance 定位后续格。
fn paint_cell_text(
window: &mut Window,
cx: &mut App,
@@ -0,0 +1,83 @@
//! Shape programming ligatures without giving the shaper control of the grid.
//!
//! Only contiguous ASCII graphic cells with identical styles share shaping.
//! Wide cells, combining sequences, whitespace and presentation boundaries keep
//! the existing per-cell path. Glyph cluster indices map back to fixed columns;
//! no `force_width` tolerance or natural advances accumulate along the row.
use std::sync::Arc;
use gpui::{LineLayout, Pixels, ShapedLine, SharedString, TextRun, WindowTextSystem};
use nebula_terminal::render::SnapCell;
fn ascii_graphic(cell: &SnapCell) -> bool {
cell.text.len() == 1 && cell.text.as_bytes()[0].is_ascii_graphic()
}
/// The caller adds viewport, selection, cursor and overlay boundaries. Keeping
/// this scan separate from painting lets a singleton use the original cache.
pub(super) fn span_len(cells: &[SnapCell], allowed: impl Fn(&SnapCell, usize) -> bool) -> usize {
let Some(first) = cells.first() else { return 0 };
if !ascii_graphic(first) || !allowed(first, 0) {
return 1;
}
cells
.iter()
.enumerate()
.take_while(|(offset, cell)| {
ascii_graphic(cell)
&& usize::from(cell.col) == usize::from(first.col) + offset
&& cell.fg == first.fg
&& cell.bg == first.bg
&& cell.bold == first.bold
&& cell.italic == first.italic
&& cell.underline == first.underline
&& cell.strikethrough == first.strikethrough
&& allowed(cell, *offset)
})
.count()
}
pub(super) fn shape_ascii_span(
text_system: &WindowTextSystem,
text: SharedString,
font_size: Pixels,
mut run: TextRun,
cell_width: Pixels,
) -> ShapedLine {
debug_assert!(text.bytes().all(|byte| byte.is_ascii_graphic()));
run.len = text.len();
let mut shaped = text_system.shape_line(text, font_size, &[run], None);
// The cached natural layout is shared with other views. Replace only this
// ShapedLine's Arc, preserving its text and decoration runs.
*shaped = Arc::new(grid_layout(&shaped, cell_width));
shaped
}
fn grid_layout(natural: &LineLayout, cell_width: Pixels) -> LineLayout {
let mut runs = natural.runs.clone();
let mut cluster = None;
for glyph in runs.iter_mut().flat_map(|run| &mut run.glyphs) {
let origin = match cluster {
Some((index, origin)) if index == glyph.index => origin,
_ => {
cluster = Some((glyph.index, glyph.position.x));
glyph.position.x
},
};
// Multiple glyphs may share one source index. Preserve their internal
// offsets, including zero-advance components; only the cluster moves.
glyph.position.x = cell_width * glyph.index as f32 + (glyph.position.x - origin);
}
LineLayout {
font_size: natural.font_size,
width: cell_width * natural.len as f32,
ascent: natural.ascent,
descent: natural.descent,
runs,
len: natural.len,
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,97 @@
use super::*;
use gpui::{FontId, GlyphId, ShapedGlyph, ShapedRun, point, px};
use nebula_terminal::vte::ansi::{Color, NamedColor};
fn cells(text: &str) -> Vec<SnapCell> {
text.chars()
.enumerate()
.map(|(col, character)| SnapCell {
col: col as u16,
text: character.to_string(),
fg: Color::Named(NamedColor::Foreground),
bg: Color::Named(NamedColor::Background),
bold: false,
italic: false,
underline: false,
strikethrough: false,
})
.collect()
}
#[test]
fn shaping_stops_at_cell_styles_gaps_and_non_ascii_text() {
assert_eq!(span_len(&cells("a->b"), |_, _| true), 4);
for text in ["a b", "a中b", "a🙂b"] {
assert_eq!(span_len(&cells(text), |_, _| true), 1);
}
for change in [
|cell: &mut SnapCell| cell.bold = true,
|cell: &mut SnapCell| cell.italic = true,
|cell: &mut SnapCell| cell.underline = true,
|cell: &mut SnapCell| cell.strikethrough = true,
|cell: &mut SnapCell| cell.fg = Color::Indexed(1),
|cell: &mut SnapCell| cell.bg = Color::Indexed(2),
|cell: &mut SnapCell| cell.col += 1,
|cell: &mut SnapCell| cell.text.push('\u{301}'),
] {
let mut cells = cells("a->b");
change(&mut cells[2]);
assert_eq!(span_len(&cells, |_, _| true), 2);
}
// The presentation adapter supplies the first cursor, selection or formula
// boundary. Text after that boundary cannot join the preceding ligature.
assert_eq!(span_len(&cells("a->b"), |_, offset| offset < 2), 2);
}
fn layout(indices_and_positions: &[(usize, f32)], len: usize) -> LineLayout {
LineLayout {
font_size: px(15.0),
width: px(len as f32 * 9.6),
ascent: px(12.0),
descent: px(3.0),
runs: vec![ShapedRun {
font_id: FontId(0),
glyphs: indices_and_positions
.iter()
.map(|&(index, x)| ShapedGlyph {
id: GlyphId(index as u32),
position: point(px(x), px(0.0)),
index,
is_emoji: false,
})
.collect(),
}],
len,
}
}
fn positions(line: &LineLayout) -> Vec<f32> {
line.runs.iter().flat_map(|run| &run.glyphs).map(|g| g.position.x.as_f32()).collect()
}
#[test]
fn ligature_components_move_together_and_the_next_cell_keeps_its_column() {
// A two-cell ligature has two components at byte 1, then the next glyph
// belongs to byte 3. Neither the glyph count nor natural advances are columns.
let natural = layout(&[(0, 0.0), (1, 9.6), (1, 13.6), (3, 28.8)], 4);
let aligned = grid_layout(&natural, px(9.0));
assert_eq!(positions(&aligned), [0.0, 9.0, 13.0, 27.0]);
assert_eq!(aligned.width, px(36.0));
assert_eq!(positions(&natural), [0.0, 9.6, 13.6, 28.8]);
}
#[test]
fn deleting_text_or_splitting_a_run_cannot_change_other_grid_origins() {
let before = layout(&[(0, 0.0), (1, 9.6), (2, 19.2), (3, 28.8)], 4);
let after = layout(&[(0, 0.0), (1, 9.6), (2, 19.2)], 3);
for width in [8.0, 9.0, 13.0 / 1.5, 14.0 / 1.5] {
let before = grid_layout(&before, px(width));
let after = grid_layout(&after, px(width));
assert_eq!(&positions(&before)[..3], positions(&after));
for (col, position) in positions(&after).iter().enumerate() {
assert_eq!(*position, col as f32 * width);
}
let suffix = grid_layout(&layout(&[(0, 0.0), (1, 9.6)], 2), px(width));
assert_eq!(positions(&suffix)[0] + 2.0 * width, positions(&before)[2]);
}
}
@@ -11,6 +11,7 @@ pub mod element;
mod event_mailbox;
mod inline_image;
pub mod keymap;
mod ligatures;
pub mod math_overlay;
mod molecule_overlay;
pub mod mouse_protocol;
+10 -4
View File
@@ -272,6 +272,7 @@ pub struct TerminalView {
pub font_italic: Font,
pub font_bold_italic: Font,
pub font_size: Pixels,
pub ligatures: bool,
cell_width_mode: nebula_settings::CellWidthModeName,
/// Cell offsets use physical pixels, matching the legacy crossfont
/// contract. They are applied after GPUI has shaped the actual face.
@@ -865,11 +866,16 @@ impl TerminalView {
// 样式/开关热切换即作废当前提示:缓存键留着会挡住新样式的首次重算。
self.suggest.clear_completion_hints();
self.font = mono_font(&families[0], FontWeight::NORMAL, FontStyle::Normal);
self.font_bold = mono_font(&families[1], FontWeight::BOLD, FontStyle::Normal);
self.font_italic = mono_font(&families[2], FontWeight::NORMAL, FontStyle::Italic);
self.font_bold_italic = mono_font(&families[3], FontWeight::BOLD, FontStyle::Italic);
self.font =
mono_font(&families[0], FontWeight::NORMAL, FontStyle::Normal, settings.ligatures);
self.font_bold =
mono_font(&families[1], FontWeight::BOLD, FontStyle::Normal, settings.ligatures);
self.font_italic =
mono_font(&families[2], FontWeight::NORMAL, FontStyle::Italic, settings.ligatures);
self.font_bold_italic =
mono_font(&families[3], FontWeight::BOLD, FontStyle::Italic, settings.ligatures);
self.font_size = font_size;
self.ligatures = settings.ligatures;
self.cell_width_mode = settings.cell_width_mode;
self.font_offset_x = settings.font_offset_x;
self.font_offset_y = settings.font_offset_y;
@@ -64,6 +64,8 @@ impl TerminalView {
None,
),
};
let ligatures =
cx.try_global::<Settings>().map(|settings| settings.ligatures).unwrap_or(true);
let default_cursor_style = term_config.default_cursor_style;
let (cell_w, line_h) = Self::cell_metrics(window, cx);
// 像素口径与 viewport 上报一致(设备 px),避免首帧一次像素级差异。
@@ -214,11 +216,17 @@ impl TerminalView {
answers: crate::assistant_answer::AnswerInbox::default(),
answer_reader: None,
confirmation: super::super::confirmation::ConfirmationState::default(),
font: mono_font(&families[0], FontWeight::NORMAL, FontStyle::Normal),
font_bold: mono_font(&families[1], FontWeight::BOLD, FontStyle::Normal),
font_italic: mono_font(&families[2], FontWeight::NORMAL, FontStyle::Italic),
font_bold_italic: mono_font(&families[3], FontWeight::BOLD, FontStyle::Italic),
font: mono_font(&families[0], FontWeight::NORMAL, FontStyle::Normal, ligatures),
font_bold: mono_font(&families[1], FontWeight::BOLD, FontStyle::Normal, ligatures),
font_italic: mono_font(&families[2], FontWeight::NORMAL, FontStyle::Italic, ligatures),
font_bold_italic: mono_font(
&families[3],
FontWeight::BOLD,
FontStyle::Italic,
ligatures,
),
font_size,
ligatures,
cell_width_mode,
font_offset_x,
font_offset_y,
@@ -83,13 +83,86 @@ fn paste_confirmation_follows_execution_risk_not_volume() {
}
#[test]
fn terminal_font_explicitly_enables_maple_ligatures() {
let font = typography::mono_font(
crate::font_install::REQUIRED_FONT_FAMILY,
FontWeight::NORMAL,
FontStyle::Normal,
);
assert_eq!(font.features.tag_value_list(), &[("calt".to_owned(), 1)]);
fn all_font_faces_explicitly_control_ligatures_without_enabling_kerning() {
for enabled in [true, false] {
for weight in [FontWeight::NORMAL, FontWeight::BOLD] {
for style in [FontStyle::Normal, FontStyle::Italic] {
let font = typography::mono_font(
crate::font_install::REQUIRED_FONT_FAMILY,
weight,
style,
enabled,
);
assert_eq!(
font.features.tag_value_list(),
&[
("calt".to_owned(), u32::from(enabled)),
("liga".to_owned(), u32::from(enabled)),
("clig".to_owned(), u32::from(enabled)),
("kern".to_owned(), 0),
]
);
}
}
}
}
// Linux's headless platform uses the real text shaper with no display or GPU.
// GPUI's normal test context uses NoopTextSystem and cannot prove ligatures.
#[cfg(target_os = "linux")]
#[test]
fn real_bundled_font_shapes_ligatures_and_preserves_the_cached_natural_layout() {
use crate::gpui_shell::terminal::ligatures::shape_ascii_span;
use gpui::{Hsla, TextRun, WindowTextSystem, px};
let app = gpui_platform::headless();
let text_system = WindowTextSystem::new(app.text_system());
text_system
.add_fonts(vec![std::borrow::Cow::Borrowed(crate::font_install::REQUIRED_FONT_BYTES)])
.unwrap();
let run = |enabled| TextRun {
len: 4,
font: typography::mono_font(
crate::font_install::REQUIRED_FONT_FAMILY,
FontWeight::NORMAL,
FontStyle::Normal,
enabled,
),
color: Hsla::default(),
background_color: None,
underline: None,
strikethrough: None,
};
let glyph_ids = |line: &gpui::ShapedLine| {
line.runs.iter().flat_map(|run| &run.glyphs).map(|glyph| glyph.id).collect::<Vec<_>>()
};
for sample in ["a->b", "a!=b", "a==b"] {
let natural = text_system.shape_line(sample.into(), px(15.0), &[run(true)], None);
let disabled = text_system.shape_line(sample.into(), px(15.0), &[run(false)], None);
assert_ne!(glyph_ids(&natural), glyph_ids(&disabled), "real font must join {sample}");
let before: Vec<_> =
natural.runs.iter().flat_map(|run| &run.glyphs).map(|glyph| glyph.position).collect();
let aligned = shape_ascii_span(&text_system, sample.into(), px(15.0), run(true), px(8.0));
assert_eq!(glyph_ids(&aligned), glyph_ids(&natural), "keep contextual substitutions");
assert_eq!(aligned.width, px(32.0));
let last = aligned
.runs
.iter()
.flat_map(|run| &run.glyphs)
.find(|glyph| glyph.index == 3)
.expect("cell after the ligature");
assert_eq!(last.position.x, px(24.0));
let cached = text_system.shape_line(sample.into(), px(15.0), &[run(true)], None);
assert_eq!(
before,
cached
.runs
.iter()
.flat_map(|run| &run.glyphs)
.map(|glyph| glyph.position)
.collect::<Vec<_>>()
);
}
}
#[test]
@@ -12,14 +12,24 @@ use nebula_settings::CellWidthModeName;
use super::Settings;
use crate::font_install::{REQUIRED_FONT_FAMILY, gpui_font_with_fallbacks};
/// GPUI's Windows backend skips font feature setup for an empty feature list.
/// Explicitly enabling `calt` keeps Maple's contextual ligatures consistent
/// across the platforms where the bundled face is used.
pub(super) fn mono_font(family: &str, weight: FontWeight, style: FontStyle) -> Font {
/// Set every ligature feature explicitly, including the off case: platform
/// defaults differ, and the Windows backend ignores an empty feature list.
pub(super) fn mono_font(
family: &str,
weight: FontWeight,
style: FontStyle,
ligatures: bool,
) -> Font {
let enabled = u32::from(ligatures);
Font {
weight,
style,
features: FontFeatures(std::sync::Arc::new(vec![("calt".to_owned(), 1)])),
features: FontFeatures(std::sync::Arc::new(vec![
("calt".to_owned(), enabled),
("liga".to_owned(), enabled),
("clig".to_owned(), enabled),
("kern".to_owned(), 0),
])),
..gpui_font_with_fallbacks(family)
}
}
@@ -85,7 +95,7 @@ fn measure_cell_metrics(
offset_y: f32,
line_height_multiplier: Option<f32>,
) -> (Pixels, Pixels) {
let font = mono_font(family, FontWeight::NORMAL, FontStyle::Normal);
let font = mono_font(family, FontWeight::NORMAL, FontStyle::Normal, false);
let sample = window.text_system().shape_line(
SharedString::new_static("M"),
font_size,
+8
View File
@@ -24,6 +24,8 @@ pub use custom_theme::{
meets_wcag_aa, wcag_contrast_ratio,
};
mod language;
mod ligatures;
pub use ligatures::Ligatures;
mod notifications;
pub use notifications::NotificationDuration;
mod quick_terminal;
@@ -963,6 +965,8 @@ pub struct RuntimeSettings {
/// **逻辑像素**(旧壳写盘语义:设置页 spinner 与 Ctrl+滚轮缩放持久化时
/// 已除以 scale factor)。`None` = 跟随 nebula.toml 的 `font.size`(pt)。
pub font_size_px: Option<f32>,
/// Enabled by default; explicit theme mode follows the selected theme.
pub ligatures: Ligatures,
pub cursor_shape: Option<CursorShapeName>,
pub cursor_blink: Option<bool>,
pub copy_on_select: bool,
@@ -1132,6 +1136,10 @@ impl RuntimeSettings {
ui_font_family: raw.value("ui_font_family").map(str::to_owned),
ui_font_size_px: raw.f32("ui_font_size").map(|size| size.clamp(10.0, 24.0)),
font_size_px: raw.f32("font_size").map(|size| size.clamp(4.0, 96.0)),
ligatures: raw
.value("ligatures")
.and_then(Ligatures::from_settings)
.unwrap_or_default(),
cursor_shape: raw.value("cursor_shape").and_then(CursorShapeName::from_settings),
cursor_blink: raw.bool_on("cursor_blink"),
copy_on_select: raw.bool_on("copy_on_select").unwrap_or(false),
+72
View File
@@ -0,0 +1,72 @@
//! Terminal ligature preference and theme precedence.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Ligatures {
#[default]
On,
Off,
Theme,
}
impl Ligatures {
pub const VALUES: &'static [&'static str] = &["on", "off", "theme"];
pub fn from_settings(value: &str) -> Option<Self> {
match value.trim() {
value if value.eq_ignore_ascii_case("on") => Some(Self::On),
value if value.eq_ignore_ascii_case("off") => Some(Self::Off),
value if value.eq_ignore_ascii_case("theme") => Some(Self::Theme),
_ => None,
}
}
pub fn settings_value(self) -> &'static str {
match self {
Self::On => "on",
Self::Off => "off",
Self::Theme => "theme",
}
}
pub fn enabled(self, theme: Option<bool>) -> bool {
match self {
Self::On => true,
Self::Off => false,
Self::Theme => theme.unwrap_or(true),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{RawSettings, RuntimeSettings, apply_updates};
#[test]
fn old_and_invalid_settings_enable_ligatures_by_default() {
for value in ["", "ligatures=", "ligatures=invalid"] {
let settings = RuntimeSettings::from_raw(&RawSettings::from_text(value));
assert_eq!(settings.ligatures, Ligatures::On);
assert!(settings.ligatures.enabled(Some(false)));
}
}
#[test]
fn preference_round_trips_and_only_theme_mode_defers_to_the_theme() {
for preference in [Ligatures::On, Ligatures::Off, Ligatures::Theme] {
let saved = apply_updates(
"font_family=Maple Mono Normal NF CN\ncustom=keep\n",
&[("ligatures", preference.settings_value().into())],
);
let settings = RuntimeSettings::from_raw(&RawSettings::from_text(&saved));
assert_eq!(settings.ligatures, preference);
assert_eq!(settings.font_family.as_deref(), Some("Maple Mono Normal NF CN"));
assert!(saved.contains("custom=keep"));
}
for theme in [None, Some(false), Some(true)] {
assert!(Ligatures::On.enabled(theme));
assert!(!Ligatures::Off.enabled(theme));
assert_eq!(Ligatures::Theme.enabled(theme), theme.unwrap_or(true));
}
}
}
+12
View File
@@ -16,6 +16,7 @@ const RESET_KEYS: &[&str] = &[
"ui_font_family",
"ui_font_size",
"font_size",
"ligatures",
"cursor_shape",
"cursor_blink",
"copy_on_select",
@@ -167,6 +168,17 @@ mod tests {
assert!(!restored.contains("notification_duration="));
}
#[test]
fn reset_enables_ligatures_even_after_following_a_theme() {
for value in ["off", "theme"] {
let restored = default_settings_text(&format!("ligatures={value}\ncustom=keep\n"));
assert_eq!(restored, "custom=keep\n");
let runtime = RuntimeSettings::from_raw(&RawSettings::from_text(&restored));
assert_eq!(runtime.ligatures, crate::Ligatures::On);
assert!(runtime.ligatures.enabled(Some(false)));
}
}
#[test]
fn reset_removes_all_overrides_and_keeps_user_data() {
let text = "# preferences\r\n THEME = Nord\r\ncopy_on_select=1\nkeybind=ctrl+x:Copy\nFONT_SIZE=30\nexecutor=custom\nblur=acrylic\nopacity=0.65\nbackground=#101216\ntheme_foreground=#d6dae6\ncustom_theme=my-night\nssh_hosts=saved-host\nai_provider=custom\nfuture_setting=keep\n";