fix(cli): draw grapheme clusters whole in the theme preview

The +list-themes interactive preview dropped the second emoji of ZWJ
clusters (e.g. woman + ZWJ + rocket) in the search box. Zig's libvaxis
enables Unicode grapheme mode 2027 for its session and sizes text per
grapheme cluster; the Rust preview did neither, so the terminal split
the cluster at the ZWJ and the cursor column was computed from summed
per-codepoint widths (4 instead of 2).
- The preview sends ?2027h on entry and ?2027l on exit like vaxis.
- tui::gwidth follows vaxis's unicode width rules per grapheme cluster
  (VS15/VS16, emoji presentation, regional-indicator pairs) using the
  ghostty-unicode emoji table; the preview's print path uses it.
+list-colors and +list-keybinds pretty output (also using gwidth) stays
byte-identical to /usr/bin/ghostty at widths 40/80/132.

Test Plan:
- cargo test -p ghostty-cli --test list_themes_pty
  zwj_emoji_cluster_is_drawn_whole_in_search_box
- cargo test --workspace --no-fail-fast
This commit is contained in:
ddidderr committed 2026-10-12 17:28:23 +02:00
1 parent a7e8eb0ac6
commit 46a2070d89
5 files changed
+84 -5

No files matched your search

+45 -2
View File
@@ -64,10 +64,49 @@ pub struct Row {
rows: Vec<Vec<Cell>>,
}
/// Display width of a string (`Window.gwidth` with unicode mode on).
/// Display width of one grapheme (`vaxis.gwidth` `.unicode` method).
fn grapheme_width(g: &str) -> u16 {
let mut width = 0u16;
let (mut vs16, mut vs15, mut emoji, mut ri) = (false, false, false, 0u8);
for c in g.chars() {
match c {
'\u{fe0f}' => {
vs16 = true;
continue;
}
'\u{fe0e}' => {
vs15 = true;
continue;
}
_ => {}
}
if ghostty_unicode::emoji_table::table().get(u32::from(c)) {
emoji = true;
}
if ('\u{1F1E6}'..='\u{1F1FF}').contains(&c) {
ri += 1;
}
// 0x1f3fb..=0x1f3ff are wide in vaxis' EAW table too.
let w = c.width().unwrap_or(0);
width = width.max(u16::try_from(w).unwrap_or(2));
}
if vs15 {
width = width.max(1);
} else if vs16 || emoji || ri == 2 {
width = width.max(2);
}
width
}
/// Display width of a string (`Window.gwidth` with unicode mode on): the sum
/// of the widths of its grapheme clusters, as `vaxis.gwidth` computes it.
#[must_use]
pub fn gwidth(s: &str) -> u16 {
let w: usize = s.chars().map(|c| c.width().unwrap_or(0)).sum();
use unicode_segmentation::UnicodeSegmentation;
let w: usize = s
.graphemes(true)
.map(|g| usize::from(grapheme_width(g)))
.sum();
u16::try_from(w).unwrap_or(u16::MAX)
}
@@ -432,6 +471,10 @@ mod tests {
let r = row.print_segment("a\u{4e2d}b", Style::default(), 0);
assert_eq!(4, r.col);
assert_eq!(2, gwidth("\u{4e2d}"));
assert_eq!(2, gwidth("\u{1F469}\u{200D}\u{1F680}"));
assert_eq!(2, gwidth("\u{2764}\u{FE0F}"));
assert_eq!(2, gwidth("\u{1F1FA}\u{1F1F8}"));
assert_eq!(0, gwidth("\u{200B}"));
let r = row.print_segment("zz", Style::default(), 3);
assert!(!r.overflow);
}