Skip to main content

tui/rendering/
soft_wrap.rs

1use std::borrow::Cow;
2
3use super::line::Line;
4use unicode_width::UnicodeWidthChar;
5
6/// Truncates text to fit within `max_width` display columns, appending "..." if truncated.
7/// Returns the original string borrowed when no truncation is needed.
8pub 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; // byte offset after last char fitting within budget
21
22    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
40/// Pads `text` with trailing spaces to reach `target_width` display columns.
41/// Returns the original text unchanged if it already meets or exceeds the target.
42pub 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
60/// Truncates a styled line to fit within `max_width` display columns.
61///
62/// Walks spans tracking cumulative display width, slicing at the character
63/// boundary where the budget is exhausted. No ellipsis is appended — callers
64/// can pad with [`Line::extend_bg_to_width`] if needed.
65///
66/// Row-fill metadata on the source line is preserved on the result.
67pub 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)>; // (byte offset, byte offset after ws, width after ws)
122
123    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        // First row: "ab" (Red) + "c" (Blue)
251        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        // Second row: "d" (Blue) + "ef" (Green)
255        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        // "中" is 2 display columns wide
290        let result = pad_text_to_width("中a", 6);
291        assert_eq!(display_width_text(&result), 6);
292        assert_eq!(result, "中a   "); // 2+1 = 3 cols, need 3 spaces
293    }
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        // Chinese characters are 2 columns wide
306        assert_eq!(truncate_text("中文字", 5), "中..."); // 6 cols -> truncate to 2+3=5
307        assert_eq!(truncate_text("中ab", 4), "中ab"); // 2+1+1=4, fits exactly
308        assert_eq!(truncate_text("中abc", 4), "..."); // 5 cols, only ellipsis fits in 4
309        assert_eq!(truncate_text("中abcde", 6), "中a..."); // 7 cols -> truncate to 2+1+3=6
310    }
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        // "中" is 2 display columns, "文" is 2.
371        // Budget of 3: "中"(2) fits, "文"(2) would exceed (2+2=4>3), so stop.
372        let line = Line::new("中文x");
373        let result = truncate_line(&line, 3);
374        assert_eq!(result.plain_text(), "中");
375
376        // Budget of 4: "中"(2) + "文"(2) = 4, fits exactly.
377        let result = truncate_line(&line, 4);
378        assert_eq!(result.plain_text(), "中文");
379
380        // Budget of 5: all fit: 2+2+1=5.
381        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}