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.
65pub 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        // First row: "ab" (Red) + "c" (Blue)
219        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        // Second row: "d" (Blue) + "ef" (Green)
223        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        // "中" is 2 display columns wide
258        let result = pad_text_to_width("中a", 6);
259        assert_eq!(display_width_text(&result), 6);
260        assert_eq!(result, "中a   "); // 2+1 = 3 cols, need 3 spaces
261    }
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        // Chinese characters are 2 columns wide
274        assert_eq!(truncate_text("中文字", 5), "中..."); // 6 cols -> truncate to 2+3=5
275        assert_eq!(truncate_text("中ab", 4), "中ab"); // 2+1+1=4, fits exactly
276        assert_eq!(truncate_text("中abc", 4), "..."); // 5 cols, only ellipsis fits in 4
277        assert_eq!(truncate_text("中abcde", 6), "中a..."); // 7 cols -> truncate to 2+1+3=6
278    }
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        // "中" is 2 display columns, "文" is 2.
339        // Budget of 3: "中"(2) fits, "文"(2) would exceed (2+2=4>3), so stop.
340        let line = Line::new("中文x");
341        let result = truncate_line(&line, 3);
342        assert_eq!(result.plain_text(), "中");
343
344        // Budget of 4: "中"(2) + "文"(2) = 4, fits exactly.
345        let result = truncate_line(&line, 4);
346        assert_eq!(result.plain_text(), "中文");
347
348        // Budget of 5: all fit: 2+2+1=5.
349        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}