aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-05 00:41:12 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-05 00:42:14 +0300
commitacfbb7c85a44d28432512772e4070428df60cfd6 (patch)
treeb9adc71ef907fc865001a0c4fb976781e419c426 /src
parentb4b69b3539fc02d855c9b626cb205074e1541c78 (diff)
fix: enable grapheme clusters in panes
refs #243
Diffstat (limited to 'src')
-rw-r--r--src/ghostty/mod.rs42
-rw-r--r--src/pane.rs6
-rw-r--r--src/pane/terminal.rs78
3 files changed, 126 insertions, 0 deletions
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<u8, Error> {
let mut out = 0u8;
unsafe {
@@ -2962,6 +2967,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();
let mut render_state = RenderState::new().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::<Bytes>(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::<Bytes>(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 = "๐Ÿ’ณ";
@@ -2444,6 +2446,14 @@ mod tests {
" "
);
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);