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 {
68 if max_width == 0 {
69 let mut empty = Line::default();
70 empty.set_fill(line.fill());
71 return empty;
72 }
73
74 let mut result = Line::default();
75 let mut remaining = max_width;
76
77 for span in line.spans() {
78 if remaining == 0 {
79 break;
80 }
81
82 let text = span.text();
83 let style = span.style();
84 let mut byte_end = 0;
85 let mut col = 0;
86
87 for (i, ch) in text.char_indices() {
88 let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
89 if col + cw > remaining {
90 break;
91 }
92 col += cw;
93 byte_end = i + ch.len_utf8();
94 }
95
96 if byte_end > 0 {
97 result.push_with_style(&text[..byte_end], style);
98 }
99 remaining -= col;
100 }
101
102 result.set_fill(line.fill());
103 result
104}
105
106pub fn soft_wrap_line(line: &Line, width: u16) -> Vec<Line> {
107 if line.is_empty() {
108 let mut empty = Line::new("");
109 empty.set_fill(line.fill());
110 return vec![empty];
111 }
112
113 let max_width = width as usize;
114 if max_width == 0 {
115 return vec![line.clone()];
116 }
117
118 let mut rows = Vec::new();
119 let mut current = Line::default();
120 let mut current_width = 0usize;
121 let mut last_ws: Option<(usize, usize, usize)>; for span in line.spans() {
124 let text = span.text();
125 let style = span.style();
126 let mut start = 0;
127 last_ws = None;
128
129 for (i, ch) in text.char_indices() {
130 if ch == '\n' {
131 if start < i {
132 current.push_with_style(&text[start..i], style);
133 }
134 rows.push(current);
135 current = Line::default();
136 current_width = 0;
137 last_ws = None;
138 start = i + ch.len_utf8();
139 continue;
140 }
141
142 let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
143 if ch_width > 0 && current_width + ch_width > max_width && current_width > 0 {
144 let had_last_ws = last_ws.is_some();
145 let (break_at, skip_to, new_width) = if let Some((ws_pos, ws_end, width_after_ws)) = last_ws.take() {
146 (ws_pos, ws_end, current_width - width_after_ws)
147 } else {
148 (i, i, 0)
149 };
150
151 if start < break_at {
152 current.push_with_style(&text[start..break_at], style);
153 }
154 rows.push(current);
155 current = Line::default();
156 current_width = new_width;
157 if skip_to < i {
158 current.push_with_style(&text[skip_to..i], style);
159 }
160 if !had_last_ws && ch.is_whitespace() {
161 start = i + ch.len_utf8();
162 last_ws = None;
163 continue;
164 }
165 start = i;
166 }
167 current_width += ch_width;
168 if ch.is_whitespace() {
169 last_ws = Some((i, i + ch.len_utf8(), current_width));
170 }
171 }
172
173 if start < text.len() {
174 current.push_with_style(&text[start..], style);
175 }
176 }
177
178 rows.push(current);
179
180 let fill = line.fill();
181 if fill.is_some() {
182 for row in &mut rows {
183 row.set_fill(fill);
184 }
185 }
186 rows
187}
188
189pub fn soft_wrap_lines_with_map(lines: &[Line], width: u16) -> (Vec<Line>, Vec<usize>) {
190 let mut out = Vec::new();
191 let mut starts = Vec::with_capacity(lines.len());
192
193 for line in lines {
194 starts.push(out.len());
195 out.extend(soft_wrap_line(line, width));
196 }
197
198 (out, starts)
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204 use crossterm::style::Color;
205
206 #[test]
207 fn wraps_ascii_to_width() {
208 let rows = soft_wrap_line(&Line::new("abcdef"), 3);
209 assert_eq!(rows, vec![Line::new("abc"), Line::new("def")]);
210 }
211
212 #[test]
213 fn display_width_ignores_style() {
214 let mut line = Line::default();
215 line.push_styled("he", Color::Red);
216 line.push_text("llo");
217 assert_eq!(display_width_line(&line), 5);
218 }
219
220 #[test]
221 fn wraps_preserving_style_spans() {
222 let line = Line::styled("abcdef", Color::Red);
223 let rows = soft_wrap_line(&line, 3);
224 assert_eq!(rows.len(), 2);
225 assert_eq!(rows[0].plain_text(), "abc");
226 assert_eq!(rows[1].plain_text(), "def");
227 assert_eq!(rows[0].spans().len(), 1);
228 assert_eq!(rows[1].spans().len(), 1);
229 assert_eq!(rows[0].spans()[0].style().fg, Some(Color::Red));
230 assert_eq!(rows[1].spans()[0].style().fg, Some(Color::Red));
231 }
232
233 #[test]
234 fn counts_wide_unicode() {
235 assert_eq!(display_width_text("中a"), 3);
236 let rows = soft_wrap_line(&Line::new("中ab"), 3);
237 assert_eq!(rows, vec![Line::new("中a"), Line::new("b")]);
238 }
239
240 #[test]
241 fn wraps_multi_span_line_mid_span() {
242 let mut line = Line::default();
243 line.push_styled("ab", Color::Red);
244 line.push_styled("cd", Color::Blue);
245 line.push_styled("ef", Color::Green);
246 let rows = soft_wrap_line(&line, 3);
247 assert_eq!(rows.len(), 2);
248 assert_eq!(rows[0].plain_text(), "abc");
249 assert_eq!(rows[1].plain_text(), "def");
250 assert_eq!(rows[0].spans().len(), 2);
252 assert_eq!(rows[0].spans()[0].style().fg, Some(Color::Red));
253 assert_eq!(rows[0].spans()[1].style().fg, Some(Color::Blue));
254 assert_eq!(rows[1].spans().len(), 2);
256 assert_eq!(rows[1].spans()[0].style().fg, Some(Color::Blue));
257 assert_eq!(rows[1].spans()[1].style().fg, Some(Color::Green));
258 }
259
260 #[test]
261 fn wraps_line_with_embedded_newlines() {
262 let line = Line::new("abc\ndef");
263 let rows = soft_wrap_line(&line, 80);
264 assert_eq!(rows.len(), 2);
265 assert_eq!(rows[0].plain_text(), "abc");
266 assert_eq!(rows[1].plain_text(), "def");
267 }
268
269 #[test]
270 fn pad_text_pads_ascii_to_target_width() {
271 let result = pad_text_to_width("hello", 10);
272 assert_eq!(result, "hello ");
273 assert_eq!(display_width_text(&result), 10);
274 }
275
276 #[test]
277 fn pad_text_returns_borrowed_when_already_wide_enough() {
278 let result = pad_text_to_width("hello", 5);
279 assert!(matches!(result, Cow::Borrowed(_)));
280 assert_eq!(result, "hello");
281
282 let result = pad_text_to_width("hello", 3);
283 assert!(matches!(result, Cow::Borrowed(_)));
284 assert_eq!(result, "hello");
285 }
286
287 #[test]
288 fn pad_text_handles_wide_unicode() {
289 let result = pad_text_to_width("中a", 6);
291 assert_eq!(display_width_text(&result), 6);
292 assert_eq!(result, "中a "); }
294
295 #[test]
296 fn truncate_text_fits_within_width() {
297 assert_eq!(truncate_text("hello", 10), "hello");
298 assert_eq!(truncate_text("hello world", 8), "hello...");
299 assert_eq!(truncate_text("hello", 5), "hello");
300 assert_eq!(truncate_text("hello", 4), "h...");
301 }
302
303 #[test]
304 fn truncate_text_handles_wide_unicode() {
305 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..."); }
311
312 #[test]
313 fn truncate_text_handles_zero_width() {
314 assert_eq!(truncate_text("hello", 0), "");
315 }
316
317 #[test]
318 fn truncate_text_max_width_1() {
319 let result = truncate_text("hello", 1);
320 assert!(
321 display_width_text(&result) <= 1,
322 "Expected width <= 1, got '{}' (width {})",
323 result,
324 display_width_text(&result),
325 );
326 assert_eq!(result, "h");
327 }
328
329 #[test]
330 fn truncate_text_max_width_2() {
331 let result = truncate_text("hello", 2);
332 assert!(
333 display_width_text(&result) <= 2,
334 "Expected width <= 2, got '{}' (width {})",
335 result,
336 display_width_text(&result),
337 );
338 assert_eq!(result, "he");
339 }
340
341 #[test]
342 fn truncate_line_returns_short_lines_unchanged() {
343 let line = Line::new("short");
344 let result = truncate_line(&line, 20);
345 assert_eq!(result.plain_text(), "short");
346 }
347
348 #[test]
349 fn truncate_line_trims_long_styled_lines() {
350 let mut line = Line::default();
351 line.push_styled("hello", Color::Red);
352 line.push_styled(" world", Color::Blue);
353 let result = truncate_line(&line, 7);
354 assert_eq!(result.plain_text(), "hello w");
355 assert_eq!(result.spans().len(), 2);
356 assert_eq!(result.spans()[0].style().fg, Some(Color::Red));
357 assert_eq!(result.spans()[1].style().fg, Some(Color::Blue));
358 }
359
360 #[test]
361 fn truncate_line_handles_mid_span_cut() {
362 let line = Line::styled("abcdefgh", Color::Green);
363 let result = truncate_line(&line, 4);
364 assert_eq!(result.plain_text(), "abcd");
365 assert_eq!(result.spans()[0].style().fg, Some(Color::Green));
366 }
367
368 #[test]
369 fn truncate_line_handles_wide_unicode_at_boundary() {
370 let line = Line::new("中文x");
373 let result = truncate_line(&line, 3);
374 assert_eq!(result.plain_text(), "中");
375
376 let result = truncate_line(&line, 4);
378 assert_eq!(result.plain_text(), "中文");
379
380 let result = truncate_line(&line, 5);
382 assert_eq!(result.plain_text(), "中文x");
383 }
384
385 #[test]
386 fn truncate_line_zero_width_returns_empty() {
387 let line = Line::new("hello");
388 let result = truncate_line(&line, 0);
389 assert!(result.is_empty());
390 }
391
392 #[test]
393 fn wraps_at_word_boundary() {
394 let rows = soft_wrap_line(&Line::new("hello world"), 7);
395 assert_eq!(rows.len(), 2);
396 assert_eq!(rows[0].plain_text(), "hello");
397 assert_eq!(rows[1].plain_text(), "world");
398 }
399
400 #[test]
401 fn wraps_multiple_words() {
402 let rows = soft_wrap_line(&Line::new("hello world foo"), 12);
403 assert_eq!(rows.len(), 2);
404 assert_eq!(rows[0].plain_text(), "hello world");
405 assert_eq!(rows[1].plain_text(), "foo");
406 }
407
408 #[test]
409 fn falls_back_to_char_break_without_whitespace() {
410 let rows = soft_wrap_line(&Line::new("superlongword next"), 5);
411 assert_eq!(rows[0].plain_text(), "super");
412 assert_eq!(rows[1].plain_text(), "longw");
413 assert_eq!(rows[2].plain_text(), "ord");
414 assert_eq!(rows[3].plain_text(), "next");
415 }
416
417 #[test]
418 fn wraps_at_word_boundary_with_styled_spans() {
419 let line = Line::styled("hello world", Color::Red);
420 let rows = soft_wrap_line(&line, 7);
421 assert_eq!(rows.len(), 2);
422 assert_eq!(rows[0].plain_text(), "hello");
423 assert_eq!(rows[1].plain_text(), "world");
424 assert_eq!(rows[0].spans()[0].style().fg, Some(Color::Red));
425 assert_eq!(rows[1].spans()[0].style().fg, Some(Color::Red));
426 }
427
428 #[test]
429 fn drops_whitespace_when_new_span_starts_at_wrap_boundary() {
430 let mut line = Line::default();
431 line.push_styled("abcdefghij", Color::Red);
432 line.push_styled(" klm", Color::Blue);
433 let rows = soft_wrap_line(&line, 10);
434
435 assert_eq!(rows.len(), 2);
436 assert_eq!(rows[0].plain_text(), "abcdefghij");
437 assert_eq!(rows[1].plain_text(), "klm");
438 assert_eq!(rows[1].spans()[0].style().fg, Some(Color::Blue));
439 }
440
441 #[test]
442 fn soft_wrap_propagates_fill_to_each_wrapped_row() {
443 let line = Line::new("abcdef").with_fill(Color::Red);
444 let rows = soft_wrap_line(&line, 3);
445 assert_eq!(rows.len(), 2);
446 for row in &rows {
447 assert_eq!(row.fill(), Some(Color::Red));
448 }
449 }
450
451 #[test]
452 fn soft_wrap_preserves_fill_on_empty_line() {
453 let line = Line::default().with_fill(Color::Red);
454 let rows = soft_wrap_line(&line, 10);
455 assert_eq!(rows.len(), 1);
456 assert_eq!(rows[0].fill(), Some(Color::Red));
457 }
458
459 #[test]
460 fn truncate_line_preserves_fill_metadata() {
461 let line = Line::new("abcdef").with_fill(Color::Blue);
462 let truncated = truncate_line(&line, 3);
463 assert_eq!(truncated.plain_text(), "abc");
464 assert_eq!(truncated.fill(), Some(Color::Blue));
465 }
466
467 #[test]
468 fn wraps_across_spans_without_panic() {
469 let mut line = Line::default();
470 line.push_styled("hello ", Color::Red);
471 line.push_styled("world this is long", Color::Blue);
472 let rows = soft_wrap_line(&line, 10);
473 assert_eq!(rows[0].plain_text(), "hello worl");
474 assert_eq!(rows[1].plain_text(), "d this is");
475 assert_eq!(rows[2].plain_text(), "long");
476 }
477}