返回 CodeWhale
ui_text.rs
根目录 / crates / tui / src / tui / ui_text.rs
1 //! Shared text helpers for TUI selection and clipboard workflows.
2
3 use ratatui::text::{Line, Span};
4 use unicode_segmentation::UnicodeSegmentation;
5 use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
6
7 use crate::tui::history::HistoryCell;
8 use crate::tui::osc8;
9
10 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
11 pub(crate) enum CopyLineSeparator {
12 None,
13 Space,
14 Newline,
15 }
16
17 impl CopyLineSeparator {
18 #[must_use]
19 pub(crate) const fn as_str(self) -> &'static str {
20 match self {
21 Self::None => "",
22 Self::Space => " ",
23 Self::Newline => "\n",
24 }
25 }
26 }
27
28 pub(crate) fn truncate_line_to_width(text: &str, max_width: usize) -> String {
29 if max_width == 0 {
30 return String::new();
31 }
32 if text_display_width(text) <= max_width {
33 return text.to_string();
34 }
35 // For very small budgets, take whole graphemes until the next one would
36 // exceed the display width. Never split an emoji or combining sequence.
37 if max_width <= 3 {
38 let mut out = String::new();
39 let mut width = 0usize;
40 for grapheme in text.graphemes(true) {
41 let grapheme_width = grapheme_display_width(grapheme);
42 if width + grapheme_width > max_width {
43 break;
44 }
45 out.push_str(grapheme);
46 width += grapheme_width;
47 }
48 return out;
49 }
50
51 let mut out = String::new();
52 let mut width = 0usize;
53 let limit = max_width.saturating_sub(3);
54 for grapheme in text.graphemes(true) {
55 let grapheme_width = grapheme_display_width(grapheme);
56 if width + grapheme_width > limit {
57 break;
58 }
59 out.push_str(grapheme);
60 width += grapheme_width;
61 }
62 out.push_str("...");
63 out
64 }
65
66 /// Truncate `text` to `max_width` display columns, preferring whole words.
67 pub(crate) fn semantic_truncate(text: &str, max_width: usize) -> String {
68 if max_width == 0 {
69 return String::new();
70 }
71 if text_display_width(text) <= max_width {
72 return text.to_string();
73 }
74
75 const ELLIPSIS: char = '…';
76 let ellipsis_width = char_display_width(ELLIPSIS);
77 let limit = max_width.saturating_sub(ellipsis_width);
78 if limit == 0 {
79 return ELLIPSIS.to_string();
80 }
81
82 let mut width = 0usize;
83 let mut cut_byte = 0usize;
84 let mut last_word_end = None;
85 let mut in_word = false;
86 for (byte_idx, grapheme) in text.grapheme_indices(true) {
87 let grapheme_width = grapheme_display_width(grapheme);
88 if width + grapheme_width > limit {
89 break;
90 }
91 width += grapheme_width;
92 cut_byte = byte_idx + grapheme.len();
93 if grapheme.chars().all(char::is_whitespace) {
94 if in_word {
95 last_word_end = Some(byte_idx);
96 in_word = false;
97 }
98 } else {
99 in_word = true;
100 }
101 }
102 if cut_byte == 0 {
103 return ELLIPSIS.to_string();
104 }
105
106 let mut body = if let Some(word_end) = last_word_end {
107 text[..word_end].trim_end()
108 } else {
109 text[..cut_byte].trim_end()
110 };
111 if body.is_empty() {
112 body = text[..cut_byte].trim_end();
113 }
114 let mut out = body.to_string();
115 out.push(ELLIPSIS);
116 out
117 }
118
119 pub(crate) fn semantic_truncate_with_affixes(
120 prefix: &str,
121 text: &str,
122 suffix: &str,
123 max_width: usize,
124 ) -> String {
125 let fixed_width = text_display_width(prefix) + text_display_width(suffix);
126 if fixed_width > max_width {
127 return semantic_truncate(&format!("{prefix}{text}{suffix}"), max_width);
128 }
129 format!(
130 "{prefix}{}{suffix}",
131 semantic_truncate_between_affixes(prefix, text, suffix, max_width)
132 )
133 }
134
135 pub(crate) fn semantic_truncate_between_affixes(
136 prefix: &str,
137 text: &str,
138 suffix: &str,
139 max_width: usize,
140 ) -> String {
141 let fixed_width = text_display_width(prefix) + text_display_width(suffix);
142 if fixed_width > max_width {
143 return String::new();
144 }
145 semantic_truncate(text, max_width - fixed_width)
146 }
147
148 pub(super) fn history_cell_to_text(cell: &HistoryCell, width: u16) -> String {
149 cell.transcript_lines(width)
150 .into_iter()
151 .map(line_to_string)
152 .collect::<Vec<_>>()
153 .join("\n")
154 }
155
156 fn line_to_string(line: Line<'static>) -> String {
157 let mut out = String::new();
158 append_spans_plain(line.spans.iter(), &mut out);
159 out
160 }
161
162 /// Convert a rendered transcript line to plain text, stripping OSC-8 link
163 /// escape sequences. The caller is responsible for shifting selection columns
164 /// to account for any visual-only rail prefix (see
165 /// `TranscriptViewCache::rail_prefix_width`).
166 pub(super) fn line_to_plain(line: &Line<'static>) -> String {
167 let mut out = String::new();
168 append_spans_plain(line.spans.iter(), &mut out);
169 out
170 }
171
172 fn append_spans_plain<'a, I>(spans: I, out: &mut String)
173 where
174 I: Iterator<Item = &'a Span<'a>>,
175 {
176 for span in spans {
177 if span.content.contains('\x1b') {
178 osc8::strip_into(&span.content, out);
179 } else {
180 out.push_str(span.content.as_ref());
181 }
182 }
183 }
184
185 pub(crate) fn text_display_width(text: &str) -> usize {
186 text.graphemes(true).map(grapheme_display_width).sum()
187 }
188
189 pub(super) fn slice_text(text: &str, start: usize, end: usize) -> String {
190 if end <= start {
191 return String::new();
192 }
193
194 let mut out = String::new();
195 let mut col = 0usize;
196 for grapheme in text.graphemes(true) {
197 let grapheme_width = grapheme_display_width(grapheme);
198 let grapheme_start = col;
199 let grapheme_end = col.saturating_add(grapheme_width);
200 if grapheme_end > start && grapheme_start < end {
201 out.push_str(grapheme);
202 }
203 col = grapheme_end;
204 if col >= end {
205 break;
206 }
207 }
208 out
209 }
210
211 pub(super) fn char_display_width(ch: char) -> usize {
212 match ch {
213 '\t' => 4,
214 // Enclosed Alphanumerics (U+2460-U+24FF), Dingbat Circled Digits
215 // (U+2776-U+2793), and Circled Numbers on Black Square (U+3248-U+324F)
216 // have East Asian Width "Ambiguous" but are rendered as 2 columns in
217 // CJK terminals. unicode-width's width() conservatively reports 1; we
218 // match the terminal. (#4479)
219 '\u{2460}'..='\u{24FF}' | '\u{2776}'..='\u{2793}' | '\u{3248}'..='\u{324F}' => 2,
220 _ => {
221 // `width()` returns `None` for control/unassigned chars (default them to
222 // one column so layout doesn't collapse) and `Some(0)` for genuinely
223 // zero-width chars — combining marks, ZWJ, zero-width spaces — which must
224 // stay 0 so display-width math (truncation, slicing, overflow, copy)
225 // matches what the terminal actually renders.
226 UnicodeWidthChar::width(ch).unwrap_or(1)
227 }
228 }
229 }
230
231 /// Measure one extended grapheme using the same string-level Unicode rules as
232 /// Ratatui. String width intentionally differs from the sum of codepoint widths
233 /// for keycaps, ZWJ emoji, modifiers, and other terminal ligatures.
234 ///
235 /// Keycap sequences (such as 1\u{fe0f}\u{20e3}) that lack an FE0F variation
236 /// selector still render as 2 columns in terminals, but unicode-width's
237 /// `grapheme.width()` only reports 1. We force 2 when U+20E3 is present in a
238 /// multi-codepoint grapheme. (#4479)
239 pub(super) fn grapheme_display_width(grapheme: &str) -> usize {
240 if grapheme == "\t" {
241 return 4;
242 }
243 if let Some(ch) = grapheme.chars().next()
244 && ch.len_utf8() == grapheme.len()
245 {
246 return char_display_width(ch);
247 }
248 // Keycap sequences always render as 2 columns. unicode-width's
249 // `width()` undercounts the non-FE0F variant to 1.
250 if grapheme.contains('\u{20e3}') {
251 return 2;
252 }
253 UnicodeWidthStr::width(grapheme)
254 }
255
256 #[cfg(test)]
257 mod tests {
258 use super::*;
259 use ratatui::text::Span;
260
261 #[test]
262 fn line_to_plain_strips_osc_8_wrapper() {
263 let wrapped = format!(
264 "\x1b]8;;{}\x1b\\{}\x1b]8;;\x1b\\",
265 "https://example.com", "https://example.com"
266 );
267 let line = Line::from(vec![
268 Span::raw("see "),
269 Span::raw(wrapped),
270 Span::raw(" for details"),
271 ]);
272 let text = line_to_plain(&line);
273 assert_eq!(text, "see https://example.com for details");
274 }
275
276 #[test]
277 fn line_to_plain_passes_through_plain_spans() {
278 let line = Line::from(vec![Span::raw("plain "), Span::raw("text")]);
279 let text = line_to_plain(&line);
280 assert_eq!(text, "plain text");
281 }
282
283 #[test]
284 fn line_to_plain_includes_all_spans() {
285 // Visual-only rail spans are stripped by the caller using
286 // TranscriptViewCache::rail_prefix_width — line_to_plain itself
287 // is a faithful span-to-string pass-through.
288 let line = Line::from(vec![Span::raw("\u{2502} "), Span::raw("tool output")]);
289 let text = line_to_plain(&line);
290 assert_eq!(text, "\u{2502} tool output");
291 }
292
293 #[test]
294 fn slice_text_respects_column_bounds() {
295 let text = "hello world";
296 assert_eq!(slice_text(text, 0, 5), "hello");
297 assert_eq!(slice_text(text, 6, 11), "world");
298 assert_eq!(slice_text(text, 0, 0), "");
299 assert_eq!(slice_text(text, 0, 100), text);
300 }
301
302 #[test]
303 fn slice_text_handles_multibyte_characters() {
304 let text = "a─b"; // U+2500 is 1 display column on supported terminals
305 assert_eq!(slice_text(text, 1, 2), "─");
306 assert_eq!(slice_text(text, 0, 3), text);
307 }
308
309 #[test]
310 fn slice_text_truncates_at_end() {
311 let text = "ab";
312 assert_eq!(slice_text(text, 1, 5), "b");
313 }
314
315 // --- Unicode / CJK / terminal-width QA (issue #3488) -------------------
316 // These exercise the production width helpers directly so the assertions
317 // track the same code path the renderer uses.
318
319 #[test]
320 fn text_display_width_counts_cjk_as_two_columns() {
321 assert_eq!(text_display_width("中文"), 4); // two wide glyphs
322 assert_eq!(text_display_width("Hello世界"), 9); // 5 ASCII + 2×2
323 // Full-width (ambiguous→wide) punctuation is two columns each.
324 assert_eq!(text_display_width(",。!?"), 8);
325 }
326
327 #[test]
328 fn text_display_width_treats_zero_width_marks_as_zero() {
329 // A combining mark adds no column: "e" + U+0301 renders as one cell.
330 // (Regression guard: the old `.max(1)` counted it as 1, over-reporting
331 // width and causing premature truncation / border drift on text with
332 // combining marks or ZWJ emoji sequences.)
333 assert_eq!(text_display_width("e\u{0301}"), 1);
334 assert_eq!(text_display_width("cafe\u{0301}"), 4);
335 // The complete ZWJ emoji is one two-column grapheme, matching Ratatui.
336 assert_eq!(text_display_width("\u{1F469}\u{200D}\u{1F4BB}"), 2);
337 }
338
339 #[test]
340 fn text_display_width_keeps_control_and_tab_widths() {
341 // Control chars still occupy a column (avoid layout collapse); tab = 4.
342 assert_eq!(text_display_width("a\u{0007}b"), 3);
343 assert_eq!(text_display_width("\t"), 4);
344 assert_eq!(text_display_width("\ta"), 5);
345 }
346
347 #[test]
348 fn truncate_line_to_width_respects_display_width_not_byte_len() {
349 // No truncation when the string already fits by display width.
350 assert_eq!(truncate_line_to_width("中文", 10), "中文");
351 // Oversized: reserve 3 cols for the ellipsis, fill the rest by width.
352 let out = truncate_line_to_width("中文测试", 7);
353 assert_eq!(out, "中文...");
354 assert_eq!(text_display_width(&out), 7);
355 // Never split a wide glyph across the boundary, and never emit U+FFFD.
356 let clipped = truncate_line_to_width("界界界界界", 5);
357 assert!(text_display_width(&clipped) <= 5);
358 assert!(!clipped.contains('\u{FFFD}'));
359 }
360
361 #[test]
362 fn semantic_truncate_prefers_word_boundaries() {
363 let out = semantic_truncate("hello world foo bar", 14);
364 assert_eq!(out, "hello world…");
365 assert!(text_display_width(&out) <= 14);
366 }
367
368 #[test]
369 fn semantic_truncate_falls_back_with_long_words_and_wide_glyphs() {
370 let long_word = semantic_truncate("supercalifragilistic", 8);
371 assert_eq!(long_word, "superca…");
372 assert!(text_display_width(&long_word) <= 8);
373
374 let cjk = semantic_truncate("中文测试文本", 7);
375 assert_eq!(cjk, "中文测…");
376 assert!(text_display_width(&cjk) <= 7);
377 }
378
379 #[test]
380 fn semantic_truncate_handles_empty_and_tiny_budgets() {
381 assert_eq!(semantic_truncate("", 10), "");
382 assert_eq!(semantic_truncate("hello", 0), "");
383 assert_eq!(semantic_truncate("hello", 1), "…");
384 }
385
386 #[test]
387 fn semantic_truncate_between_affixes_reserves_fixed_columns() {
388 let hint = semantic_truncate_between_affixes(
389 " > [ ] Prefix stability (",
390 "whether system/tools stayed cacheable",
391 ")",
392 49,
393 );
394 let row = format!(" > [ ] Prefix stability ({hint})");
395 assert_eq!(hint, "whether system/tools…");
396 assert!(text_display_width(&row) <= 49);
397 }
398
399 #[test]
400 fn slice_text_slices_cjk_by_display_column() {
401 // Columns: 中=[0,2) 文=[2,4) a=[4,5) b=[5,6)
402 let text = "中文ab";
403 assert_eq!(slice_text(text, 0, 2), "中");
404 assert_eq!(slice_text(text, 2, 4), "文");
405 assert_eq!(slice_text(text, 4, 6), "ab");
406 }
407
408 // --- New #3488 fixtures: CJK/wide-glyph truncation on selector-style rows.
409 // truncate_line_to_width is the production helper behind sidebar (file_tree),
410 // statusline (footer_ui), hotbar, and picker (mouse_ui) row rendering, so
411 // these exercise the same truncation path those surfaces use.
412
413 #[test]
414 fn truncate_line_to_width_full_width_cjk_lands_on_glyph_boundary() {
415 // Each Han glyph is two columns. With an odd budget the truncation must
416 // land on a whole-glyph boundary (reserving three columns for the
417 // ellipsis), never leaving a half-rendered wide cell or emitting U+FFFD.
418 let title = "项目报告结果"; // 6 glyphs, 12 columns
419 let out = truncate_line_to_width(title, 7);
420 // Budget 7 -> limit 4 columns -> two glyphs fit, then the ellipsis.
421 assert_eq!(out, "项目...");
422 assert_eq!(text_display_width(&out), 7);
423 // The kept prefix is composed only of whole wide glyphs (each 2 cols),
424 // proving the boundary glyph was dropped whole, not split.
425 let prefix = out.strip_suffix("...").expect("ellipsis present");
426 assert!(prefix.chars().all(|c| char_display_width(c) == 2));
427 assert!(!out.contains('\u{FFFD}'));
428 }
429
430 #[test]
431 fn truncate_line_to_width_mixed_ascii_cjk_row_keeps_ellipsis_within_budget() {
432 // A sidebar/selector row mixing an ASCII label with a CJK title, wider
433 // than the column budget, must truncate with a trailing ellipsis that
434 // still fits by display width and must not split a wide glyph.
435 let row = "Task: 数据库迁移任务 done"; // ASCII label + 7 Han glyphs
436 let budget = 12;
437 let out = truncate_line_to_width(row, budget);
438 assert!(out.ends_with("..."), "expected ellipsis, got {out:?}");
439 // Ellipsis-and-content fit within the budget by *display* width.
440 assert!(text_display_width(&out) <= budget);
441 // The non-ellipsis prefix stays within budget-minus-ellipsis, so the
442 // wide glyph on the boundary was dropped whole rather than half-drawn.
443 let prefix = out.strip_suffix("...").expect("ellipsis present");
444 assert!(text_display_width(prefix) <= budget - 3);
445 assert!(!out.contains('\u{FFFD}'));
446 // The semantic ASCII prefix survives truncation.
447 assert!(out.starts_with("Task:"));
448 }
449
450 #[test]
451 fn truncate_line_to_width_dense_cjk_selector_row_survives_narrow_widths() {
452 // Picker/selector rows degrade through truncate_line_to_width when the
453 // terminal is narrow. A dense row with a leading marker glyph and CJK
454 // content must stay within budget at tiny widths, without panicking or
455 // emitting a replacement char from a mid-glyph byte split.
456 let row = "▸ 中文项目 · main"; // marker + CJK + separator + branch
457 for width in [1usize, 2, 3, 4, 6, 8] {
458 let out = truncate_line_to_width(row, width);
459 assert!(
460 text_display_width(&out) <= width,
461 "width={width}: {out:?} exceeds budget"
462 );
463 assert!(
464 !out.contains('\u{FFFD}'),
465 "width={width}: truncation split a wide glyph"
466 );
467 }
468 }
469
470 // --- keycap / grapheme regression guard (#4479) ---------------------------
471 // Fully qualified keycap sequences render as two columns. Codepoint sums
472 // report one; the canonical string/grapheme contract reports two.
473
474 #[test]
475 fn text_display_width_treats_keycap_sequence_as_two_columns() {
476 for keycap in [
477 "1\u{fe0f}\u{20e3}",
478 "9\u{fe0f}\u{20e3}",
479 "#\u{fe0f}\u{20e3}",
480 ] {
481 assert_eq!(text_display_width(keycap), 2);
482 assert_eq!(text_display_width(keycap), UnicodeWidthStr::width(keycap));
483 }
484 // A digit directly followed by U+20E3 (without FE0F variation selector)
485 // still renders as a 2-column keycap in terminals. We force this in
486 // grapheme_display_width when the grapheme contains U+20E3.
487 // A standalone U+20E3 is a zero-width combining mark.
488 assert_eq!(text_display_width("1\u{20e3}"), 2);
489 assert_eq!(text_display_width("\u{20e3}"), 0);
490 }
491
492 #[test]
493 fn circled_digit_display_width() {
494 assert_eq!(char_display_width('\u{2460}'), 2);
495 assert_eq!(char_display_width('\u{2461}'), 2);
496 assert_eq!(char_display_width('\u{24ea}'), 2);
497 assert_eq!(char_display_width('\u{2776}'), 2);
498 assert_eq!(text_display_width("\u{2460}\u{2461}\u{2462}"), 6);
499 assert_eq!(text_display_width("Step \u{2460}: init"), 13);
500 assert_eq!(text_display_width("A\u{24d0}B"), 4);
501 }
502
503 #[test]
504 fn unicode_width_reports_circled_digits_as_two_columns() {
505 // Regression guard for the unicode-width patch (#4479): Ratatui
506 // renders text through UnicodeWidthChar::width(), so the patch must
507 // make even the raw crate API report 2 columns for ambiguous-width
508 // characters — otherwise Ratatui places them in 1 cell while the
509 // terminal paints 2, shifting every downstream column.
510 assert_eq!(UnicodeWidthChar::width('\u{2460}'), Some(2));
511 assert_eq!(UnicodeWidthChar::width('\u{24ea}'), Some(2));
512 assert_eq!(UnicodeWidthStr::width("\u{2460}\u{2461}\u{2462}"), 6);
513 }
514
515 #[test]
516 fn slice_text_does_not_split_keycap_sequence() {
517 let row = "step 1\u{fe0f}\u{20e3} done";
518 // The keycap occupies columns [5, 7). Any overlapping selection keeps
519 // the complete grapheme; no isolated FE0F/U+20E3 mark may escape.
520 for (start, end) in [(0, 7), (5, 6), (6, 7)] {
521 let sliced = slice_text(row, start, end);
522 assert!(
523 sliced.contains("1\u{fe0f}\u{20e3}"),
524 "range=({start}, {end}) split keycap: {sliced:?}"
525 );
526 }
527 }
528
529 #[test]
530 fn truncate_line_to_width_always_stays_within_budget_with_keycap() {
531 // Budgets from zero through wide, with and without surrounding text.
532 let cases = [
533 "1\u{fe0f}\u{20e3}",
534 "A 1\u{fe0f}\u{20e3} B",
535 "step 2\u{fe0f}\u{20e3} and 3\u{fe0f}\u{20e3} continue",
536 ];
537 for text in &cases {
538 for budget in 0..=text_display_width(text) + 4 {
539 let out = truncate_line_to_width(text, budget);
540 let width = text_display_width(&out);
541 assert!(
542 width <= budget,
543 "budget={budget} text={text:?} -> {out:?} (width={width})"
544 );
545 assert!(!out.ends_with('\u{fe0f}'));
546 assert!(!out.starts_with('\u{20e3}'));
547 }
548 }
549 }
550 }
551
551 lines RUST