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 {
18        max_width - ELLIPSIS_WIDTH
19    } else {
20        max_width
21    };
22
23    let mut width = 0;
24    let mut fit_end = 0; // byte offset after last char fitting within budget
25
26    for (i, ch) in text.char_indices() {
27        let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
28        if width + cw > max_width {
29            return if use_ellipsis {
30                Cow::Owned(format!("{}{ELLIPSIS}", &text[..fit_end]))
31            } else {
32                Cow::Owned(text[..fit_end].to_owned())
33            };
34        }
35        width += cw;
36        if width <= budget {
37            fit_end = i + ch.len_utf8();
38        }
39    }
40
41    Cow::Borrowed(text)
42}
43
44/// Pads `text` with trailing spaces to reach `target_width` display columns.
45/// Returns the original text unchanged if it already meets or exceeds the target.
46pub fn pad_text_to_width(text: &str, target_width: usize) -> Cow<'_, str> {
47    let current = display_width_text(text);
48    if current >= target_width {
49        Cow::Borrowed(text)
50    } else {
51        let padding = target_width - current;
52        Cow::Owned(format!("{text}{}", " ".repeat(padding)))
53    }
54}
55
56pub fn display_width_text(s: &str) -> usize {
57    s.chars()
58        .map(|ch| UnicodeWidthChar::width(ch).unwrap_or(0))
59        .sum()
60}
61
62pub fn display_width_line(line: &Line) -> usize {
63    line.spans()
64        .iter()
65        .map(|span| display_width_text(span.text()))
66        .sum()
67}
68
69/// Truncates a styled line to fit within `max_width` display columns.
70///
71/// Walks spans tracking cumulative display width, slicing at the character
72/// boundary where the budget is exhausted. No ellipsis is appended — callers
73/// can pad with [`Line::extend_bg_to_width`] if needed.
74pub fn truncate_line(line: &Line, max_width: usize) -> Line {
75    if max_width == 0 {
76        return Line::default();
77    }
78
79    let mut result = Line::default();
80    let mut remaining = max_width;
81
82    for span in line.spans() {
83        if remaining == 0 {
84            break;
85        }
86
87        let text = span.text();
88        let style = span.style();
89        let mut byte_end = 0;
90        let mut col = 0;
91
92        for (i, ch) in text.char_indices() {
93            let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
94            if col + cw > remaining {
95                break;
96            }
97            col += cw;
98            byte_end = i + ch.len_utf8();
99        }
100
101        if byte_end > 0 {
102            result.push_with_style(&text[..byte_end], style);
103        }
104        remaining -= col;
105    }
106
107    result
108}
109
110pub fn soft_wrap_line(line: &Line, width: u16) -> Vec<Line> {
111    if line.is_empty() {
112        return vec![Line::new("")];
113    }
114
115    let max_width = width as usize;
116    if max_width == 0 {
117        return vec![line.clone()];
118    }
119
120    let mut rows = Vec::new();
121    let mut current = Line::default();
122    let mut current_width = 0usize;
123
124    for span in line.spans() {
125        let text = span.text();
126        let style = span.style();
127        let mut start = 0;
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                start = i + ch.len_utf8();
138                continue;
139            }
140
141            let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
142            if ch_width > 0 && current_width + ch_width > max_width && current_width > 0 {
143                if start < i {
144                    current.push_with_style(&text[start..i], style);
145                }
146                rows.push(current);
147                current = Line::default();
148                current_width = 0;
149                start = i;
150            }
151            current_width += ch_width;
152        }
153
154        if start < text.len() {
155            current.push_with_style(&text[start..], style);
156        }
157    }
158
159    rows.push(current);
160    if rows.is_empty() {
161        rows.push(Line::new(""));
162    }
163    rows
164}
165
166pub fn soft_wrap_lines_with_map(lines: &[Line], width: u16) -> (Vec<Line>, Vec<usize>) {
167    let mut out = Vec::new();
168    let mut starts = Vec::with_capacity(lines.len());
169
170    for line in lines {
171        starts.push(out.len());
172        out.extend(soft_wrap_line(line, width));
173    }
174
175    (out, starts)
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use crossterm::style::Color;
182
183    #[test]
184    fn wraps_ascii_to_width() {
185        let rows = soft_wrap_line(&Line::new("abcdef"), 3);
186        assert_eq!(rows, vec![Line::new("abc"), Line::new("def")]);
187    }
188
189    #[test]
190    fn display_width_ignores_style() {
191        let mut line = Line::default();
192        line.push_styled("he", Color::Red);
193        line.push_text("llo");
194        assert_eq!(display_width_line(&line), 5);
195    }
196
197    #[test]
198    fn wraps_preserving_style_spans() {
199        let line = Line::styled("abcdef", Color::Red);
200        let rows = soft_wrap_line(&line, 3);
201        assert_eq!(rows.len(), 2);
202        assert_eq!(rows[0].plain_text(), "abc");
203        assert_eq!(rows[1].plain_text(), "def");
204        assert_eq!(rows[0].spans().len(), 1);
205        assert_eq!(rows[1].spans().len(), 1);
206        assert_eq!(rows[0].spans()[0].style().fg, Some(Color::Red));
207        assert_eq!(rows[1].spans()[0].style().fg, Some(Color::Red));
208    }
209
210    #[test]
211    fn counts_wide_unicode() {
212        assert_eq!(display_width_text("中a"), 3);
213        let rows = soft_wrap_line(&Line::new("中ab"), 3);
214        assert_eq!(rows, vec![Line::new("中a"), Line::new("b")]);
215    }
216
217    #[test]
218    fn wraps_multi_span_line_mid_span() {
219        let mut line = Line::default();
220        line.push_styled("ab", Color::Red);
221        line.push_styled("cd", Color::Blue);
222        line.push_styled("ef", Color::Green);
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        // First row: "ab" (Red) + "c" (Blue)
228        assert_eq!(rows[0].spans().len(), 2);
229        assert_eq!(rows[0].spans()[0].style().fg, Some(Color::Red));
230        assert_eq!(rows[0].spans()[1].style().fg, Some(Color::Blue));
231        // Second row: "d" (Blue) + "ef" (Green)
232        assert_eq!(rows[1].spans().len(), 2);
233        assert_eq!(rows[1].spans()[0].style().fg, Some(Color::Blue));
234        assert_eq!(rows[1].spans()[1].style().fg, Some(Color::Green));
235    }
236
237    #[test]
238    fn wraps_line_with_embedded_newlines() {
239        let line = Line::new("abc\ndef");
240        let rows = soft_wrap_line(&line, 80);
241        assert_eq!(rows.len(), 2);
242        assert_eq!(rows[0].plain_text(), "abc");
243        assert_eq!(rows[1].plain_text(), "def");
244    }
245
246    #[test]
247    fn pad_text_pads_ascii_to_target_width() {
248        let result = pad_text_to_width("hello", 10);
249        assert_eq!(result, "hello     ");
250        assert_eq!(display_width_text(&result), 10);
251    }
252
253    #[test]
254    fn pad_text_returns_borrowed_when_already_wide_enough() {
255        let result = pad_text_to_width("hello", 5);
256        assert!(matches!(result, Cow::Borrowed(_)));
257        assert_eq!(result, "hello");
258
259        let result = pad_text_to_width("hello", 3);
260        assert!(matches!(result, Cow::Borrowed(_)));
261        assert_eq!(result, "hello");
262    }
263
264    #[test]
265    fn pad_text_handles_wide_unicode() {
266        // "中" is 2 display columns wide
267        let result = pad_text_to_width("中a", 6);
268        assert_eq!(display_width_text(&result), 6);
269        assert_eq!(result, "中a   "); // 2+1 = 3 cols, need 3 spaces
270    }
271
272    #[test]
273    fn truncate_text_fits_within_width() {
274        assert_eq!(truncate_text("hello", 10), "hello");
275        assert_eq!(truncate_text("hello world", 8), "hello...");
276        assert_eq!(truncate_text("hello", 5), "hello");
277        assert_eq!(truncate_text("hello", 4), "h...");
278    }
279
280    #[test]
281    fn truncate_text_handles_wide_unicode() {
282        // Chinese characters are 2 columns wide
283        assert_eq!(truncate_text("中文字", 5), "中..."); // 6 cols -> truncate to 2+3=5
284        assert_eq!(truncate_text("中ab", 4), "中ab"); // 2+1+1=4, fits exactly
285        assert_eq!(truncate_text("中abc", 4), "..."); // 5 cols, only ellipsis fits in 4
286        assert_eq!(truncate_text("中abcde", 6), "中a..."); // 7 cols -> truncate to 2+1+3=6
287    }
288
289    #[test]
290    fn truncate_text_handles_zero_width() {
291        assert_eq!(truncate_text("hello", 0), "");
292    }
293
294    #[test]
295    fn truncate_text_max_width_1() {
296        let result = truncate_text("hello", 1);
297        assert!(
298            display_width_text(&result) <= 1,
299            "Expected width <= 1, got '{}' (width {})",
300            result,
301            display_width_text(&result),
302        );
303        assert_eq!(result, "h");
304    }
305
306    #[test]
307    fn truncate_text_max_width_2() {
308        let result = truncate_text("hello", 2);
309        assert!(
310            display_width_text(&result) <= 2,
311            "Expected width <= 2, got '{}' (width {})",
312            result,
313            display_width_text(&result),
314        );
315        assert_eq!(result, "he");
316    }
317
318    #[test]
319    fn truncate_line_returns_short_lines_unchanged() {
320        let line = Line::new("short");
321        let result = truncate_line(&line, 20);
322        assert_eq!(result.plain_text(), "short");
323    }
324
325    #[test]
326    fn truncate_line_trims_long_styled_lines() {
327        let mut line = Line::default();
328        line.push_styled("hello", Color::Red);
329        line.push_styled(" world", Color::Blue);
330        let result = truncate_line(&line, 7);
331        assert_eq!(result.plain_text(), "hello w");
332        assert_eq!(result.spans().len(), 2);
333        assert_eq!(result.spans()[0].style().fg, Some(Color::Red));
334        assert_eq!(result.spans()[1].style().fg, Some(Color::Blue));
335    }
336
337    #[test]
338    fn truncate_line_handles_mid_span_cut() {
339        let line = Line::styled("abcdefgh", Color::Green);
340        let result = truncate_line(&line, 4);
341        assert_eq!(result.plain_text(), "abcd");
342        assert_eq!(result.spans()[0].style().fg, Some(Color::Green));
343    }
344
345    #[test]
346    fn truncate_line_handles_wide_unicode_at_boundary() {
347        // "中" is 2 display columns, "文" is 2.
348        // Budget of 3: "中"(2) fits, "文"(2) would exceed (2+2=4>3), so stop.
349        let line = Line::new("中文x");
350        let result = truncate_line(&line, 3);
351        assert_eq!(result.plain_text(), "中");
352
353        // Budget of 4: "中"(2) + "文"(2) = 4, fits exactly.
354        let result = truncate_line(&line, 4);
355        assert_eq!(result.plain_text(), "中文");
356
357        // Budget of 5: all fit: 2+2+1=5.
358        let result = truncate_line(&line, 5);
359        assert_eq!(result.plain_text(), "中文x");
360    }
361
362    #[test]
363    fn truncate_line_zero_width_returns_empty() {
364        let line = Line::new("hello");
365        let result = truncate_line(&line, 0);
366        assert!(result.is_empty());
367    }
368}