diff --git a/crates/tui/src/tui/markdown_render.rs b/crates/tui/src/tui/markdown_render.rs index 6226d25ea..47926f150 100644 --- a/crates/tui/src/tui/markdown_render.rs +++ b/crates/tui/src/tui/markdown_render.rs @@ -29,6 +29,7 @@ use std::cell::Cell; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; +use unicode_segmentation::UnicodeSegmentation; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use crate::palette; @@ -379,10 +380,10 @@ fn wrap_plain_line(line: &str, width: usize, style: Style) -> Vec> let mut current_width = 0usize; let mut last_break_pos = None; - for ch in line.chars() { + for grapheme in line.graphemes(true) { loop { - let ch_width = char_display_width(ch, current_width); - if current_width + ch_width <= width || current.is_empty() { + let grapheme_width = markdown_grapheme_width(grapheme, current_width); + if current_width + grapheme_width <= width || current.is_empty() { break; } @@ -410,10 +411,10 @@ fn wrap_plain_line(line: &str, width: usize, style: Style) -> Vec> break; } - let ch_width = char_display_width(ch, current_width); - current.push(ch); - current_width += ch_width; - if ch.is_whitespace() { + let grapheme_width = markdown_grapheme_width(grapheme, current_width); + current.push_str(grapheme); + current_width += grapheme_width; + if grapheme.chars().all(char::is_whitespace) { last_break_pos = Some(current.len()); } } @@ -434,8 +435,8 @@ fn wrap_plain_line(line: &str, width: usize, style: Style) -> Vec> fn plain_display_width(text: &str) -> usize { let mut width = 0usize; - for ch in text.chars() { - width += char_display_width(ch, width); + for grapheme in text.graphemes(true) { + width += markdown_grapheme_width(grapheme, width); } width } @@ -646,7 +647,7 @@ fn render_line_with_links_tagged( continue; } // If the word itself is wider than an entire line, hard-break it at - // character boundaries so wrapping always makes progress (#1344, + // grapheme boundaries so wrapping always makes progress (#1344, // #1351). Without this, long URLs / paths / hashes were placed on // their own line whole and silently overflowed the right edge of // the transcript. @@ -666,9 +667,9 @@ fn render_line_with_links_tagged( // current line so the next word can pack onto it. let mut chunk = String::new(); let mut chunk_w = 0usize; - for ch in word.text.chars() { - let cw = ch.width().unwrap_or(1); - if chunk_w + cw > width && chunk_w > 0 { + for grapheme in word.text.graphemes(true) { + let grapheme_width = grapheme.width(); + if chunk_w + grapheme_width > width && chunk_w > 0 { let chunk = std::mem::take(&mut chunk); let mut links = Vec::new(); record_inline_link(&mut links, &word, 0, chunk_w); @@ -681,8 +682,8 @@ fn render_line_with_links_tagged( }); chunk_w = 0; } - chunk.push(ch); - chunk_w += cw; + chunk.push_str(grapheme); + chunk_w += grapheme_width; } if !chunk.is_empty() { record_inline_link(&mut current_links, &word, 0, chunk_w); @@ -1076,7 +1077,7 @@ fn split_table_cells(inner: &str) -> Vec { /// Word-wrap a single cell's text into one or more visual lines, each /// constrained to `col_width` display columns. Whitespace is the preferred -/// break point; words wider than `col_width` are hard-broken at character +/// break point; words wider than `col_width` are hard-broken at grapheme /// boundaries so wrapping always makes progress (no infinite loop on URLs /// or paths). Returns at least one segment. fn wrap_cell_text(cell: &str, col_width: usize) -> Vec { @@ -1091,7 +1092,13 @@ fn wrap_cell_text(cell: &str, col_width: usize) -> Vec { let word_w = word.width(); if current_w == 0 { if word_w > col_width { - push_word_breaking_chars(word, col_width, &mut current, &mut current_w, &mut lines); + push_word_breaking_graphemes( + word, + col_width, + &mut current, + &mut current_w, + &mut lines, + ); } else { current.push_str(word); current_w = word_w; @@ -1104,7 +1111,13 @@ fn wrap_cell_text(cell: &str, col_width: usize) -> Vec { lines.push(std::mem::take(&mut current)); current_w = 0; if word_w > col_width { - push_word_breaking_chars(word, col_width, &mut current, &mut current_w, &mut lines); + push_word_breaking_graphemes( + word, + col_width, + &mut current, + &mut current_w, + &mut lines, + ); } else { current.push_str(word); current_w = word_w; @@ -1252,22 +1265,22 @@ fn link_style() -> Style { .add_modifier(Modifier::UNDERLINED) } -/// Hard-wrap a code line at `width` display columns, preserving all -/// whitespace (including leading indentation). Unlike [`wrap_text`], this -/// does not split on word boundaries — code indentation is semantic. -/// Display-column width of a single character for the purposes of terminal -/// line-wrap calculations. +/// Display-column width of one extended grapheme for terminal line-wrap +/// calculations. /// -/// `UnicodeWidthChar::width` returns `None` for control characters, which -/// includes `\t`. A tab advances to the next 8-column tab stop, so we model -/// it as 8 columns here (a safe over-estimate that avoids terminal overflow). -/// Other control characters are counted as 1 column. -fn char_display_width(ch: char, col: usize) -> usize { - match ch { - '\t' => 8 - (col % 8), // advance to next 8-column tab stop - '\u{20E3}' => 1, // COMBINING ENCLOSING KEYCAP - _ => ch.width().unwrap_or(1), +/// A tab advances to the next 8-column tab stop. Single control characters +/// retain the previous one-column fallback; multi-codepoint emoji and +/// combining sequences use the same string-level width contract as Ratatui. +fn markdown_grapheme_width(grapheme: &str, col: usize) -> usize { + if grapheme == "\t" { + return 8 - (col % 8); // advance to next 8-column tab stop } + if let Some(ch) = grapheme.chars().next() + && ch.len_utf8() == grapheme.len() + { + return ch.width().unwrap_or(1); + } + grapheme.width() } /// Hard-wrap a code line at `width` display columns, preserving all @@ -1281,15 +1294,15 @@ fn wrap_code_line(line: &str, width: usize) -> Vec { let mut current = String::new(); let mut current_width = 0usize; - for ch in line.chars() { - let ch_width = char_display_width(ch, current_width); - if current_width + ch_width > width && !current.is_empty() { + for grapheme in line.graphemes(true) { + let grapheme_width = markdown_grapheme_width(grapheme, current_width); + if current_width + grapheme_width > width && !current.is_empty() { chunks.push(current); current = String::new(); current_width = 0; } - current.push(ch); - current_width += ch_width; + current.push_str(grapheme); + current_width += grapheme_width; } chunks.push(current); chunks @@ -1306,7 +1319,7 @@ fn wrap_text(text: &str, width: usize) -> Vec { for word in text.split_whitespace() { let word_width = word.width(); // If this single word is wider than the entire line, hard-break it - // at character boundaries so wrapping always makes progress + // at grapheme boundaries so wrapping always makes progress // (#1344, #1351). Without this, long URLs / paths / hashes overflow // the right edge silently. if word_width > width { @@ -1314,7 +1327,7 @@ fn wrap_text(text: &str, width: usize) -> Vec { lines.push(std::mem::take(&mut current)); current_width = 0; } - push_word_breaking_chars(word, width, &mut current, &mut current_width, &mut lines); + push_word_breaking_graphemes(word, width, &mut current, &mut current_width, &mut lines); continue; } let additional = if current.is_empty() { @@ -1345,26 +1358,26 @@ fn wrap_text(text: &str, width: usize) -> Vec { lines } -/// Push characters from `word` into `current`, flushing to `lines` when the -/// running display width would exceed `width`. Width is computed at the -/// `unicode-width` char level, matching the rest of the rendering pipeline. +/// Push graphemes from `word` into `current`, flushing to `lines` when the +/// running display width would exceed `width`. String-level Unicode width +/// matches Ratatui for emoji and combining sequences. /// Used by `wrap_text` and `wrap_cell_text` so a word longer than the /// allotted width never silently overflows the right edge. -fn push_word_breaking_chars( +fn push_word_breaking_graphemes( word: &str, width: usize, current: &mut String, current_width: &mut usize, lines: &mut Vec, ) { - for ch in word.chars() { - let cw = ch.width().unwrap_or(1); - if *current_width + cw > width && *current_width > 0 { + for grapheme in word.graphemes(true) { + let grapheme_width = grapheme.width(); + if *current_width + grapheme_width > width && *current_width > 0 { lines.push(std::mem::take(current)); *current_width = 0; } - current.push(ch); - *current_width += cw; + current.push_str(grapheme); + *current_width += grapheme_width; } } @@ -1592,15 +1605,17 @@ mod tests { } #[test] - fn char_display_width_tab_uses_tab_stop() { + fn markdown_grapheme_width_uses_tab_stop_and_string_width() { // At column 0 a tab fills to column 8. - assert_eq!(char_display_width('\t', 0), 8); + assert_eq!(markdown_grapheme_width("\t", 0), 8); // At column 4 a tab fills to column 8 (4 remaining). - assert_eq!(char_display_width('\t', 4), 4); + assert_eq!(markdown_grapheme_width("\t", 4), 4); // At column 8 a tab fills to the next stop at 16 (8 columns). - assert_eq!(char_display_width('\t', 8), 8); + assert_eq!(markdown_grapheme_width("\t", 8), 8); // Regular ASCII is 1. - assert_eq!(char_display_width('a', 0), 1); + assert_eq!(markdown_grapheme_width("a", 0), 1); + // A fully-qualified keycap is one two-column grapheme. + assert_eq!(markdown_grapheme_width("1\u{fe0f}\u{20e3}", 0), 2); } #[test] @@ -1932,7 +1947,7 @@ mod tests { // width was placed alone on a line and silently overflowed the right // edge of the transcript. Long URLs / paths / hashes / no-whitespace // CJK runs all hit this. The fix hard-breaks overlong words at - // character boundaries; these tests pin that across widths 40/60/80/120. + // grapheme boundaries; these tests pin that across widths 40/60/80/120. fn rendered_widths(rendered: &[Line<'static>]) -> Vec { rendered diff --git a/crates/tui/src/tui/ui_text.rs b/crates/tui/src/tui/ui_text.rs index c6c29e3ff..11b6647ae 100644 --- a/crates/tui/src/tui/ui_text.rs +++ b/crates/tui/src/tui/ui_text.rs @@ -1,6 +1,7 @@ //! Shared text helpers for TUI selection and clipboard workflows. use ratatui::text::{Line, Span}; +use unicode_segmentation::UnicodeSegmentation; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use crate::tui::history::HistoryCell; @@ -28,20 +29,21 @@ pub(crate) fn truncate_line_to_width(text: &str, max_width: usize) -> String { if max_width == 0 { return String::new(); } - if UnicodeWidthStr::width(text) <= max_width { + if text_display_width(text) <= max_width { return text.to_string(); } - // For very small budgets, take chars until we exceed the *display* width. + // For very small budgets, take whole graphemes until the next one would + // exceed the display width. Never split an emoji or combining sequence. if max_width <= 3 { let mut out = String::new(); let mut width = 0usize; - for ch in text.chars() { - let ch_width = char_display_width(ch); - if width + ch_width > max_width { + for grapheme in text.graphemes(true) { + let grapheme_width = grapheme_display_width(grapheme); + if width + grapheme_width > max_width { break; } - out.push(ch); - width += ch_width; + out.push_str(grapheme); + width += grapheme_width; } return out; } @@ -49,13 +51,13 @@ pub(crate) fn truncate_line_to_width(text: &str, max_width: usize) -> String { let mut out = String::new(); let mut width = 0usize; let limit = max_width.saturating_sub(3); - for ch in text.chars() { - let ch_width = char_display_width(ch); - if width + ch_width > limit { + for grapheme in text.graphemes(true) { + let grapheme_width = grapheme_display_width(grapheme); + if width + grapheme_width > limit { break; } - out.push(ch); - width += ch_width; + out.push_str(grapheme); + width += grapheme_width; } out.push_str("..."); out @@ -81,14 +83,14 @@ pub(crate) fn semantic_truncate(text: &str, max_width: usize) -> String { let mut cut_byte = 0usize; let mut last_word_end = None; let mut in_word = false; - for (byte_idx, ch) in text.char_indices() { - let ch_width = char_display_width(ch); - if width + ch_width > limit { + for (byte_idx, grapheme) in text.grapheme_indices(true) { + let grapheme_width = grapheme_display_width(grapheme); + if width + grapheme_width > limit { break; } - width += ch_width; - cut_byte = byte_idx + ch.len_utf8(); - if ch.is_whitespace() { + width += grapheme_width; + cut_byte = byte_idx + grapheme.len(); + if grapheme.chars().all(char::is_whitespace) { if in_word { last_word_end = Some(byte_idx); in_word = false; @@ -262,7 +264,7 @@ where } pub(crate) fn text_display_width(text: &str) -> usize { - text.chars().map(char_display_width).sum() + text.graphemes(true).map(grapheme_display_width).sum() } pub(super) fn slice_text(text: &str, start: usize, end: usize) -> String { @@ -272,14 +274,14 @@ pub(super) fn slice_text(text: &str, start: usize, end: usize) -> String { let mut out = String::new(); let mut col = 0usize; - for ch in text.chars() { - let ch_width = char_display_width(ch); - let ch_start = col; - let ch_end = col.saturating_add(ch_width); - if ch_end > start && ch_start < end { - out.push(ch); + for grapheme in text.graphemes(true) { + let grapheme_width = grapheme_display_width(grapheme); + let grapheme_start = col; + let grapheme_end = col.saturating_add(grapheme_width); + if grapheme_end > start && grapheme_start < end { + out.push_str(grapheme); } - col = ch_end; + col = grapheme_end; if col >= end { break; } @@ -290,13 +292,6 @@ pub(super) fn slice_text(text: &str, start: usize, end: usize) -> String { pub(super) fn char_display_width(ch: char) -> usize { if ch == '\t' { 4 - } else if ch == '\u{20E3}' { - // U+20E3 COMBINING ENCLOSING KEYCAP completes a keycap sequence - // (e.g. 1️⃣ = 1️⃣) that renders as 2 columns - // in terminals, but unicode-width reports it as 1 (base = 1, - // FE0F = 0, 20E3 = 0). Giving 20E3 a display width of 1 makes - // the total 2, matching the terminal. - 1 } else { // `width()` returns `None` for control/unassigned chars (default them to // one column so layout doesn't collapse) and `Some(0)` for genuinely @@ -307,6 +302,21 @@ pub(super) fn char_display_width(ch: char) -> usize { } } +/// Measure one extended grapheme using the same string-level Unicode rules as +/// Ratatui. String width intentionally differs from the sum of codepoint widths +/// for keycaps, ZWJ emoji, modifiers, and other terminal ligatures. +pub(super) fn grapheme_display_width(grapheme: &str) -> usize { + if grapheme == "\t" { + return 4; + } + if let Some(ch) = grapheme.chars().next() + && ch.len_utf8() == grapheme.len() + { + return char_display_width(ch); + } + UnicodeWidthStr::width(grapheme) +} + #[cfg(test)] mod tests { use super::*; @@ -386,8 +396,8 @@ mod tests { // combining marks or ZWJ emoji sequences.) assert_eq!(text_display_width("e\u{0301}"), 1); assert_eq!(text_display_width("cafe\u{0301}"), 4); - // ZWJ joiner itself is zero-width; the two emoji are 2 cols each. - assert_eq!(text_display_width("\u{1F469}\u{200D}\u{1F4BB}"), 4); + // The complete ZWJ emoji is one two-column grapheme, matching Ratatui. + assert_eq!(text_display_width("\u{1F469}\u{200D}\u{1F4BB}"), 2); } #[test] @@ -546,34 +556,38 @@ mod tests { } } - // --- keycap / enclosing-keycap regression guard (#4479) ------------------- - // Keycap sequences (digit + FE0F + U+20E3) render as 2 columns in Windows - // Terminal but were measured as 1 column by char_display_width. Proof that - // every code path listed below returns 2 after the fix. + // --- keycap / grapheme regression guard (#4479) --------------------------- + // Fully qualified keycap sequences render as two columns. Codepoint sums + // report one; the canonical string/grapheme contract reports two. #[test] fn text_display_width_treats_keycap_sequence_as_two_columns() { - assert_eq!(text_display_width("1\u{fe0f}\u{20e3}"), 2); - assert_eq!(text_display_width("9\u{fe0f}\u{20e3}"), 2); - // Without FE0F: digit + enclosing keycap still renders as a single - // keycap on many terminals (2 cols). - assert_eq!(text_display_width("1\u{20e3}"), 2); - assert_eq!(text_display_width("#\u{fe0f}\u{20e3}"), 2); - // Independent enclosing keycap codepoint: we give it 1 col to err - // on the safe side (extra space over cell collapse). - assert_eq!(text_display_width("\u{20e3}"), 1); + for keycap in [ + "1\u{fe0f}\u{20e3}", + "9\u{fe0f}\u{20e3}", + "#\u{fe0f}\u{20e3}", + ] { + assert_eq!(text_display_width(keycap), 2); + assert_eq!(text_display_width(keycap), UnicodeWidthStr::width(keycap)); + } + // Preserve unicode-width's distinction between fully-qualified emoji + // presentation and text/standalone combining-mark forms. + assert_eq!(text_display_width("1\u{20e3}"), 1); + assert_eq!(text_display_width("\u{20e3}"), 0); } #[test] fn slice_text_does_not_split_keycap_sequence() { - // Slicing a row that contains a keycap must keep the sequence intact. let row = "step 1\u{fe0f}\u{20e3} done"; - // Slice from column 0 to 6 display-width cols. - let sliced = slice_text(row, 0, 6); - assert!(sliced.contains("1\u{fe0f}\u{20e3}"), - "keycap was split: {sliced:?}"); - assert_eq!(UnicodeWidthStr::width(sliced.as_str()), 6, - "slice width {sliced:?} != 6"); + // The keycap occupies columns [5, 7). Any overlapping selection keeps + // the complete grapheme; no isolated FE0F/U+20E3 mark may escape. + for (start, end) in [(0, 7), (5, 6), (6, 7)] { + let sliced = slice_text(row, start, end); + assert!( + sliced.contains("1\u{fe0f}\u{20e3}"), + "range=({start}, {end}) split keycap: {sliced:?}" + ); + } } #[test] @@ -585,11 +599,15 @@ mod tests { "step 2\u{fe0f}\u{20e3} and 3\u{fe0f}\u{20e3} continue", ]; for text in &cases { - for budget in 0..=text.chars().count() + 4 { + for budget in 0..=text_display_width(text) + 4 { let out = truncate_line_to_width(text, budget); - let w = UnicodeWidthStr::width(out.as_str()); - assert!(w <= budget, - "budget={budget} text={text:?} -> {out:?} (width={w})"); + let width = text_display_width(&out); + assert!( + width <= budget, + "budget={budget} text={text:?} -> {out:?} (width={width})" + ); + assert!(!out.ends_with('\u{fe0f}')); + assert!(!out.starts_with('\u{20e3}')); } } } diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index d2a9e53b4..b391c90a2 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -40,7 +40,7 @@ use crate::tui::approval::{ }; use crate::tui::history::{GenericToolCell, HistoryCell, ToolCell, ToolRun, ToolStatus}; use crate::tui::scrolling::TranscriptLineMeta; -use crate::tui::ui_text::{char_display_width, text_display_width}; +use crate::tui::ui_text::{grapheme_display_width, text_display_width}; use crate::tui::underwater::ShellPhase; use ratatui::{ buffer::Buffer, @@ -3087,20 +3087,20 @@ fn apply_selection_to_line( let mut before = String::new(); let mut selected = String::new(); let mut after = String::new(); - let mut ch_col = current_col; + let mut grapheme_col = current_col; - for ch in span_text.chars() { - let ch_width = char_display_width(ch); - let ch_start = ch_col; - let ch_end = ch_col.saturating_add(ch_width); - if ch_end <= col_start { - before.push(ch); - } else if ch_start >= col_end { - after.push(ch); + for grapheme in span_text.graphemes(true) { + let grapheme_width = grapheme_display_width(grapheme); + let grapheme_start = grapheme_col; + let grapheme_end = grapheme_col.saturating_add(grapheme_width); + if grapheme_end <= col_start { + before.push_str(grapheme); + } else if grapheme_start >= col_end { + after.push_str(grapheme); } else { - selected.push(ch); + selected.push_str(grapheme); } - ch_col = ch_end; + grapheme_col = grapheme_end; } if !before.is_empty() { @@ -5200,6 +5200,21 @@ mod tests { assert_eq!(styled[1].content.as_ref(), " world"); } + #[test] + fn selection_keeps_keycap_grapheme_intact() { + let line = Line::from(Span::raw("A1\u{fe0f}\u{20e3}B")); + let selection_style = Style::default().bg(palette::SELECTION_BG); + + // Selecting the second display column of the two-column keycap must + // style the complete grapheme, never only FE0F/U+20E3. + let styled = apply_selection_to_line(&line, 2, 3, selection_style); + assert_eq!(styled.len(), 3); + assert_eq!(styled[0].content.as_ref(), "A"); + assert_eq!(styled[1].content.as_ref(), "1\u{fe0f}\u{20e3}"); + assert_eq!(styled[1].style.bg, Some(palette::SELECTION_BG)); + assert_eq!(styled[2].content.as_ref(), "B"); + } + #[test] fn composer_layout_helpers_stay_consistent() { let input = "line one wraps nicely\nline two wraps as well";