返回 CodeWhale
text.rs
1 //! Text that is safe to measure and paint.
2 //!
3 //! Every string a caller passes can come from a model, a tool or a file name.
4 //! ratatui 0.30 already drops control characters when it writes cells, so an
5 //! escape sequence cannot drive the terminal. Bidirectional overrides pass,
6 //! though, and can make `rm -rf ~/x` display as something else, so every
7 //! component runs caller text through [`display_safe`] first.
8
9 use std::borrow::Cow;
10
11 use unicode_segmentation::UnicodeSegmentation;
12 use unicode_width::UnicodeWidthStr;
13
14 use crate::glyphs;
15
16 /// Characters that reorder or hide text without drawing anything.
17 fn is_hidden_control(c: char) -> bool {
18 matches!(c,
19 '\u{202A}'..='\u{202E}' // LRE RLE PDF LRO RLO
20 | '\u{2066}'..='\u{2069}' // LRI RLI FSI PDI
21 | '\u{200E}' | '\u{200F}' | '\u{061C}' // LRM RLM ALM
22 ) || c.is_control()
23 }
24
25 /// `text` without bidi controls or other control characters.
26 #[must_use]
27 pub fn display_safe(text: &str) -> Cow<'_, str> {
28 if text.chars().any(is_hidden_control) {
29 Cow::Owned(text.chars().filter(|c| !is_hidden_control(*c)).collect())
30 } else {
31 Cow::Borrowed(text)
32 }
33 }
34
35 /// Display width in terminal cells.
36 #[must_use]
37 pub fn width(text: &str) -> usize {
38 UnicodeWidthStr::width(text)
39 }
40
41 /// Fit `text` into `max` cells, ending with `…` (`...` when ASCII-safe)
42 /// when anything was cut. Cuts between graphemes, so a wide character or a
43 /// combining sequence is never split. Use it for names, paths and IDs.
44 #[must_use]
45 pub fn truncate(text: &str, max: usize, ascii: bool) -> Cow<'_, str> {
46 cut(text, max, ascii, false)
47 }
48
49 /// Like [`truncate`], but for prose: the cut lands between words where it
50 /// can, because a clipped clause reads as a sentence and a clipped word
51 /// reads as a bug. From the engine's `ui_text::semantic_truncate`.
52 #[must_use]
53 pub fn truncate_words(text: &str, max: usize, ascii: bool) -> Cow<'_, str> {
54 cut(text, max, ascii, true)
55 }
56
57 fn cut(text: &str, max: usize, ascii: bool, at_word: bool) -> Cow<'_, str> {
58 if width(text) <= max {
59 return Cow::Borrowed(text);
60 }
61 if max == 0 {
62 return Cow::Borrowed("");
63 }
64 // ASCII has no one-cell ellipsis, and a lone `.` makes a clipped
65 // sentence read as finished. Spend three cells where there are four.
66 let ellipsis = match (ascii, max) {
67 (false, _) => glyphs::ELLIPSIS,
68 (true, 4..) => "...",
69 (true, _) => ".",
70 };
71 let budget = max.saturating_sub(width(ellipsis));
72 let mut used = 0;
73 let mut end = 0;
74 let mut word_end = None;
75 let mut in_word = false;
76 for (at, g) in text.grapheme_indices(true) {
77 let w = width(g);
78 if used + w > budget {
79 break;
80 }
81 used += w;
82 end = at + g.len();
83 if g.chars().all(char::is_whitespace) {
84 if in_word {
85 word_end = Some(at);
86 }
87 in_word = false;
88 } else {
89 in_word = true;
90 }
91 }
92 let body = match word_end {
93 Some(word_end) if at_word => text[..word_end].trim_end(),
94 _ => text[..end].trim_end(),
95 };
96 let body = if body.is_empty() {
97 text[..end].trim_end()
98 } else {
99 body
100 };
101 Cow::Owned(format!("{body}{ellipsis}"))
102 }
103
104 /// Pad `text` with spaces to exactly `cells` wide (truncating first).
105 #[must_use]
106 pub fn pad(text: &str, cells: usize, ascii: bool) -> String {
107 let fitted = truncate(text, cells, ascii);
108 let gap = cells.saturating_sub(width(&fitted));
109 format!("{fitted}{}", " ".repeat(gap))
110 }
111
112 #[cfg(test)]
113 mod tests {
114 use super::*;
115
116 #[test]
117 fn bidi_overrides_are_stripped() {
118 let spoof = "rm -rf ~/\u{202E}txt.exe";
119 assert_eq!(display_safe(spoof), "rm -rf ~/txt.exe");
120 assert!(matches!(display_safe("plain"), Cow::Borrowed(_)));
121 assert_eq!(display_safe("a\u{1b}[31mb"), "a[31mb");
122 }
123
124 #[test]
125 fn truncate_respects_cell_width() {
126 assert_eq!(truncate("Shoreline", 20, false), "Shoreline");
127 assert_eq!(truncate("Shoreline light", 10, false), "Shoreline…");
128 // In ASCII a cut says so: `Shoreline.` would read as a full stop.
129 assert_eq!(truncate("Shoreline light", 10, true), "Shoreli...");
130 assert_eq!(truncate("Shoreline light", 3, true), "Sh.");
131 // A wide character is never split.
132 assert_eq!(truncate("鲸鱼鲸鱼", 5, false), "鲸鱼…");
133 assert_eq!(width(&pad("ab", 4, false)), 4);
134 // A combining sequence stays whole.
135 assert_eq!(truncate("cafe\u{301} au lait", 6, false), "cafe\u{301}…");
136 }
137
138 #[test]
139 fn prose_is_cut_between_words() {
140 let hint = "Works in this session; asks before edits and shell commands";
141 assert_eq!(
142 truncate_words(hint, 40, false),
143 "Works in this session; asks before…"
144 );
145 assert_eq!(
146 truncate(hint, 40, false),
147 "Works in this session; asks before edit…"
148 );
149 // One long word still fits by cutting inside it.
150 assert_eq!(truncate_words("supercalifragilistic", 8, false), "superca…");
151 }
152 }
153
153 lines RUST