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

Generated
+1
View File
@@ -1436,6 +1436,7 @@ dependencies = [
"ghostty-helpgen", "ghostty-helpgen",
"ghostty-input", "ghostty-input",
"ghostty-os", "ghostty-os",
"ghostty-unicode",
"ghostty-vt", "ghostty-vt",
"log", "log",
"ratatui", "ratatui",
+1
View File
@@ -18,6 +18,7 @@ ghostty-crash.workspace = true
ghostty-font.workspace = true ghostty-font.workspace = true
ghostty-input.workspace = true ghostty-input.workspace = true
ghostty-os.workspace = true ghostty-os.workspace = true
ghostty-unicode.workspace = true
ghostty-vt.workspace = true ghostty-vt.workspace = true
log = "0.4.34" log = "0.4.34"
ratatui = "0.30.2" ratatui = "0.30.2"
+30 -3
View File
@@ -1570,7 +1570,7 @@ impl Win<'_, '_> {
let x = self.area.x + col; let x = self.area.x + col;
let y = self.area.y + row; let y = self.area.y + row;
let max = usize::from(self.area.width - col); let max = usize::from(self.area.width - col);
let (nx, _) = self.buf.borrow_mut().set_stringn(x, y, text, max, style); let nx = self.set_graphemes(x, y, text, max, style);
if (link.is_some() || ul.is_some()) && nx > x { if (link.is_some() || ul.is_some()) && nx > x {
self.decos.borrow_mut().push(Deco { self.decos.borrow_mut().push(Deco {
x, x,
@@ -1583,6 +1583,33 @@ impl Win<'_, '_> {
nx - self.area.x nx - self.area.x
} }
/// Write `text` grapheme by grapheme, each sized with `vaxis.gwidth`
/// (not ratatui's width), stopping when a grapheme no longer fits.
fn set_graphemes(&self, x: u16, y: u16, text: &str, max: usize, style: Style) -> u16 {
let mut buf = self.buf.borrow_mut();
let mut remaining = max.min(usize::from(buf.area.right().saturating_sub(x)));
let mut cx = x;
for g in text.graphemes(true) {
if g.contains(char::is_control) {
continue;
}
let w = gw(g);
if w == 0 {
continue;
}
let Some(rest) = remaining.checked_sub(usize::from(w)) else {
break;
};
remaining = rest;
buf[(cx, y)].set_symbol(g).set_style(style);
for i in 1..w {
buf[(cx + i, y)].reset();
}
cx += w;
}
cx
}
/// `Window.print`: several segments one after another. /// `Window.print`: several segments one after another.
fn print_segments(&self, segments: &[(&str, Style)], row: u16, col: u16) -> u16 { fn print_segments(&self, segments: &[(&str, Style)], row: u16, col: u16) -> u16 {
let mut col = col; let mut col = col;
@@ -1711,7 +1738,7 @@ fn run_loop(app: &mut Preview) -> Result<(), Error> {
// Alternate screen, title, mouse reporting. // Alternate screen, title, mouse reporting.
write!( write!(
tty, tty,
"\x1b[?1049h\x1b]2;\u{1f47b} Ghostty Theme Preview \u{1f47b}\x1b\\\ "\x1b[?1049h\x1b[?2027h\x1b]2;\u{1f47b} Ghostty Theme Preview \u{1f47b}\x1b\\\
\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h" \x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h"
)?; )?;
@@ -1767,7 +1794,7 @@ fn run_loop(app: &mut Preview) -> Result<(), Error> {
let _ = terminal.show_cursor(); let _ = terminal.show_cursor();
let _ = write!( let _ = write!(
tty, tty,
"{}\x1b[?1006l\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?1049l", "{}\x1b[?1006l\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?2027l\x1b[?1049l",
Csi::Mode(Mode::ResetDecPrivateMode(theme_mode)), Csi::Mode(Mode::ResetDecPrivateMode(theme_mode)),
); );
let _ = tty.flush(); let _ = tty.flush();
+45 -2
View File
@@ -64,10 +64,49 @@ pub struct Row {
rows: Vec<Vec<Cell>>, 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] #[must_use]
pub fn gwidth(s: &str) -> u16 { 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) 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); let r = row.print_segment("a\u{4e2d}b", Style::default(), 0);
assert_eq!(4, r.col); assert_eq!(4, r.col);
assert_eq!(2, gwidth("\u{4e2d}")); 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); let r = row.print_segment("zz", Style::default(), 3);
assert!(!r.overflow); assert!(!r.overflow);
} }
+7
View File
@@ -380,3 +380,10 @@ fn load_file_error_exits_the_preview() {
} }
assert!(code.is_some(), "the preview must have exited"); assert!(code.is_some(), "the preview must have exited");
} }
#[test]
fn zwj_emoji_cluster_is_drawn_whole_in_search_box() {
let cluster = "\u{1F469}\u{200D}\u{1F680}";
let typed = format!("a{cluster}b");
check_trace(&[typed.as_bytes()], &[&typed]);
}