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:
1 parent
03045f6ece
commit
f687a8c585
5 files changed
+84
-5
No files matched your search
Generated
+1
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user