From 46a2070d891b381634f1e10103f8c358fd20771d Mon Sep 17 00:00:00 2001 From: ddidderr Date: Mon, 12 Oct 2026 17:28:23 +0200 Subject: [PATCH] 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 --- Cargo.lock | 1 + crates/ghostty-cli/Cargo.toml | 1 + crates/ghostty-cli/src/list_themes/preview.rs | 33 +++++++++++-- crates/ghostty-cli/src/tui.rs | 47 ++++++++++++++++++- tests/list_themes_pty.rs | 7 +++ 5 files changed, 84 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a38cf3a..cd755e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1436,6 +1436,7 @@ dependencies = [ "ghostty-helpgen", "ghostty-input", "ghostty-os", + "ghostty-unicode", "ghostty-vt", "log", "ratatui", diff --git a/crates/ghostty-cli/Cargo.toml b/crates/ghostty-cli/Cargo.toml index e3d19dc..137e47a 100644 --- a/crates/ghostty-cli/Cargo.toml +++ b/crates/ghostty-cli/Cargo.toml @@ -18,6 +18,7 @@ ghostty-crash.workspace = true ghostty-font.workspace = true ghostty-input.workspace = true ghostty-os.workspace = true +ghostty-unicode.workspace = true ghostty-vt.workspace = true log = "0.4.34" ratatui = "0.30.2" diff --git a/crates/ghostty-cli/src/list_themes/preview.rs b/crates/ghostty-cli/src/list_themes/preview.rs index cc29f51..8b176f6 100644 --- a/crates/ghostty-cli/src/list_themes/preview.rs +++ b/crates/ghostty-cli/src/list_themes/preview.rs @@ -1570,7 +1570,7 @@ impl Win<'_, '_> { let x = self.area.x + col; let y = self.area.y + row; 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 { self.decos.borrow_mut().push(Deco { x, @@ -1583,6 +1583,33 @@ impl Win<'_, '_> { 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. fn print_segments(&self, segments: &[(&str, Style)], row: u16, col: u16) -> u16 { let mut col = col; @@ -1711,7 +1738,7 @@ fn run_loop(app: &mut Preview) -> Result<(), Error> { // Alternate screen, title, mouse reporting. write!( 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" )?; @@ -1767,7 +1794,7 @@ fn run_loop(app: &mut Preview) -> Result<(), Error> { let _ = terminal.show_cursor(); let _ = write!( 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)), ); let _ = tty.flush(); diff --git a/crates/ghostty-cli/src/tui.rs b/crates/ghostty-cli/src/tui.rs index 4df7615..e626a94 100644 --- a/crates/ghostty-cli/src/tui.rs +++ b/crates/ghostty-cli/src/tui.rs @@ -64,10 +64,49 @@ pub struct Row { rows: Vec>, } -/// 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); } diff --git a/tests/list_themes_pty.rs b/tests/list_themes_pty.rs index 8f43e9e..0aa0e06 100644 --- a/tests/list_themes_pty.rs +++ b/tests/list_themes_pty.rs @@ -380,3 +380,10 @@ fn load_file_error_exits_the_preview() { } 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]); +}