Skip to main content

tui/rendering/
soft_wrap.rs

1use std::borrow::Cow;
2use std::iter::Sum;
3use std::ops::{Add, AddAssign, Range};
4
5use super::line::Line;
6use crossterm::style::Color;
7use unicode_width::UnicodeWidthChar;
8
9/// Returns `(row, col)` for `byte_offset` after applying the same soft-wrap
10/// rules used by [`soft_wrap_line`].
11pub fn soft_wrap_text_position(text: &str, offset: usize, max_width: usize) -> (usize, usize) {
12    let byte_offset = clamp_to_char_boundary(text, offset);
13    let max_width = Col(max_width);
14    if max_width.0 == 0 {
15        return (0, display_width_text(text.slice_to(byte_offset)));
16    }
17
18    let mut row_index = Row::ZERO;
19    let mut row_start = ByteOffset::ZERO;
20
21    loop {
22        let (row, next_row_start) = next_text_row(text, row_start, max_width);
23        if byte_offset < row.end
24            || byte_offset == row.end && next_row_start != Some(row.end)
25            || next_row_start.is_none()
26        {
27            return (row_index.0, display_width_text_until(text, row, byte_offset).0);
28        }
29
30        let Some(next_row_start) = next_row_start else {
31            return (row_index.0, 0);
32        };
33        row_start = next_row_start;
34        row_index += 1;
35    }
36}
37
38pub(crate) fn soft_wrap_text_byte_offset(text: &str, target_row: usize, target_col: usize, max_width: usize) -> usize {
39    let target_row = Row(target_row);
40    let target_col = Col(target_col);
41    let max_width = Col(max_width);
42    if max_width.0 == 0 {
43        return byte_offset_for_col(text, ByteOffset::ZERO..ByteOffset::end_of(text), target_col).0;
44    }
45
46    let mut row_index = Row::ZERO;
47    let mut row_start = ByteOffset::ZERO;
48
49    loop {
50        let (row, next_row_start) = next_text_row(text, row_start, max_width);
51        if row_index == target_row {
52            return byte_offset_for_col(text, row, target_col).0;
53        }
54
55        let Some(next_row_start) = next_row_start else {
56            return text.len();
57        };
58        row_start = next_row_start;
59        row_index += 1;
60    }
61}
62
63/// Truncates text to fit within `max_width` display columns, appending "..." if truncated.
64/// Returns the original string borrowed when no truncation is needed.
65pub fn truncate_text(text: &str, max_width: usize) -> Cow<'_, str> {
66    const ELLIPSIS: &str = "...";
67    const ELLIPSIS_WIDTH: usize = 3;
68
69    if max_width == 0 {
70        return Cow::Borrowed("");
71    }
72
73    let use_ellipsis = max_width >= ELLIPSIS_WIDTH;
74    let budget = if use_ellipsis { max_width - ELLIPSIS_WIDTH } else { max_width };
75
76    let mut width = 0;
77    let mut fit_end = 0; // byte offset after last char fitting within budget
78
79    for (i, ch) in text.char_indices() {
80        let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
81        if width + cw > max_width {
82            return if use_ellipsis {
83                Cow::Owned(format!("{}{ELLIPSIS}", &text[..fit_end]))
84            } else {
85                Cow::Owned(text[..fit_end].to_owned())
86            };
87        }
88        width += cw;
89        if width <= budget {
90            fit_end = i + ch.len_utf8();
91        }
92    }
93
94    Cow::Borrowed(text)
95}
96
97/// Pads `text` with trailing spaces to reach `target_width` display columns.
98/// Returns the original text unchanged if it already meets or exceeds the target.
99pub fn pad_text_to_width(text: &str, target_width: usize) -> Cow<'_, str> {
100    let current = display_width_text(text);
101    if current >= target_width {
102        Cow::Borrowed(text)
103    } else {
104        let padding = target_width - current;
105        Cow::Owned(format!("{text}{}", " ".repeat(padding)))
106    }
107}
108
109pub fn display_width_text(s: &str) -> usize {
110    s.chars().map(|ch| UnicodeWidthChar::width(ch).unwrap_or(0)).sum()
111}
112
113pub fn display_width_line(line: &Line) -> usize {
114    line.spans().iter().map(|span| display_width_text(span.text())).sum()
115}
116
117/// Truncates a styled line to fit within `max_width` display columns.
118///
119/// Walks spans tracking cumulative display width, slicing at the character
120/// boundary where the budget is exhausted. No ellipsis is appended — callers
121/// can pad with [`Line::extend_bg_to_width`] if needed.
122///
123/// Row-fill metadata on the source line is preserved on the result.
124pub fn truncate_line(line: &Line, max_width: usize) -> Line {
125    if max_width == 0 {
126        let mut empty = Line::default();
127        empty.set_fill(line.fill());
128        return empty;
129    }
130
131    let mut result = Line::default();
132    let mut remaining = max_width;
133
134    for span in line.spans() {
135        if remaining == 0 {
136            break;
137        }
138
139        let text = span.text();
140        let style = span.style();
141        let mut byte_end = 0;
142        let mut col = 0;
143
144        for (i, ch) in text.char_indices() {
145            let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
146            if col + cw > remaining {
147                break;
148            }
149            col += cw;
150            byte_end = i + ch.len_utf8();
151        }
152
153        if byte_end > 0 {
154            result.push_with_style(&text[..byte_end], style);
155        }
156        remaining -= col;
157    }
158
159    result.set_fill(line.fill());
160    result
161}
162
163pub fn soft_wrap_line(line: &Line, width: u16) -> Vec<Line> {
164    if line.is_empty() {
165        let mut empty = Line::new("");
166        empty.set_fill(line.fill());
167        return vec![empty];
168    }
169
170    let max_width = Col(width as usize);
171    if max_width.0 == 0 {
172        return vec![line.clone()];
173    }
174
175    let text = line.plain_text();
176    let mut rows = Vec::new();
177    let mut row_start = ByteOffset::ZERO;
178    loop {
179        let (range, next_row_start) = next_text_row(&text, row_start, max_width);
180        rows.push(slice_line(line, range, line.fill()));
181        let Some(next_row_start) = next_row_start else {
182            return rows;
183        };
184        row_start = next_row_start;
185    }
186}
187
188pub fn soft_wrap_lines_with_map(lines: &[Line], width: u16) -> (Vec<Line>, Vec<usize>) {
189    let mut out = Vec::new();
190    let mut starts = Vec::with_capacity(lines.len());
191
192    for line in lines {
193        starts.push(out.len());
194        out.extend(soft_wrap_line(line, width));
195    }
196
197    (out, starts)
198}
199
200fn slice_line(line: &Line, range: Range<ByteOffset>, fill: Option<Color>) -> Line {
201    let mut result = Line::default();
202    let mut cursor = ByteOffset::ZERO;
203
204    for span in line.spans() {
205        let span_start = cursor;
206        let span_end = cursor + span.text().len();
207        cursor = span_end;
208
209        let start = range.start.max(span_start);
210        let end = range.end.min(span_end);
211        if start < end {
212            result.push_with_style(&span.text()[start.0 - span_start.0..end.0 - span_start.0], span.style());
213        }
214    }
215
216    result.set_fill(fill);
217    result
218}
219
220fn next_text_row(text: &str, row_start: ByteOffset, max_width: Col) -> (Range<ByteOffset>, Option<ByteOffset>) {
221    if row_start.0 >= text.len() {
222        let end = ByteOffset::end_of(text);
223        return (end..end, None);
224    }
225
226    let mut row_width = Col::ZERO;
227    let mut last_ws: Option<Range<ByteOffset>> = None;
228
229    for (offset, ch) in text.slice_from(row_start).char_indices() {
230        let byte_start = row_start + offset;
231        let byte_end = byte_start + ch.len_utf8();
232
233        if ch == '\n' {
234            return (row_start..byte_start, Some(byte_end));
235        }
236
237        let width = col_of(ch);
238        if width.0 > 0 && row_width + width > max_width && row_width.0 > 0 {
239            return if ch.is_whitespace() {
240                (row_start..byte_start, Some(byte_end))
241            } else if let Some(ws) = last_ws {
242                (row_start..ws.start, Some(ws.end))
243            } else {
244                (row_start..byte_start, Some(byte_start))
245            };
246        }
247
248        row_width += width;
249        if ch.is_whitespace() {
250            last_ws = Some(byte_start..byte_end);
251        }
252    }
253
254    (row_start..ByteOffset::end_of(text), None)
255}
256
257fn byte_offset_for_col(text: &str, range: Range<ByteOffset>, target_col: Col) -> ByteOffset {
258    let mut col = Col::ZERO;
259    let mut byte = range.start;
260    for ch in text.slice(range.clone()).chars() {
261        if col >= target_col {
262            return byte;
263        }
264        col += col_of(ch);
265        byte += ch.len_utf8();
266        if col >= target_col {
267            return byte;
268        }
269    }
270    range.end
271}
272
273fn clamp_to_char_boundary(text: &str, byte_offset: usize) -> ByteOffset {
274    let mut byte_offset = byte_offset.min(text.len());
275    while !text.is_char_boundary(byte_offset) {
276        byte_offset = byte_offset.saturating_sub(1);
277    }
278    ByteOffset(byte_offset)
279}
280
281fn display_width_text_until(text: &str, range: Range<ByteOffset>, byte_offset: ByteOffset) -> Col {
282    text.slice(range.clone())
283        .char_indices()
284        .take_while(|(offset, ch)| range.start + *offset + ch.len_utf8() <= byte_offset)
285        .map(|(_, ch)| col_of(ch))
286        .sum()
287}
288
289fn col_of(ch: char) -> Col {
290    Col(UnicodeWidthChar::width(ch).unwrap_or(0))
291}
292
293/// Byte offset into a UTF-8 string.
294#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
295struct ByteOffset(usize);
296
297impl ByteOffset {
298    const ZERO: Self = Self(0);
299    fn end_of(text: &str) -> Self {
300        Self(text.len())
301    }
302}
303
304impl Add<usize> for ByteOffset {
305    type Output = Self;
306    fn add(self, rhs: usize) -> Self {
307        Self(self.0 + rhs)
308    }
309}
310
311impl AddAssign<usize> for ByteOffset {
312    fn add_assign(&mut self, rhs: usize) {
313        self.0 += rhs;
314    }
315}
316
317/// Display column / unicode display-width count.
318#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
319struct Col(usize);
320
321impl Col {
322    const ZERO: Self = Self(0);
323}
324
325impl Add for Col {
326    type Output = Self;
327    fn add(self, rhs: Self) -> Self {
328        Self(self.0 + rhs.0)
329    }
330}
331
332impl AddAssign for Col {
333    fn add_assign(&mut self, rhs: Self) {
334        self.0 += rhs.0;
335    }
336}
337
338impl Sum for Col {
339    fn sum<I: Iterator<Item = Self>>(iter: I) -> Self {
340        Self(iter.map(|c| c.0).sum())
341    }
342}
343
344/// Visual row index in the wrapped grid.
345#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
346struct Row(usize);
347
348impl Row {
349    const ZERO: Self = Self(0);
350}
351
352impl AddAssign<usize> for Row {
353    fn add_assign(&mut self, rhs: usize) {
354        self.0 += rhs;
355    }
356}
357
358/// Slice `&str` with [`Bytes`]-typed offsets.
359trait ByteSlice {
360    fn slice(&self, range: Range<ByteOffset>) -> &str;
361    fn slice_from(&self, start: ByteOffset) -> &str;
362    fn slice_to(&self, end: ByteOffset) -> &str;
363}
364
365impl ByteSlice for str {
366    fn slice(&self, range: Range<ByteOffset>) -> &str {
367        &self[range.start.0..range.end.0]
368    }
369    fn slice_from(&self, start: ByteOffset) -> &str {
370        &self[start.0..]
371    }
372    fn slice_to(&self, end: ByteOffset) -> &str {
373        &self[..end.0]
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380    use crossterm::style::Color;
381
382    #[test]
383    fn wraps_ascii_to_width() {
384        let rows = soft_wrap_line(&Line::new("abcdef"), 3);
385        assert_eq!(rows, vec![Line::new("abc"), Line::new("def")]);
386    }
387
388    #[test]
389    fn display_width_ignores_style() {
390        let mut line = Line::default();
391        line.push_styled("he", Color::Red);
392        line.push_text("llo");
393        assert_eq!(display_width_line(&line), 5);
394    }
395
396    #[test]
397    fn wraps_preserving_style_spans() {
398        let line = Line::styled("abcdef", Color::Red);
399        let rows = soft_wrap_line(&line, 3);
400        assert_eq!(rows.len(), 2);
401        assert_eq!(rows[0].plain_text(), "abc");
402        assert_eq!(rows[1].plain_text(), "def");
403        assert_eq!(rows[0].spans().len(), 1);
404        assert_eq!(rows[1].spans().len(), 1);
405        assert_eq!(rows[0].spans()[0].style().fg, Some(Color::Red));
406        assert_eq!(rows[1].spans()[0].style().fg, Some(Color::Red));
407    }
408
409    #[test]
410    fn counts_wide_unicode() {
411        assert_eq!(display_width_text("中a"), 3);
412        let rows = soft_wrap_line(&Line::new("中ab"), 3);
413        assert_eq!(rows, vec![Line::new("中a"), Line::new("b")]);
414    }
415
416    #[test]
417    fn wraps_multi_span_line_mid_span() {
418        let mut line = Line::default();
419        line.push_styled("ab", Color::Red);
420        line.push_styled("cd", Color::Blue);
421        line.push_styled("ef", Color::Green);
422        let rows = soft_wrap_line(&line, 3);
423        assert_eq!(rows.len(), 2);
424        assert_eq!(rows[0].plain_text(), "abc");
425        assert_eq!(rows[1].plain_text(), "def");
426        // First row: "ab" (Red) + "c" (Blue)
427        assert_eq!(rows[0].spans().len(), 2);
428        assert_eq!(rows[0].spans()[0].style().fg, Some(Color::Red));
429        assert_eq!(rows[0].spans()[1].style().fg, Some(Color::Blue));
430        // Second row: "d" (Blue) + "ef" (Green)
431        assert_eq!(rows[1].spans().len(), 2);
432        assert_eq!(rows[1].spans()[0].style().fg, Some(Color::Blue));
433        assert_eq!(rows[1].spans()[1].style().fg, Some(Color::Green));
434    }
435
436    #[test]
437    fn wraps_line_with_embedded_newlines() {
438        let line = Line::new("abc\ndef");
439        let rows = soft_wrap_line(&line, 80);
440        assert_eq!(rows.len(), 2);
441        assert_eq!(rows[0].plain_text(), "abc");
442        assert_eq!(rows[1].plain_text(), "def");
443    }
444
445    #[test]
446    fn pad_text_pads_ascii_to_target_width() {
447        let result = pad_text_to_width("hello", 10);
448        assert_eq!(result, "hello     ");
449        assert_eq!(display_width_text(&result), 10);
450    }
451
452    #[test]
453    fn pad_text_returns_borrowed_when_already_wide_enough() {
454        let result = pad_text_to_width("hello", 5);
455        assert!(matches!(result, Cow::Borrowed(_)));
456        assert_eq!(result, "hello");
457
458        let result = pad_text_to_width("hello", 3);
459        assert!(matches!(result, Cow::Borrowed(_)));
460        assert_eq!(result, "hello");
461    }
462
463    #[test]
464    fn pad_text_handles_wide_unicode() {
465        // "中" is 2 display columns wide
466        let result = pad_text_to_width("中a", 6);
467        assert_eq!(display_width_text(&result), 6);
468        assert_eq!(result, "中a   "); // 2+1 = 3 cols, need 3 spaces
469    }
470
471    #[test]
472    fn truncate_text_fits_within_width() {
473        assert_eq!(truncate_text("hello", 10), "hello");
474        assert_eq!(truncate_text("hello world", 8), "hello...");
475        assert_eq!(truncate_text("hello", 5), "hello");
476        assert_eq!(truncate_text("hello", 4), "h...");
477    }
478
479    #[test]
480    fn truncate_text_handles_wide_unicode() {
481        // Chinese characters are 2 columns wide
482        assert_eq!(truncate_text("中文字", 5), "中..."); // 6 cols -> truncate to 2+3=5
483        assert_eq!(truncate_text("中ab", 4), "中ab"); // 2+1+1=4, fits exactly
484        assert_eq!(truncate_text("中abc", 4), "..."); // 5 cols, only ellipsis fits in 4
485        assert_eq!(truncate_text("中abcde", 6), "中a..."); // 7 cols -> truncate to 2+1+3=6
486    }
487
488    #[test]
489    fn truncate_text_handles_zero_width() {
490        assert_eq!(truncate_text("hello", 0), "");
491    }
492
493    #[test]
494    fn truncate_text_max_width_1() {
495        let result = truncate_text("hello", 1);
496        assert!(
497            display_width_text(&result) <= 1,
498            "Expected width <= 1, got '{}' (width {})",
499            result,
500            display_width_text(&result),
501        );
502        assert_eq!(result, "h");
503    }
504
505    #[test]
506    fn truncate_text_max_width_2() {
507        let result = truncate_text("hello", 2);
508        assert!(
509            display_width_text(&result) <= 2,
510            "Expected width <= 2, got '{}' (width {})",
511            result,
512            display_width_text(&result),
513        );
514        assert_eq!(result, "he");
515    }
516
517    #[test]
518    fn truncate_line_returns_short_lines_unchanged() {
519        let line = Line::new("short");
520        let result = truncate_line(&line, 20);
521        assert_eq!(result.plain_text(), "short");
522    }
523
524    #[test]
525    fn truncate_line_trims_long_styled_lines() {
526        let mut line = Line::default();
527        line.push_styled("hello", Color::Red);
528        line.push_styled(" world", Color::Blue);
529        let result = truncate_line(&line, 7);
530        assert_eq!(result.plain_text(), "hello w");
531        assert_eq!(result.spans().len(), 2);
532        assert_eq!(result.spans()[0].style().fg, Some(Color::Red));
533        assert_eq!(result.spans()[1].style().fg, Some(Color::Blue));
534    }
535
536    #[test]
537    fn truncate_line_handles_mid_span_cut() {
538        let line = Line::styled("abcdefgh", Color::Green);
539        let result = truncate_line(&line, 4);
540        assert_eq!(result.plain_text(), "abcd");
541        assert_eq!(result.spans()[0].style().fg, Some(Color::Green));
542    }
543
544    #[test]
545    fn truncate_line_handles_wide_unicode_at_boundary() {
546        // "中" is 2 display columns, "文" is 2.
547        // Budget of 3: "中"(2) fits, "文"(2) would exceed (2+2=4>3), so stop.
548        let line = Line::new("中文x");
549        let result = truncate_line(&line, 3);
550        assert_eq!(result.plain_text(), "中");
551
552        // Budget of 4: "中"(2) + "文"(2) = 4, fits exactly.
553        let result = truncate_line(&line, 4);
554        assert_eq!(result.plain_text(), "中文");
555
556        // Budget of 5: all fit: 2+2+1=5.
557        let result = truncate_line(&line, 5);
558        assert_eq!(result.plain_text(), "中文x");
559    }
560
561    #[test]
562    fn truncate_line_zero_width_returns_empty() {
563        let line = Line::new("hello");
564        let result = truncate_line(&line, 0);
565        assert!(result.is_empty());
566    }
567
568    #[test]
569    fn soft_wrap_text_position_uses_word_boundaries() {
570        assert_eq!(soft_wrap_text_position("hello world", "hello ".len(), 7), (1, 0));
571        assert_eq!(soft_wrap_text_position("hello world", "hello world".len(), 7), (1, 5));
572    }
573
574    #[test]
575    fn soft_wrap_text_byte_offset_uses_word_boundaries() {
576        assert_eq!(soft_wrap_text_byte_offset("hello world", 0, 5, 7), 5);
577        assert_eq!(soft_wrap_text_byte_offset("hello world", 1, 3, 7), 9);
578    }
579
580    #[test]
581    fn soft_wrap_text_position_handles_trailing_newline() {
582        assert_eq!(soft_wrap_text_position("hello\n", "hello\n".len(), 10), (1, 0));
583    }
584
585    #[test]
586    fn soft_wrap_text_position_places_hard_wrap_boundary_on_next_row() {
587        assert_eq!(soft_wrap_text_position("abcdef", 3, 3), (1, 0));
588    }
589
590    #[test]
591    fn wraps_at_word_boundary() {
592        let rows = soft_wrap_line(&Line::new("hello world"), 7);
593        assert_eq!(rows.len(), 2);
594        assert_eq!(rows[0].plain_text(), "hello");
595        assert_eq!(rows[1].plain_text(), "world");
596    }
597
598    #[test]
599    fn wraps_multiple_words() {
600        let rows = soft_wrap_line(&Line::new("hello world foo"), 12);
601        assert_eq!(rows.len(), 2);
602        assert_eq!(rows[0].plain_text(), "hello world");
603        assert_eq!(rows[1].plain_text(), "foo");
604    }
605
606    #[test]
607    fn falls_back_to_char_break_without_whitespace() {
608        let rows = soft_wrap_line(&Line::new("superlongword next"), 5);
609        assert_eq!(rows[0].plain_text(), "super");
610        assert_eq!(rows[1].plain_text(), "longw");
611        assert_eq!(rows[2].plain_text(), "ord");
612        assert_eq!(rows[3].plain_text(), "next");
613    }
614
615    #[test]
616    fn wraps_at_word_boundary_with_styled_spans() {
617        let line = Line::styled("hello world", Color::Red);
618        let rows = soft_wrap_line(&line, 7);
619        assert_eq!(rows.len(), 2);
620        assert_eq!(rows[0].plain_text(), "hello");
621        assert_eq!(rows[1].plain_text(), "world");
622        assert_eq!(rows[0].spans()[0].style().fg, Some(Color::Red));
623        assert_eq!(rows[1].spans()[0].style().fg, Some(Color::Red));
624    }
625
626    #[test]
627    fn wraps_at_whitespace_across_span_boundaries() {
628        let mut line = Line::default();
629        line.push_styled("@aaaaa", Color::Red);
630        line.push_text(" ");
631        line.push_styled("@bbbbbb", Color::Blue);
632
633        let rows = soft_wrap_line(&line, 10);
634
635        assert_eq!(rows.len(), 2);
636        assert_eq!(rows[0].plain_text(), "@aaaaa");
637        assert_eq!(rows[1].plain_text(), "@bbbbbb");
638        assert_eq!(rows[0].spans()[0].style().fg, Some(Color::Red));
639        assert_eq!(rows[1].spans()[0].style().fg, Some(Color::Blue));
640    }
641
642    #[test]
643    fn hard_wraps_long_styled_token_without_whitespace() {
644        let line = Line::styled("@abcdefghijk", Color::Green);
645        let rows = soft_wrap_line(&line, 5);
646
647        assert_eq!(rows.len(), 3);
648        assert_eq!(rows[0].plain_text(), "@abcd");
649        assert_eq!(rows[1].plain_text(), "efghi");
650        assert_eq!(rows[2].plain_text(), "jk");
651        for row in &rows {
652            assert_eq!(row.spans()[0].style().fg, Some(Color::Green));
653        }
654    }
655
656    #[test]
657    fn drops_whitespace_when_new_span_starts_at_wrap_boundary() {
658        let mut line = Line::default();
659        line.push_styled("abcdefghij", Color::Red);
660        line.push_styled(" klm", Color::Blue);
661        let rows = soft_wrap_line(&line, 10);
662
663        assert_eq!(rows.len(), 2);
664        assert_eq!(rows[0].plain_text(), "abcdefghij");
665        assert_eq!(rows[1].plain_text(), "klm");
666        assert_eq!(rows[1].spans()[0].style().fg, Some(Color::Blue));
667    }
668
669    #[test]
670    fn soft_wrap_propagates_fill_to_each_wrapped_row() {
671        let line = Line::new("abcdef").with_fill(Color::Red);
672        let rows = soft_wrap_line(&line, 3);
673        assert_eq!(rows.len(), 2);
674        for row in &rows {
675            assert_eq!(row.fill(), Some(Color::Red));
676        }
677    }
678
679    #[test]
680    fn soft_wrap_preserves_fill_on_empty_line() {
681        let line = Line::default().with_fill(Color::Red);
682        let rows = soft_wrap_line(&line, 10);
683        assert_eq!(rows.len(), 1);
684        assert_eq!(rows[0].fill(), Some(Color::Red));
685    }
686
687    #[test]
688    fn truncate_line_preserves_fill_metadata() {
689        let line = Line::new("abcdef").with_fill(Color::Blue);
690        let truncated = truncate_line(&line, 3);
691        assert_eq!(truncated.plain_text(), "abc");
692        assert_eq!(truncated.fill(), Some(Color::Blue));
693    }
694
695    #[test]
696    fn wraps_across_spans_without_panic() {
697        let mut line = Line::default();
698        line.push_styled("hello ", Color::Red);
699        line.push_styled("world this is long", Color::Blue);
700        let rows = soft_wrap_line(&line, 10);
701        assert_eq!(rows[0].plain_text(), "hello");
702        assert_eq!(rows[1].plain_text(), "world this");
703        assert_eq!(rows[2].plain_text(), "is long");
704    }
705}