Skip to main content

tui/rendering/
soft_wrap.rs

1use std::borrow::Cow;
2
3use super::{line::Line, style::Style};
4use crossterm::style::Color;
5use unicode_width::UnicodeWidthChar;
6
7/// Truncates text to fit within `max_width` display columns, appending "..." if truncated.
8/// Returns the original string borrowed when no truncation is needed.
9pub 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; // byte offset after last char fitting within budget
22
23    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
41/// Pads `text` with trailing spaces to reach `target_width` display columns.
42/// Returns the original text unchanged if it already meets or exceeds the target.
43pub 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
61/// Truncates a styled line to fit within `max_width` display columns.
62///
63/// Walks spans tracking cumulative display width, slicing at the character
64/// boundary where the budget is exhausted. No ellipsis is appended — callers
65/// can pad with [`Line::extend_bg_to_width`] if needed.
66///
67/// Row-fill metadata on the source line is preserved on the result.
68pub 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        // First row: "ab" (Red) + "c" (Blue)
276        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        // Second row: "d" (Blue) + "ef" (Green)
280        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        // "中" is 2 display columns wide
315        let result = pad_text_to_width("中a", 6);
316        assert_eq!(display_width_text(&result), 6);
317        assert_eq!(result, "中a   "); // 2+1 = 3 cols, need 3 spaces
318    }
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        // Chinese characters are 2 columns wide
331        assert_eq!(truncate_text("中文字", 5), "中..."); // 6 cols -> truncate to 2+3=5
332        assert_eq!(truncate_text("中ab", 4), "中ab"); // 2+1+1=4, fits exactly
333        assert_eq!(truncate_text("中abc", 4), "..."); // 5 cols, only ellipsis fits in 4
334        assert_eq!(truncate_text("中abcde", 6), "中a..."); // 7 cols -> truncate to 2+1+3=6
335    }
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        // "中" is 2 display columns, "文" is 2.
396        // Budget of 3: "中"(2) fits, "文"(2) would exceed (2+2=4>3), so stop.
397        let line = Line::new("中文x");
398        let result = truncate_line(&line, 3);
399        assert_eq!(result.plain_text(), "中");
400
401        // Budget of 4: "中"(2) + "文"(2) = 4, fits exactly.
402        let result = truncate_line(&line, 4);
403        assert_eq!(result.plain_text(), "中文");
404
405        // Budget of 5: all fit: 2+2+1=5.
406        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}