1use std::borrow::Cow;
2
3use super::line::Line;
4use unicode_width::UnicodeWidthChar;
5
6pub fn truncate_text(text: &str, max_width: usize) -> Cow<'_, str> {
9 const ELLIPSIS: &str = "...";
10 const ELLIPSIS_WIDTH: usize = 3;
11
12 if max_width == 0 {
13 return Cow::Borrowed("");
14 }
15
16 let use_ellipsis = max_width >= ELLIPSIS_WIDTH;
17 let budget = if use_ellipsis { max_width - ELLIPSIS_WIDTH } else { max_width };
18
19 let mut width = 0;
20 let mut fit_end = 0; for (i, ch) in text.char_indices() {
23 let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
24 if width + cw > max_width {
25 return if use_ellipsis {
26 Cow::Owned(format!("{}{ELLIPSIS}", &text[..fit_end]))
27 } else {
28 Cow::Owned(text[..fit_end].to_owned())
29 };
30 }
31 width += cw;
32 if width <= budget {
33 fit_end = i + ch.len_utf8();
34 }
35 }
36
37 Cow::Borrowed(text)
38}
39
40pub fn pad_text_to_width(text: &str, target_width: usize) -> Cow<'_, str> {
43 let current = display_width_text(text);
44 if current >= target_width {
45 Cow::Borrowed(text)
46 } else {
47 let padding = target_width - current;
48 Cow::Owned(format!("{text}{}", " ".repeat(padding)))
49 }
50}
51
52pub fn display_width_text(s: &str) -> usize {
53 s.chars().map(|ch| UnicodeWidthChar::width(ch).unwrap_or(0)).sum()
54}
55
56pub fn display_width_line(line: &Line) -> usize {
57 line.spans().iter().map(|span| display_width_text(span.text())).sum()
58}
59
60pub fn truncate_line(line: &Line, max_width: usize) -> Line {
66 if max_width == 0 {
67 return Line::default();
68 }
69
70 let mut result = Line::default();
71 let mut remaining = max_width;
72
73 for span in line.spans() {
74 if remaining == 0 {
75 break;
76 }
77
78 let text = span.text();
79 let style = span.style();
80 let mut byte_end = 0;
81 let mut col = 0;
82
83 for (i, ch) in text.char_indices() {
84 let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
85 if col + cw > remaining {
86 break;
87 }
88 col += cw;
89 byte_end = i + ch.len_utf8();
90 }
91
92 if byte_end > 0 {
93 result.push_with_style(&text[..byte_end], style);
94 }
95 remaining -= col;
96 }
97
98 result
99}
100
101pub fn soft_wrap_line(line: &Line, width: u16) -> Vec<Line> {
102 if line.is_empty() {
103 return vec![Line::new("")];
104 }
105
106 let max_width = width as usize;
107 if max_width == 0 {
108 return vec![line.clone()];
109 }
110
111 let mut rows = Vec::new();
112 let mut current = Line::default();
113 let mut current_width = 0usize;
114
115 for span in line.spans() {
116 let text = span.text();
117 let style = span.style();
118 let mut start = 0;
119
120 for (i, ch) in text.char_indices() {
121 if ch == '\n' {
122 if start < i {
123 current.push_with_style(&text[start..i], style);
124 }
125 rows.push(current);
126 current = Line::default();
127 current_width = 0;
128 start = i + ch.len_utf8();
129 continue;
130 }
131
132 let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
133 if ch_width > 0 && current_width + ch_width > max_width && current_width > 0 {
134 if start < i {
135 current.push_with_style(&text[start..i], style);
136 }
137 rows.push(current);
138 current = Line::default();
139 current_width = 0;
140 start = i;
141 }
142 current_width += ch_width;
143 }
144
145 if start < text.len() {
146 current.push_with_style(&text[start..], style);
147 }
148 }
149
150 rows.push(current);
151 if rows.is_empty() {
152 rows.push(Line::new(""));
153 }
154 rows
155}
156
157pub fn soft_wrap_lines_with_map(lines: &[Line], width: u16) -> (Vec<Line>, Vec<usize>) {
158 let mut out = Vec::new();
159 let mut starts = Vec::with_capacity(lines.len());
160
161 for line in lines {
162 starts.push(out.len());
163 out.extend(soft_wrap_line(line, width));
164 }
165
166 (out, starts)
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use crossterm::style::Color;
173
174 #[test]
175 fn wraps_ascii_to_width() {
176 let rows = soft_wrap_line(&Line::new("abcdef"), 3);
177 assert_eq!(rows, vec![Line::new("abc"), Line::new("def")]);
178 }
179
180 #[test]
181 fn display_width_ignores_style() {
182 let mut line = Line::default();
183 line.push_styled("he", Color::Red);
184 line.push_text("llo");
185 assert_eq!(display_width_line(&line), 5);
186 }
187
188 #[test]
189 fn wraps_preserving_style_spans() {
190 let line = Line::styled("abcdef", Color::Red);
191 let rows = soft_wrap_line(&line, 3);
192 assert_eq!(rows.len(), 2);
193 assert_eq!(rows[0].plain_text(), "abc");
194 assert_eq!(rows[1].plain_text(), "def");
195 assert_eq!(rows[0].spans().len(), 1);
196 assert_eq!(rows[1].spans().len(), 1);
197 assert_eq!(rows[0].spans()[0].style().fg, Some(Color::Red));
198 assert_eq!(rows[1].spans()[0].style().fg, Some(Color::Red));
199 }
200
201 #[test]
202 fn counts_wide_unicode() {
203 assert_eq!(display_width_text("中a"), 3);
204 let rows = soft_wrap_line(&Line::new("中ab"), 3);
205 assert_eq!(rows, vec![Line::new("中a"), Line::new("b")]);
206 }
207
208 #[test]
209 fn wraps_multi_span_line_mid_span() {
210 let mut line = Line::default();
211 line.push_styled("ab", Color::Red);
212 line.push_styled("cd", Color::Blue);
213 line.push_styled("ef", Color::Green);
214 let rows = soft_wrap_line(&line, 3);
215 assert_eq!(rows.len(), 2);
216 assert_eq!(rows[0].plain_text(), "abc");
217 assert_eq!(rows[1].plain_text(), "def");
218 assert_eq!(rows[0].spans().len(), 2);
220 assert_eq!(rows[0].spans()[0].style().fg, Some(Color::Red));
221 assert_eq!(rows[0].spans()[1].style().fg, Some(Color::Blue));
222 assert_eq!(rows[1].spans().len(), 2);
224 assert_eq!(rows[1].spans()[0].style().fg, Some(Color::Blue));
225 assert_eq!(rows[1].spans()[1].style().fg, Some(Color::Green));
226 }
227
228 #[test]
229 fn wraps_line_with_embedded_newlines() {
230 let line = Line::new("abc\ndef");
231 let rows = soft_wrap_line(&line, 80);
232 assert_eq!(rows.len(), 2);
233 assert_eq!(rows[0].plain_text(), "abc");
234 assert_eq!(rows[1].plain_text(), "def");
235 }
236
237 #[test]
238 fn pad_text_pads_ascii_to_target_width() {
239 let result = pad_text_to_width("hello", 10);
240 assert_eq!(result, "hello ");
241 assert_eq!(display_width_text(&result), 10);
242 }
243
244 #[test]
245 fn pad_text_returns_borrowed_when_already_wide_enough() {
246 let result = pad_text_to_width("hello", 5);
247 assert!(matches!(result, Cow::Borrowed(_)));
248 assert_eq!(result, "hello");
249
250 let result = pad_text_to_width("hello", 3);
251 assert!(matches!(result, Cow::Borrowed(_)));
252 assert_eq!(result, "hello");
253 }
254
255 #[test]
256 fn pad_text_handles_wide_unicode() {
257 let result = pad_text_to_width("中a", 6);
259 assert_eq!(display_width_text(&result), 6);
260 assert_eq!(result, "中a "); }
262
263 #[test]
264 fn truncate_text_fits_within_width() {
265 assert_eq!(truncate_text("hello", 10), "hello");
266 assert_eq!(truncate_text("hello world", 8), "hello...");
267 assert_eq!(truncate_text("hello", 5), "hello");
268 assert_eq!(truncate_text("hello", 4), "h...");
269 }
270
271 #[test]
272 fn truncate_text_handles_wide_unicode() {
273 assert_eq!(truncate_text("中文字", 5), "中..."); assert_eq!(truncate_text("中ab", 4), "中ab"); assert_eq!(truncate_text("中abc", 4), "..."); assert_eq!(truncate_text("中abcde", 6), "中a..."); }
279
280 #[test]
281 fn truncate_text_handles_zero_width() {
282 assert_eq!(truncate_text("hello", 0), "");
283 }
284
285 #[test]
286 fn truncate_text_max_width_1() {
287 let result = truncate_text("hello", 1);
288 assert!(
289 display_width_text(&result) <= 1,
290 "Expected width <= 1, got '{}' (width {})",
291 result,
292 display_width_text(&result),
293 );
294 assert_eq!(result, "h");
295 }
296
297 #[test]
298 fn truncate_text_max_width_2() {
299 let result = truncate_text("hello", 2);
300 assert!(
301 display_width_text(&result) <= 2,
302 "Expected width <= 2, got '{}' (width {})",
303 result,
304 display_width_text(&result),
305 );
306 assert_eq!(result, "he");
307 }
308
309 #[test]
310 fn truncate_line_returns_short_lines_unchanged() {
311 let line = Line::new("short");
312 let result = truncate_line(&line, 20);
313 assert_eq!(result.plain_text(), "short");
314 }
315
316 #[test]
317 fn truncate_line_trims_long_styled_lines() {
318 let mut line = Line::default();
319 line.push_styled("hello", Color::Red);
320 line.push_styled(" world", Color::Blue);
321 let result = truncate_line(&line, 7);
322 assert_eq!(result.plain_text(), "hello w");
323 assert_eq!(result.spans().len(), 2);
324 assert_eq!(result.spans()[0].style().fg, Some(Color::Red));
325 assert_eq!(result.spans()[1].style().fg, Some(Color::Blue));
326 }
327
328 #[test]
329 fn truncate_line_handles_mid_span_cut() {
330 let line = Line::styled("abcdefgh", Color::Green);
331 let result = truncate_line(&line, 4);
332 assert_eq!(result.plain_text(), "abcd");
333 assert_eq!(result.spans()[0].style().fg, Some(Color::Green));
334 }
335
336 #[test]
337 fn truncate_line_handles_wide_unicode_at_boundary() {
338 let line = Line::new("中文x");
341 let result = truncate_line(&line, 3);
342 assert_eq!(result.plain_text(), "中");
343
344 let result = truncate_line(&line, 4);
346 assert_eq!(result.plain_text(), "中文");
347
348 let result = truncate_line(&line, 5);
350 assert_eq!(result.plain_text(), "中文x");
351 }
352
353 #[test]
354 fn truncate_line_zero_width_returns_empty() {
355 let line = Line::new("hello");
356 let result = truncate_line(&line, 0);
357 assert!(result.is_empty());
358 }
359}