1use std::borrow::Cow;
2
3use super::{line::Line, style::Style};
4use crossterm::style::Color;
5use unicode_width::UnicodeWidthChar;
6
7pub fn truncate_text(text: &str, max_width: usize) -> Cow<'_, str> {
10 const ELLIPSIS: &str = "...";
11 const ELLIPSIS_WIDTH: usize = 3;
12
13 if max_width == 0 {
14 return Cow::Borrowed("");
15 }
16
17 let use_ellipsis = max_width >= ELLIPSIS_WIDTH;
18 let budget = if use_ellipsis { max_width - ELLIPSIS_WIDTH } else { max_width };
19
20 let mut width = 0;
21 let mut fit_end = 0; for (i, ch) in text.char_indices() {
24 let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
25 if width + cw > max_width {
26 return if use_ellipsis {
27 Cow::Owned(format!("{}{ELLIPSIS}", &text[..fit_end]))
28 } else {
29 Cow::Owned(text[..fit_end].to_owned())
30 };
31 }
32 width += cw;
33 if width <= budget {
34 fit_end = i + ch.len_utf8();
35 }
36 }
37
38 Cow::Borrowed(text)
39}
40
41pub fn pad_text_to_width(text: &str, target_width: usize) -> Cow<'_, str> {
44 let current = display_width_text(text);
45 if current >= target_width {
46 Cow::Borrowed(text)
47 } else {
48 let padding = target_width - current;
49 Cow::Owned(format!("{text}{}", " ".repeat(padding)))
50 }
51}
52
53pub fn display_width_text(s: &str) -> usize {
54 s.chars().map(|ch| UnicodeWidthChar::width(ch).unwrap_or(0)).sum()
55}
56
57pub fn display_width_line(line: &Line) -> usize {
58 line.spans().iter().map(|span| display_width_text(span.text())).sum()
59}
60
61pub fn truncate_line(line: &Line, max_width: usize) -> Line {
69 if max_width == 0 {
70 let mut empty = Line::default();
71 empty.set_fill(line.fill());
72 return empty;
73 }
74
75 let mut result = Line::default();
76 let mut remaining = max_width;
77
78 for span in line.spans() {
79 if remaining == 0 {
80 break;
81 }
82
83 let text = span.text();
84 let style = span.style();
85 let mut byte_end = 0;
86 let mut col = 0;
87
88 for (i, ch) in text.char_indices() {
89 let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
90 if col + cw > remaining {
91 break;
92 }
93 col += cw;
94 byte_end = i + ch.len_utf8();
95 }
96
97 if byte_end > 0 {
98 result.push_with_style(&text[..byte_end], style);
99 }
100 remaining -= col;
101 }
102
103 result.set_fill(line.fill());
104 result
105}
106
107pub fn soft_wrap_line(line: &Line, width: u16) -> Vec<Line> {
108 if line.is_empty() {
109 let mut empty = Line::new("");
110 empty.set_fill(line.fill());
111 return vec![empty];
112 }
113
114 let max_width = width as usize;
115 if max_width == 0 {
116 return vec![line.clone()];
117 }
118
119 let cells = to_cells(line);
120 let mut rows = Vec::new();
121 let mut row_start = 0usize;
122 let mut row_width = 0usize;
123 let mut last_ws = None;
124 let mut i = 0usize;
125
126 while i < cells.len() {
127 let cell = cells[i];
128
129 if cell.ch == '\n' {
130 rows.push(to_line(&cells[row_start..i], line.fill()));
131 row_start = i + 1;
132 row_width = 0;
133 last_ws = None;
134 i += 1;
135 continue;
136 }
137
138 if cell.width > 0 && row_width + cell.width > max_width && row_width > 0 {
139 let split_start = row_start;
140 let (row_end, next_row_start) = if cell.ch.is_whitespace() {
141 (i, i + 1)
142 } else if let Some(ws) = last_ws {
143 (ws, ws + 1)
144 } else {
145 (i, i)
146 };
147
148 rows.push(to_line(&cells[split_start..row_end], line.fill()));
149 row_start = next_row_start;
150
151 if row_start > i {
152 row_width = 0;
153 last_ws = None;
154 i += 1;
155 continue;
156 }
157
158 row_width = display_width_cells(&cells[row_start..i]);
159 last_ws = last_whitespace_index(&cells[row_start..i]).map(|offset| row_start + offset);
160 continue;
161 }
162
163 row_width += cell.width;
164 if cell.ch.is_whitespace() {
165 last_ws = Some(i);
166 }
167 i += 1;
168 }
169
170 rows.push(to_line(&cells[row_start..], line.fill()));
171 rows
172}
173
174pub fn soft_wrap_lines_with_map(lines: &[Line], width: u16) -> (Vec<Line>, Vec<usize>) {
175 let mut out = Vec::new();
176 let mut starts = Vec::with_capacity(lines.len());
177
178 for line in lines {
179 starts.push(out.len());
180 out.extend(soft_wrap_line(line, width));
181 }
182
183 (out, starts)
184}
185
186#[derive(Clone, Copy)]
187struct SoftWrapCell {
188 ch: char,
189 style: Style,
190 width: usize,
191}
192
193fn to_cells(line: &Line) -> Vec<SoftWrapCell> {
194 line.spans()
195 .iter()
196 .flat_map(|span| {
197 let style = span.style();
198 span.text().chars().map(move |ch| SoftWrapCell {
199 ch,
200 style,
201 width: UnicodeWidthChar::width(ch).unwrap_or(0),
202 })
203 })
204 .collect()
205}
206
207fn to_line(cells: &[SoftWrapCell], fill: Option<Color>) -> Line {
208 let mut line = Line::default();
209 for cell in cells {
210 let mut ch = [0; 4];
211 line.push_with_style(cell.ch.encode_utf8(&mut ch), cell.style);
212 }
213
214 line.set_fill(fill);
215 line
216}
217
218fn display_width_cells(cells: &[SoftWrapCell]) -> usize {
219 cells.iter().map(|cell| cell.width).sum()
220}
221
222fn last_whitespace_index(cells: &[SoftWrapCell]) -> Option<usize> {
223 cells.iter().rposition(|cell| cell.ch.is_whitespace())
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229 use crossterm::style::Color;
230
231 #[test]
232 fn wraps_ascii_to_width() {
233 let rows = soft_wrap_line(&Line::new("abcdef"), 3);
234 assert_eq!(rows, vec![Line::new("abc"), Line::new("def")]);
235 }
236
237 #[test]
238 fn display_width_ignores_style() {
239 let mut line = Line::default();
240 line.push_styled("he", Color::Red);
241 line.push_text("llo");
242 assert_eq!(display_width_line(&line), 5);
243 }
244
245 #[test]
246 fn wraps_preserving_style_spans() {
247 let line = Line::styled("abcdef", Color::Red);
248 let rows = soft_wrap_line(&line, 3);
249 assert_eq!(rows.len(), 2);
250 assert_eq!(rows[0].plain_text(), "abc");
251 assert_eq!(rows[1].plain_text(), "def");
252 assert_eq!(rows[0].spans().len(), 1);
253 assert_eq!(rows[1].spans().len(), 1);
254 assert_eq!(rows[0].spans()[0].style().fg, Some(Color::Red));
255 assert_eq!(rows[1].spans()[0].style().fg, Some(Color::Red));
256 }
257
258 #[test]
259 fn counts_wide_unicode() {
260 assert_eq!(display_width_text("中a"), 3);
261 let rows = soft_wrap_line(&Line::new("中ab"), 3);
262 assert_eq!(rows, vec![Line::new("中a"), Line::new("b")]);
263 }
264
265 #[test]
266 fn wraps_multi_span_line_mid_span() {
267 let mut line = Line::default();
268 line.push_styled("ab", Color::Red);
269 line.push_styled("cd", Color::Blue);
270 line.push_styled("ef", Color::Green);
271 let rows = soft_wrap_line(&line, 3);
272 assert_eq!(rows.len(), 2);
273 assert_eq!(rows[0].plain_text(), "abc");
274 assert_eq!(rows[1].plain_text(), "def");
275 assert_eq!(rows[0].spans().len(), 2);
277 assert_eq!(rows[0].spans()[0].style().fg, Some(Color::Red));
278 assert_eq!(rows[0].spans()[1].style().fg, Some(Color::Blue));
279 assert_eq!(rows[1].spans().len(), 2);
281 assert_eq!(rows[1].spans()[0].style().fg, Some(Color::Blue));
282 assert_eq!(rows[1].spans()[1].style().fg, Some(Color::Green));
283 }
284
285 #[test]
286 fn wraps_line_with_embedded_newlines() {
287 let line = Line::new("abc\ndef");
288 let rows = soft_wrap_line(&line, 80);
289 assert_eq!(rows.len(), 2);
290 assert_eq!(rows[0].plain_text(), "abc");
291 assert_eq!(rows[1].plain_text(), "def");
292 }
293
294 #[test]
295 fn pad_text_pads_ascii_to_target_width() {
296 let result = pad_text_to_width("hello", 10);
297 assert_eq!(result, "hello ");
298 assert_eq!(display_width_text(&result), 10);
299 }
300
301 #[test]
302 fn pad_text_returns_borrowed_when_already_wide_enough() {
303 let result = pad_text_to_width("hello", 5);
304 assert!(matches!(result, Cow::Borrowed(_)));
305 assert_eq!(result, "hello");
306
307 let result = pad_text_to_width("hello", 3);
308 assert!(matches!(result, Cow::Borrowed(_)));
309 assert_eq!(result, "hello");
310 }
311
312 #[test]
313 fn pad_text_handles_wide_unicode() {
314 let result = pad_text_to_width("中a", 6);
316 assert_eq!(display_width_text(&result), 6);
317 assert_eq!(result, "中a "); }
319
320 #[test]
321 fn truncate_text_fits_within_width() {
322 assert_eq!(truncate_text("hello", 10), "hello");
323 assert_eq!(truncate_text("hello world", 8), "hello...");
324 assert_eq!(truncate_text("hello", 5), "hello");
325 assert_eq!(truncate_text("hello", 4), "h...");
326 }
327
328 #[test]
329 fn truncate_text_handles_wide_unicode() {
330 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..."); }
336
337 #[test]
338 fn truncate_text_handles_zero_width() {
339 assert_eq!(truncate_text("hello", 0), "");
340 }
341
342 #[test]
343 fn truncate_text_max_width_1() {
344 let result = truncate_text("hello", 1);
345 assert!(
346 display_width_text(&result) <= 1,
347 "Expected width <= 1, got '{}' (width {})",
348 result,
349 display_width_text(&result),
350 );
351 assert_eq!(result, "h");
352 }
353
354 #[test]
355 fn truncate_text_max_width_2() {
356 let result = truncate_text("hello", 2);
357 assert!(
358 display_width_text(&result) <= 2,
359 "Expected width <= 2, got '{}' (width {})",
360 result,
361 display_width_text(&result),
362 );
363 assert_eq!(result, "he");
364 }
365
366 #[test]
367 fn truncate_line_returns_short_lines_unchanged() {
368 let line = Line::new("short");
369 let result = truncate_line(&line, 20);
370 assert_eq!(result.plain_text(), "short");
371 }
372
373 #[test]
374 fn truncate_line_trims_long_styled_lines() {
375 let mut line = Line::default();
376 line.push_styled("hello", Color::Red);
377 line.push_styled(" world", Color::Blue);
378 let result = truncate_line(&line, 7);
379 assert_eq!(result.plain_text(), "hello w");
380 assert_eq!(result.spans().len(), 2);
381 assert_eq!(result.spans()[0].style().fg, Some(Color::Red));
382 assert_eq!(result.spans()[1].style().fg, Some(Color::Blue));
383 }
384
385 #[test]
386 fn truncate_line_handles_mid_span_cut() {
387 let line = Line::styled("abcdefgh", Color::Green);
388 let result = truncate_line(&line, 4);
389 assert_eq!(result.plain_text(), "abcd");
390 assert_eq!(result.spans()[0].style().fg, Some(Color::Green));
391 }
392
393 #[test]
394 fn truncate_line_handles_wide_unicode_at_boundary() {
395 let line = Line::new("中文x");
398 let result = truncate_line(&line, 3);
399 assert_eq!(result.plain_text(), "中");
400
401 let result = truncate_line(&line, 4);
403 assert_eq!(result.plain_text(), "中文");
404
405 let result = truncate_line(&line, 5);
407 assert_eq!(result.plain_text(), "中文x");
408 }
409
410 #[test]
411 fn truncate_line_zero_width_returns_empty() {
412 let line = Line::new("hello");
413 let result = truncate_line(&line, 0);
414 assert!(result.is_empty());
415 }
416
417 #[test]
418 fn wraps_at_word_boundary() {
419 let rows = soft_wrap_line(&Line::new("hello world"), 7);
420 assert_eq!(rows.len(), 2);
421 assert_eq!(rows[0].plain_text(), "hello");
422 assert_eq!(rows[1].plain_text(), "world");
423 }
424
425 #[test]
426 fn wraps_multiple_words() {
427 let rows = soft_wrap_line(&Line::new("hello world foo"), 12);
428 assert_eq!(rows.len(), 2);
429 assert_eq!(rows[0].plain_text(), "hello world");
430 assert_eq!(rows[1].plain_text(), "foo");
431 }
432
433 #[test]
434 fn falls_back_to_char_break_without_whitespace() {
435 let rows = soft_wrap_line(&Line::new("superlongword next"), 5);
436 assert_eq!(rows[0].plain_text(), "super");
437 assert_eq!(rows[1].plain_text(), "longw");
438 assert_eq!(rows[2].plain_text(), "ord");
439 assert_eq!(rows[3].plain_text(), "next");
440 }
441
442 #[test]
443 fn wraps_at_word_boundary_with_styled_spans() {
444 let line = Line::styled("hello world", Color::Red);
445 let rows = soft_wrap_line(&line, 7);
446 assert_eq!(rows.len(), 2);
447 assert_eq!(rows[0].plain_text(), "hello");
448 assert_eq!(rows[1].plain_text(), "world");
449 assert_eq!(rows[0].spans()[0].style().fg, Some(Color::Red));
450 assert_eq!(rows[1].spans()[0].style().fg, Some(Color::Red));
451 }
452
453 #[test]
454 fn wraps_at_whitespace_across_span_boundaries() {
455 let mut line = Line::default();
456 line.push_styled("@aaaaa", Color::Red);
457 line.push_text(" ");
458 line.push_styled("@bbbbbb", Color::Blue);
459
460 let rows = soft_wrap_line(&line, 10);
461
462 assert_eq!(rows.len(), 2);
463 assert_eq!(rows[0].plain_text(), "@aaaaa");
464 assert_eq!(rows[1].plain_text(), "@bbbbbb");
465 assert_eq!(rows[0].spans()[0].style().fg, Some(Color::Red));
466 assert_eq!(rows[1].spans()[0].style().fg, Some(Color::Blue));
467 }
468
469 #[test]
470 fn hard_wraps_long_styled_token_without_whitespace() {
471 let line = Line::styled("@abcdefghijk", Color::Green);
472 let rows = soft_wrap_line(&line, 5);
473
474 assert_eq!(rows.len(), 3);
475 assert_eq!(rows[0].plain_text(), "@abcd");
476 assert_eq!(rows[1].plain_text(), "efghi");
477 assert_eq!(rows[2].plain_text(), "jk");
478 for row in &rows {
479 assert_eq!(row.spans()[0].style().fg, Some(Color::Green));
480 }
481 }
482
483 #[test]
484 fn drops_whitespace_when_new_span_starts_at_wrap_boundary() {
485 let mut line = Line::default();
486 line.push_styled("abcdefghij", Color::Red);
487 line.push_styled(" klm", Color::Blue);
488 let rows = soft_wrap_line(&line, 10);
489
490 assert_eq!(rows.len(), 2);
491 assert_eq!(rows[0].plain_text(), "abcdefghij");
492 assert_eq!(rows[1].plain_text(), "klm");
493 assert_eq!(rows[1].spans()[0].style().fg, Some(Color::Blue));
494 }
495
496 #[test]
497 fn soft_wrap_propagates_fill_to_each_wrapped_row() {
498 let line = Line::new("abcdef").with_fill(Color::Red);
499 let rows = soft_wrap_line(&line, 3);
500 assert_eq!(rows.len(), 2);
501 for row in &rows {
502 assert_eq!(row.fill(), Some(Color::Red));
503 }
504 }
505
506 #[test]
507 fn soft_wrap_preserves_fill_on_empty_line() {
508 let line = Line::default().with_fill(Color::Red);
509 let rows = soft_wrap_line(&line, 10);
510 assert_eq!(rows.len(), 1);
511 assert_eq!(rows[0].fill(), Some(Color::Red));
512 }
513
514 #[test]
515 fn truncate_line_preserves_fill_metadata() {
516 let line = Line::new("abcdef").with_fill(Color::Blue);
517 let truncated = truncate_line(&line, 3);
518 assert_eq!(truncated.plain_text(), "abc");
519 assert_eq!(truncated.fill(), Some(Color::Blue));
520 }
521
522 #[test]
523 fn wraps_across_spans_without_panic() {
524 let mut line = Line::default();
525 line.push_styled("hello ", Color::Red);
526 line.push_styled("world this is long", Color::Blue);
527 let rows = soft_wrap_line(&line, 10);
528 assert_eq!(rows[0].plain_text(), "hello");
529 assert_eq!(rows[1].plain_text(), "world this");
530 assert_eq!(rows[2].plain_text(), "is long");
531 }
532}