From acfbb7c85a44d28432512772e4070428df60cfd6 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Fri, 5 Jun 2026 00:41:12 +0300 Subject: fix: enable grapheme clusters in panes refs #243 --- src/ghostty/mod.rs | 42 ++++++++++++++++++++++++++++ src/pane.rs | 6 ++++ src/pane/terminal.rs | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+) (limited to 'src') diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs index a64fece1..08d0e117 100644 --- a/src/ghostty/mod.rs +++ b/src/ghostty/mod.rs @@ -161,6 +161,7 @@ pub const MODE_MOUSE_ALTERNATE_SCROLL: u16 = 1007; pub const MODE_MOUSE_SGR_PIXELS: u16 = 1016; pub const MODE_BRACKETED_PASTE: u16 = 2004; pub const MODE_SYNCHRONIZED_OUTPUT: u16 = 2026; +pub const MODE_GRAPHEME_CLUSTER: u16 = 2027; const KITTY_IMAGE_STORAGE_LIMIT_BYTES: u64 = 64 * 1024 * 1024; const APC_MAX_BYTES: usize = 16 * 1024 * 1024; @@ -678,6 +679,10 @@ impl Terminal { unsafe { ffi::ghostty_terminal_mode_set(self.raw, mode, value).into_result() } } + pub fn enable_grapheme_cluster_mode(&mut self) -> Result<(), Error> { + self.mode_set(MODE_GRAPHEME_CLUSTER, true) + } + pub fn kitty_keyboard_flags(&self) -> Result { let mut out = 0u8; unsafe { @@ -2961,6 +2966,43 @@ mod tests { } } + #[test] + fn grapheme_cluster_mode_keeps_issue_453_payload_safe() { + let mut terminal = Terminal::new(80, 3, 100).unwrap(); + terminal.enable_grapheme_cluster_mode().unwrap(); + terminal.write("README ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ ๐Ÿง‘โ€๐Ÿ’ป โœ… โšก ๆผขๅญ— cafรฉ eฬ ๐Ÿณ๏ธโ€๐ŸŒˆ ๐Ÿš€\r\n".as_bytes()); + + let mut render_state = RenderState::new().unwrap(); + render_state.update(&terminal).unwrap(); + + let mut row_iterator = RowIterator::new().unwrap(); + let mut rows = render_state + .populate_row_iterator(&mut row_iterator) + .unwrap(); + let mut row_cells = RowCells::new().unwrap(); + let mut codepoints = Vec::new(); + let mut text = String::new(); + + while rows.next() { + let mut cells = rows.populate_cells(&mut row_cells).unwrap(); + while cells.next() { + cells + .grapheme_text_into(&mut codepoints, &mut text) + .unwrap(); + } + } + } + + #[test] + fn grapheme_cluster_mode_is_default_and_survives_full_reset() { + let mut terminal = Terminal::new(80, 3, 100).unwrap(); + assert!(terminal.mode_get(MODE_GRAPHEME_CLUSTER).unwrap()); + + terminal.write(b"\x1bc"); + + assert!(terminal.mode_get(MODE_GRAPHEME_CLUSTER).unwrap()); + } + #[test] fn render_state_row_dirty_can_be_cleared_independently() { let mut terminal = Terminal::new(8, 3, 100).unwrap(); diff --git a/src/pane.rs b/src/pane.rs index 19556ffe..214eeef2 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -1284,6 +1284,9 @@ impl PaneRuntime { let (response_tx, _response_rx) = mpsc::channel::(1); let mut terminal = crate::ghostty::Terminal::new(cols, rows, scrollback_limit_bytes) .map_err(|e| std::io::Error::other(e.to_string()))?; + terminal + .enable_grapheme_cluster_mode() + .map_err(|e| std::io::Error::other(e.to_string()))?; if crate::kitty_graphics::is_enabled() { terminal .enable_kitty_graphics() @@ -1395,6 +1398,9 @@ impl PaneRuntime { let (response_tx, _response_rx) = mpsc::channel::(1); let mut terminal = crate::ghostty::Terminal::new(cols, rows, scrollback_limit_bytes) .map_err(|e| std::io::Error::other(e.to_string()))?; + terminal + .enable_grapheme_cluster_mode() + .map_err(|e| std::io::Error::other(e.to_string()))?; if crate::kitty_graphics::is_enabled() { terminal .enable_kitty_graphics() diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index f2fceaa8..cf8506ba 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -2432,6 +2432,8 @@ mod tests { #[test] fn ghostty_normalize_buffer_symbol_prefers_grapheme_width_when_metadata_disagrees() { const WIDE_GRAPHEME: &str = "๐Ÿ™‚"; + const FLAG_GRAPHEME: &str = "๐Ÿ‡ง๐Ÿ‡ท"; + const FAMILY_GRAPHEME: &str = "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ง"; const VS16_GRAPHEME: &str = "โš ๏ธ"; const EMOJI_GRAPHEME: &str = "๐Ÿ’ณ"; @@ -2443,6 +2445,14 @@ mod tests { ghostty_normalize_buffer_symbol("a", crate::ghostty::CellWide::Wide), " " ); + assert_eq!( + ghostty_normalize_buffer_symbol(FLAG_GRAPHEME, crate::ghostty::CellWide::Wide), + FLAG_GRAPHEME + ); + assert_eq!( + ghostty_normalize_buffer_symbol(FAMILY_GRAPHEME, crate::ghostty::CellWide::Wide), + FAMILY_GRAPHEME + ); assert_eq!( ghostty_normalize_buffer_symbol("โŒจ๏ธ", crate::ghostty::CellWide::Narrow), "โŒจ๏ธ" @@ -2465,6 +2475,74 @@ mod tests { ); } + fn render_cells_to_symbols( + terminal: &mut crate::ghostty::Terminal, + ) -> Vec<(crate::ghostty::CellWide, String)> { + let mut render_state = crate::ghostty::RenderState::new().unwrap(); + render_state.update(terminal).unwrap(); + + let mut row_iterator = crate::ghostty::RowIterator::new().unwrap(); + let mut rows = render_state + .populate_row_iterator(&mut row_iterator) + .unwrap(); + let mut row_cells = crate::ghostty::RowCells::new().unwrap(); + let mut grapheme_codepoints = Vec::new(); + let mut symbol_scratch = String::new(); + let mut out = Vec::new(); + + if rows.next() { + let mut cells = rows.populate_cells(&mut row_cells).unwrap(); + while cells.next() { + let wide = cells.wide().unwrap_or(crate::ghostty::CellWide::Narrow); + let symbol = ghostty_buffer_symbol_into( + &cells, + wide, + false, + &mut grapheme_codepoints, + &mut symbol_scratch, + ) + .unwrap() + .to_string(); + out.push((wide, symbol)); + } + } + + out + } + + #[test] + fn grapheme_cluster_mode_renders_flag_emoji_in_single_wide_cell() { + let mut terminal = crate::ghostty::Terminal::new(40, 1, 0).unwrap(); + terminal.enable_grapheme_cluster_mode().unwrap(); + terminal.write("๐Ÿ‡ง๐Ÿ‡ท".as_bytes()); + + let cells = render_cells_to_symbols(&mut terminal); + + assert!( + cells + .iter() + .any(|(wide, symbol)| *wide == crate::ghostty::CellWide::Wide && symbol == "๐Ÿ‡ง๐Ÿ‡ท"), + "expected a wide cell containing the full flag grapheme, got {cells:?}" + ); + } + + #[test] + fn grapheme_cluster_mode_renders_zwj_family_in_single_wide_cell() { + let mut terminal = crate::ghostty::Terminal::new(40, 1, 0).unwrap(); + terminal.enable_grapheme_cluster_mode().unwrap(); + terminal.write("๐Ÿ‘จ\u{200d}๐Ÿ‘ฉ\u{200d}๐Ÿ‘ง".as_bytes()); + + let cells = render_cells_to_symbols(&mut terminal); + + assert!( + cells + .iter() + .any(|(wide, symbol)| *wide == crate::ghostty::CellWide::Wide + && symbol == "๐Ÿ‘จ\u{200d}๐Ÿ‘ฉ\u{200d}๐Ÿ‘ง"), + "expected a wide cell containing the full ZWJ grapheme, got {cells:?}" + ); + } + #[test] fn pane_scrollback_controls_reach_top_without_ui_interference() { let (tx, _rx) = mpsc::channel(4); -- cgit v1.2.3