Skip to main content

mermaid_cli/render/
wrap.rs

1//! Text wrapping: plain and styled, with hard-break fallback for tokens that
2//! cannot fit.
3//!
4//! Lived inside `widgets/chat.rs`, which is why `widgets/question.rs` imported
5//! `wrap_styled_line` from a sibling WIDGET. Wrapping is not a chat concern —
6//! it is a render-layer primitive that several widgets need — so it sits one
7//! level up and that import becomes legitimate.
8
9use ratatui::style::Style;
10use ratatui::text::{Line, Span};
11use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
12
13/// Hard-break a single over-long token into the plain-text line accumulator,
14/// splitting at char boundaries (UTF-8-safe, display-cell aware) so a giant
15/// unbroken token (e.g. a 5000-char URL) wraps across lines instead of
16/// overflowing the viewport and being clipped (F33).
17///
18/// Mirrors the accumulation `wrap_text_with_indent` does for normal words:
19/// `current_line`/`current_length` carry the in-progress line (its indent
20/// already pushed into `current_line`, not counted in `current_length`),
21/// finished lines are pushed to `out`, and each new line gets a
22/// `continuation_indent`-space hanging indent. `initial_budget` is the content
23/// width available on the line the token starts on (the caller's per-line
24/// `available_width`); subsequent lines use `width - continuation_indent`.
25pub(crate) fn hard_break_plain_token(
26    token: &str,
27    out: &mut Vec<String>,
28    current_line: &mut String,
29    current_length: &mut usize,
30    width: usize,
31    continuation_indent: usize,
32    initial_budget: usize,
33) {
34    let cont_budget = width.saturating_sub(continuation_indent).max(1);
35    let mut line_budget = initial_budget.max(1);
36
37    // If the current line already holds content, flush it so the token starts
38    // fresh on a continuation line; otherwise break onto the current (indent-
39    // only) line directly.
40    if *current_length > 0 {
41        out.push(std::mem::take(current_line));
42        current_line.push_str(&" ".repeat(continuation_indent));
43        *current_length = 0;
44        line_budget = cont_budget;
45    }
46
47    for ch in token.chars() {
48        let cw = ch.width().unwrap_or(0);
49        // Break before this char if it would overflow and the line already
50        // holds at least one glyph (so a single too-wide glyph never loops).
51        if *current_length + cw > line_budget && *current_length > 0 {
52            out.push(std::mem::take(current_line));
53            current_line.push_str(&" ".repeat(continuation_indent));
54            *current_length = 0;
55            line_budget = cont_budget;
56        }
57        current_line.push(ch);
58        *current_length += cw;
59    }
60}
61
62/// Wrap text with hanging indent support.
63///
64/// `width`, `first_line_indent`, and `continuation_indent` are all measured
65/// in **display cells**, not bytes. Word lengths are also measured in cells
66/// via `UnicodeWidthStr::width` so CJK / emoji wrap at the visual edge —
67/// previously a CJK paragraph would wrap after ~1/3 of the line because
68/// `word.len()` (bytes) is roughly 3× `word.width()` (cells) for 3-byte
69/// codepoints.
70pub(crate) fn wrap_text_with_indent(
71    text: &str,
72    width: usize,
73    first_line_indent: usize,
74    continuation_indent: usize,
75) -> Vec<String> {
76    let mut wrapped_lines = Vec::new();
77
78    for (line_idx, line) in text.lines().enumerate() {
79        if line.is_empty() {
80            wrapped_lines.push(String::new());
81            continue;
82        }
83
84        let current_indent = if line_idx == 0 {
85            first_line_indent
86        } else {
87            continuation_indent
88        };
89        let available_width = width.saturating_sub(current_indent);
90
91        if available_width == 0 {
92            wrapped_lines.push(" ".repeat(current_indent));
93            continue;
94        }
95
96        let words: Vec<&str> = line.split_whitespace().collect();
97        if words.is_empty() {
98            wrapped_lines.push(" ".repeat(current_indent));
99            continue;
100        }
101
102        let mut current_line = String::with_capacity(width);
103        current_line.push_str(&" ".repeat(current_indent));
104        // Display-cell widths: indent is ASCII spaces (1 cell each), so
105        // start fresh and let words contribute their own cell widths.
106        let mut current_length = 0;
107
108        for (word_idx, word) in words.iter().enumerate() {
109            let word_width = word.width();
110
111            if word_idx == 0 {
112                if word_width <= available_width {
113                    // First word fits on the line
114                    current_line.push_str(word);
115                    current_length = word_width;
116                } else {
117                    // A single token wider than the whole line (e.g. a long
118                    // URL): hard-break it at width boundaries so it wraps
119                    // instead of overflowing the viewport and being clipped
120                    // (F33).
121                    hard_break_plain_token(
122                        word,
123                        &mut wrapped_lines,
124                        &mut current_line,
125                        &mut current_length,
126                        width,
127                        continuation_indent,
128                        available_width,
129                    );
130                }
131            } else if current_length + 1 + word_width <= available_width {
132                // Word fits on current line (the +1 accounts for the
133                // separator space, which is 1 cell)
134                current_line.push(' ');
135                current_line.push_str(word);
136                current_length += 1 + word_width;
137            } else if word_width <= available_width {
138                // Word doesn't fit, start a new line
139                wrapped_lines.push(current_line);
140                current_line = String::with_capacity(width);
141                current_line.push_str(&" ".repeat(continuation_indent));
142                current_line.push_str(word);
143                current_length = word_width;
144            } else {
145                // Over-long token mid-paragraph: flush the current line, then
146                // hard-break the token across continuation lines (F33).
147                hard_break_plain_token(
148                    word,
149                    &mut wrapped_lines,
150                    &mut current_line,
151                    &mut current_length,
152                    width,
153                    continuation_indent,
154                    available_width,
155                );
156            }
157        }
158
159        // Add the last line
160        if !current_line.trim().is_empty() {
161            wrapped_lines.push(current_line);
162        }
163    }
164
165    wrapped_lines
166}
167
168/// Hard-break a single over-long word into the styled line accumulator,
169/// splitting at char boundaries (UTF-8-safe, display-cell aware) and keeping
170/// each fragment's own style on every produced piece, so a giant unbroken
171/// token (e.g. a long URL) wraps across rows instead of overflowing the
172/// viewport and being clipped (F33). The styled counterpart of
173/// `hard_break_plain_token`. The word arrives as styled fragments (see the
174/// flattening pass in `wrap_styled_line`) because a token can change style
175/// mid-word (`**bold**suffix`); the break must not flatten that to one style.
176///
177/// `current_line_spans`/`current_line_width` carry the in-progress row;
178/// finished rows are pushed to `result_lines`; each new row opens with a
179/// `continuation_indent`-space span. `line_capacity` is the width budget for
180/// the row the token starts on (the first row counts its leading indent in
181/// `current_line_width`, so its budget is the full `width`); wrapped rows use
182/// `continuation_capacity` (the caller's `available_width`, with the indent in
183/// a separate span and not counted).
184pub(crate) fn hard_break_styled_word(
185    fragments: &[(String, Style)],
186    result_lines: &mut Vec<Line<'static>>,
187    current_line_spans: &mut Vec<Span<'static>>,
188    current_line_width: &mut usize,
189    continuation_indent: usize,
190    continuation_capacity: usize,
191    mut line_capacity: usize,
192) {
193    for (text, style) in fragments {
194        let mut buf = String::new();
195        for ch in text.chars() {
196            let cw = ch.width().unwrap_or(0);
197            // Break before this char if it would overflow and the row already
198            // holds at least one glyph (so a single too-wide glyph never loops).
199            if *current_line_width + cw > line_capacity && *current_line_width > 0 {
200                if !buf.is_empty() {
201                    current_line_spans.push(Span::styled(std::mem::take(&mut buf), *style));
202                }
203                result_lines.push(Line::from(std::mem::take(current_line_spans)));
204                current_line_spans.push(Span::raw(" ".repeat(continuation_indent)));
205                *current_line_width = 0;
206                line_capacity = continuation_capacity.max(1);
207            }
208            buf.push(ch);
209            *current_line_width += cw;
210        }
211        if !buf.is_empty() {
212            current_line_spans.push(Span::styled(buf, *style));
213        }
214    }
215}
216
217/// One word of a styled line: a run of styled fragments plus the style of the
218/// whitespace that separated it from the previous word.
219struct Word {
220    fragments: Vec<(String, Style)>,
221    /// Style of the whitespace run that preceded this word, taken from the
222    /// span that whitespace lived in. Interior gaps of a styled run keep
223    /// the run's style; gaps between runs carry the plain prose style.
224    separator: Style,
225}
226
227/// Cells of leading space across the line's spans, up to the first span that
228/// carries non-space content — the "  " continuation gutter a caller prepends.
229fn leading_space_cells(spans: &[Span<'static>]) -> usize {
230    let mut n = 0;
231    for span in spans {
232        let spaces = span.content.len() - span.content.trim_start_matches(' ').len();
233        n += spaces;
234        if spaces < span.content.len() {
235            break; // this span has non-space content, so leading run ends here
236        }
237    }
238    n
239}
240
241/// Flatten the spans into words: each word is a run of styled fragments plus
242/// the style of the whitespace that separated it from the previous word.
243/// Whitespace anywhere closes the current word (runs collapse to a single
244/// boundary); a span ending mid-word leaves the word open so the next
245/// span's text glues on — a style change is NOT a word boundary.
246fn flatten_words(spans: &[Span<'static>]) -> Vec<Word> {
247    let mut words: Vec<Word> = Vec::new();
248    let mut current_word: Vec<(String, Style)> = Vec::new();
249    // Separator in front of the word currently being built. The first word has
250    // no preceding gap, so its value is never emitted.
251    let mut separator = Style::default();
252    for span in spans {
253        let mut frag = String::new();
254        for ch in span.content.chars() {
255            if ch.is_whitespace() {
256                if !frag.is_empty() {
257                    current_word.push((std::mem::take(&mut frag), span.style));
258                }
259                if !current_word.is_empty() {
260                    words.push(Word {
261                        fragments: std::mem::take(&mut current_word),
262                        separator,
263                    });
264                }
265                // This gap belongs to the span it was written in, and becomes
266                // the separator in front of the NEXT word — that is what keeps
267                // a multi-word code span's background continuous.
268                separator = span.style;
269            } else {
270                frag.push(ch);
271            }
272        }
273        if !frag.is_empty() {
274            current_word.push((frag, span.style));
275        }
276    }
277    if !current_word.is_empty() {
278        words.push(Word {
279            fragments: current_word,
280            separator,
281        });
282    }
283    words
284}
285
286/// Wrap a styled Line with hanging indent, preserving all span styles.
287/// Returns multiple Line objects with proper indentation.
288///
289/// Wrapping runs over a word stream flattened ACROSS spans: a word is a run of
290/// styled fragments, and a word boundary exists only where the source text has
291/// whitespace. A span ending mid-word glues onto the next span's text, so
292/// `**bold**suffix` stays one token and a `.` right after a link's dimmed URL
293/// stays attached (no phantom space at a style boundary).
294///
295/// A separator space is re-emitted with the style of the span the whitespace
296/// CAME FROM, so a gap *inside* a styled run keeps that run's paint while a gap
297/// *between* runs stays plain. This is what keeps multi-word inline code
298/// (`` `No image data found` ``) one continuous block instead of a row of
299/// disconnected per-word boxes, and still leaves the space in front of a link
300/// un-underlined (that space belongs to the preceding prose span).
301pub(crate) fn wrap_styled_line(
302    line: Line<'static>,
303    width: usize,
304    continuation_indent: usize,
305) -> Vec<Line<'static>> {
306    // Widths are counted in display cells (via `UnicodeWidthStr`), not
307    // bytes. This makes CJK double-width chars and emoji wrap at the
308    // correct visual column, and avoids over-wrapping multi-byte ASCII-
309    // looking glyphs.
310    let total_width: usize = line.spans.iter().map(|s| s.content.width()).sum();
311
312    // If the line fits within width, return as-is
313    if total_width <= width {
314        return vec![line];
315    }
316
317    // Line needs wrapping - extract all text and styles
318    let mut result_lines = Vec::new();
319    let mut current_line_spans: Vec<Span<'static>> = Vec::new();
320    let mut current_line_width = 0usize;
321    let available_width = width.saturating_sub(continuation_indent);
322
323    // Preserve the line's existing left margin (the "  " continuation gutter the
324    // caller prepends to every non-first message line) on the *first* wrapped
325    // segment. The whitespace split below drops leading spaces and the "first
326    // word, no indent" rule would then flush the segment to column 0 — that's the
327    // recurring bug where a wrapped paragraph escapes the message gutter while its
328    // own continuation lines (which get `continuation_indent`) stay aligned. A
329    // non-whitespace prefix like "● " is unaffected (it survives the split).
330    let leading_indent = leading_space_cells(&line.spans);
331    let words = flatten_words(&line.spans);
332
333    fn emit_word(spans: &mut Vec<Span<'static>>, word: Vec<(String, Style)>) {
334        for (text, style) in word {
335            spans.push(Span::styled(text, style));
336        }
337    }
338
339    for Word {
340        fragments: word,
341        separator,
342    } in words
343    {
344        let word_width: usize = word.iter().map(|(text, _)| text.width()).sum();
345
346        if current_line_width == 0 && result_lines.is_empty() {
347            // First word of the first line: re-apply the original left margin
348            // (dropped by the whitespace split) so the segment keeps the gutter
349            // instead of flushing to column 0.
350            if leading_indent > 0 {
351                current_line_spans.push(Span::raw(" ".repeat(leading_indent)));
352                current_line_width += leading_indent;
353            }
354            if word_width <= available_width {
355                current_line_width += word_width;
356                emit_word(&mut current_line_spans, word);
357            } else {
358                // A single token wider than the line (e.g. a long URL):
359                // hard-break it at width boundaries so it wraps instead of
360                // being clipped by the viewport (F33). The first row may use
361                // the full `width` (its indent is already counted above);
362                // continuation rows fall back to `available_width`.
363                hard_break_styled_word(
364                    &word,
365                    &mut result_lines,
366                    &mut current_line_spans,
367                    &mut current_line_width,
368                    continuation_indent,
369                    available_width,
370                    width,
371                );
372            }
373            continue;
374        }
375
376        // Separator space before this word — only when the row already holds
377        // content, and painted with the style of the span the gap came from
378        // (see the flattening pass): interior gaps of a code span keep its
379        // background, gaps between runs stay plain. A gap that lands on a wrap
380        // point is dropped entirely, so no row ends in a highlighted space.
381        let sep = usize::from(current_line_width > 0);
382        if current_line_width + sep + word_width <= available_width {
383            // Word fits on current line
384            if sep == 1 {
385                current_line_spans.push(Span::styled(" ", separator));
386            }
387            current_line_width += sep + word_width;
388            emit_word(&mut current_line_spans, word);
389        } else if word_width <= available_width {
390            // Word doesn't fit - finish current line and start new one
391            result_lines.push(Line::from(std::mem::take(&mut current_line_spans)));
392            current_line_spans.push(Span::raw(" ".repeat(continuation_indent)));
393            current_line_width = word_width;
394            emit_word(&mut current_line_spans, word);
395        } else {
396            // Over-long token mid-line: finish the current line, then
397            // hard-break the token across continuation rows (F33), keeping
398            // each fragment's style on every produced piece.
399            result_lines.push(Line::from(std::mem::take(&mut current_line_spans)));
400            current_line_spans.push(Span::raw(" ".repeat(continuation_indent)));
401            current_line_width = 0;
402            hard_break_styled_word(
403                &word,
404                &mut result_lines,
405                &mut current_line_spans,
406                &mut current_line_width,
407                continuation_indent,
408                available_width,
409                available_width,
410            );
411        }
412    }
413
414    // Add the last line if it has content
415    if !current_line_spans.is_empty() {
416        result_lines.push(Line::from(current_line_spans));
417    }
418
419    if result_lines.is_empty() {
420        vec![line]
421    } else {
422        result_lines
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429
430    /// CJK characters are 3 bytes but 2 display cells each. The
431    /// byte-length version of `wrap_styled_line` would incorrectly
432    /// over-wrap such input. This test asserts the display-width
433    /// version keeps CJK-only input on a single line when the display
434    /// width fits, even when the byte length exceeds the width.
435    #[test]
436    fn wrap_styled_line_uses_display_width_for_cjk() {
437        // "你好世界" is 4 CJK chars × 3 bytes = 12 bytes, × 2 display cells = 8 cells.
438        // Target width of 10: byte-length would see 12 > 10 and wrap;
439        // display-width sees 8 <= 10 and keeps it on one line.
440        let line = Line::from(Span::raw("你好世界".to_string()));
441        let wrapped = wrap_styled_line(line, 10, 2);
442        assert_eq!(
443            wrapped.len(),
444            1,
445            "CJK input fitting in display-width should NOT be wrapped; got {} lines",
446            wrapped.len()
447        );
448    }
449
450    /// Sanity: ASCII wrapping still works and produces >= 2 lines when
451    /// the input exceeds the width.
452    #[test]
453    fn wrap_styled_line_ascii_wraps_when_too_long() {
454        let line = Line::from(Span::raw(
455            "the quick brown fox jumps over the lazy dog".to_string(),
456        ));
457        let wrapped = wrap_styled_line(line, 15, 2);
458        assert!(
459            wrapped.len() >= 2,
460            "long ASCII input should wrap to multiple lines; got {}",
461            wrapped.len()
462        );
463    }
464
465    fn first_segment_text(wrapped: &[Line<'static>]) -> String {
466        wrapped[0]
467            .spans
468            .iter()
469            .map(|s| s.content.as_ref())
470            .collect()
471    }
472
473    /// Regression (recurring "paragraph escapes the gutter" bug): a non-first
474    /// message line carries a 2-space gutter prefix; when it wraps, the first
475    /// segment must keep that gutter, not flush to column 0. `split_whitespace`
476    /// used to drop the leading spaces and the "first word, no indent" rule
477    /// flushed the segment left.
478    #[test]
479    fn wrap_styled_line_keeps_gutter_on_wrapped_paragraph() {
480        let line = Line::from(vec![
481            Span::raw("  "), // the continuation gutter chat.rs prepends
482            Span::raw(
483                "No source files, no config, no docs, no build system and more words to wrap"
484                    .to_string(),
485            ),
486        ]);
487        let wrapped = wrap_styled_line(line, 30, 2);
488        assert!(wrapped.len() >= 2, "should wrap");
489        let first = first_segment_text(&wrapped);
490        assert!(
491            first.starts_with("  ") && first.trim_start().starts_with("No source"),
492            "first wrapped segment must keep the 2-space gutter; got {first:?}"
493        );
494    }
495
496    /// Regression: a multi-word inline code span used to render as one box per
497    /// word. The wrapper re-emitted every separator space unstyled, punching
498    /// plain gaps through the code background — every wrapped answer containing
499    /// `` `a phrase like this` `` came out visually shredded. Gaps *inside* a
500    /// styled run now keep that run's style; the gap *before* it stays plain.
501    #[test]
502    fn wrap_styled_line_keeps_inline_code_background_across_its_spaces() {
503        let code = Style::default().bg(ratatui::style::Color::Rgb(40, 40, 40));
504        let line = Line::from(vec![
505            Span::raw("read_image_bytes bails with ".to_string()),
506            Span::styled("No image data found in clipboard".to_string(), code),
507            Span::raw(" and the effect routes it onward".to_string()),
508        ]);
509        let wrapped = wrap_styled_line(line, 40, 2);
510        assert!(wrapped.len() >= 2, "should wrap");
511
512        // Walk the produced spans in order: every space BETWEEN two code-styled
513        // spans must itself be code-styled; the space before the run must not.
514        let spans: Vec<_> = wrapped.iter().flat_map(|l| l.spans.iter()).collect();
515        let interior_gaps = spans
516            .windows(3)
517            .filter(|w| {
518                w[1].content.as_ref() == " " && w[0].style.bg.is_some() && w[2].style.bg.is_some()
519            })
520            .count();
521        assert!(
522            interior_gaps >= 3,
523            "the 5-word code span should keep its background on interior gaps; got \
524             {interior_gaps} in {:?}",
525            spans
526                .iter()
527                .map(|s| (s.content.as_ref(), s.style.bg))
528                .collect::<Vec<_>>()
529        );
530        assert!(
531            spans.windows(2).all(|w| {
532                !(w[0].content.as_ref() == " "
533                    && w[0].style.bg.is_some()
534                    && w[1].style.bg.is_none())
535            }),
536            "no highlighted space may leak onto the plain prose that follows"
537        );
538    }
539
540    /// End-to-end: a wrapped list item keeps the bullet on the first segment and
541    /// hangs its continuation lines under the item text (col 6 = 2 gutter + 2
542    /// nesting indent + 2 marker), instead of snapping back to the message gutter.
543    /// Exercises the same span shape chat.rs builds, with the continuation indent
544    /// chat.rs derives via `markdown::line_hanging_indent` (4) + the gutter (2).
545    #[test]
546    fn wrap_styled_line_hangs_list_continuation_under_marker() {
547        let line = Line::from(vec![
548            Span::raw("  "), // message gutter (chat.rs)
549            Span::raw("  "), // list nesting indent (markdown)
550            Span::raw("• "), // marker (markdown)
551            Span::raw("alpha beta gamma delta epsilon zeta eta theta iota".to_string()),
552        ]);
553        let wrapped = wrap_styled_line(line, 24, 6);
554        assert!(wrapped.len() >= 2, "should wrap");
555        assert!(
556            first_segment_text(&wrapped).starts_with("    • "),
557            "first segment keeps gutter + nesting + marker"
558        );
559        for cont in &wrapped[1..] {
560            let t: String = cont.spans.iter().map(|s| s.content.as_ref()).collect();
561            assert!(
562                t.starts_with("      ") && t.chars().nth(6).is_some_and(|c| c != ' '),
563                "continuation hangs under the item text at col 6; got {t:?}"
564            );
565        }
566    }
567
568    /// The fix preserves whitespace margins only — the message bullet "● " must
569    /// still sit at column 0 on the first line.
570    #[test]
571    fn wrap_styled_line_keeps_bullet_at_column_zero() {
572        let line = Line::from(vec![
573            Span::raw("● "),
574            Span::raw(
575                "a fairly long first line of a message that definitely needs to wrap".to_string(),
576            ),
577        ]);
578        let wrapped = wrap_styled_line(line, 25, 2);
579        assert!(wrapped.len() >= 2, "should wrap");
580        assert!(
581            first_segment_text(&wrapped).starts_with('●'),
582            "bullet must stay at column 0"
583        );
584    }
585
586    /// Counterpart to `wrap_styled_line_uses_display_width_for_cjk` for
587    /// the plain-string wrapper used by user messages and thinking blocks.
588    /// The byte-based version would wrap a 4-CJK paragraph after the second
589    /// char (12 bytes > 10) even though it fits in 8 cells. Display-width
590    /// version keeps it on one line.
591    #[test]
592    fn wrap_text_with_indent_uses_display_width_for_cjk() {
593        // "你好世界" = 4 chars, 12 bytes, 8 display cells. Width 12 cells
594        // with 0 indent: should fit on one line.
595        let wrapped = wrap_text_with_indent("你好世界", 12, 0, 0);
596        assert_eq!(
597            wrapped.len(),
598            1,
599            "CJK paragraph fitting in display width should not wrap; got {} lines: {:?}",
600            wrapped.len(),
601            wrapped
602        );
603        assert_eq!(wrapped[0].trim_start(), "你好世界");
604    }
605
606    /// Mixed content: CJK + ASCII should still wrap correctly when the
607    /// total exceeds available cells.
608    #[test]
609    fn wrap_text_with_indent_wraps_cjk_at_visual_edge() {
610        // "你好 world 世界" = 2 + 1 + 5 + 1 + 2 = 11 cells without spaces,
611        // with separators: 2 + 1 + 5 + 1 + 4 = 13 cells. Width 8 cells should
612        // produce ≥ 2 lines.
613        let wrapped = wrap_text_with_indent("你好 world 世界", 8, 0, 0);
614        assert!(
615            wrapped.len() >= 2,
616            "mixed CJK+ASCII exceeding width should wrap; got {} lines: {:?}",
617            wrapped.len(),
618            wrapped
619        );
620    }
621
622    #[test]
623    fn wrap_text_with_indent_hard_breaks_overlong_token() {
624        // F33: a single unbroken token far wider than the viewport must
625        // hard-break at width boundaries instead of overflowing and being
626        // clipped. No internal spaces, so word-wrapping alone can't split it.
627        let token = "x".repeat(100);
628        let width = 20;
629        let wrapped = wrap_text_with_indent(&token, width, 2, 2);
630        assert!(
631            wrapped.len() >= 5,
632            "a 100-cell token at width 20 must span many rows; got {}",
633            wrapped.len()
634        );
635        for line in &wrapped {
636            assert!(
637                line.chars().count() <= width,
638                "no wrapped row may exceed the width; got {:?} ({} cells)",
639                line,
640                line.chars().count()
641            );
642        }
643        // Stripping each row's hanging indent reconstructs the token intact.
644        let joined: String = wrapped.iter().map(|l| l.trim_start()).collect();
645        assert_eq!(
646            joined, token,
647            "hard-break must preserve the token's content"
648        );
649    }
650
651    #[test]
652    fn wrap_styled_line_hard_breaks_overlong_token() {
653        // F33 (styled path): the same hard-break, preserving each piece's style.
654        let token = "y".repeat(90);
655        let style = Style::new().fg(ratatui::style::Color::Red);
656        let line = Line::from(vec![Span::raw("  "), Span::styled(token.clone(), style)]);
657        let width = 24;
658        let wrapped = wrap_styled_line(line, width, 2);
659        assert!(
660            wrapped.len() >= 4,
661            "must hard-break across rows; got {}",
662            wrapped.len()
663        );
664
665        let mut reconstructed = String::new();
666        for l in &wrapped {
667            let row_cells: usize = l.spans.iter().map(|s| s.content.chars().count()).sum();
668            assert!(
669                row_cells <= width,
670                "row exceeds width: {row_cells} > {width}"
671            );
672            for s in &l.spans {
673                // Skip indent/gutter spans (whitespace only); every content
674                // piece must keep the original red foreground.
675                if s.content.trim().is_empty() {
676                    continue;
677                }
678                assert_eq!(
679                    s.style.fg,
680                    Some(ratatui::style::Color::Red),
681                    "hard-break must preserve the span style"
682                );
683                reconstructed.push_str(s.content.as_ref());
684            }
685        }
686        assert_eq!(reconstructed, token, "hard-break must preserve the token");
687    }
688
689    /// The separator space re-inserted between words must be unstyled: when a
690    /// wrapped line contains an underlined link span, the gap before the link
691    /// used to inherit the underline (visibly underlined space in the TUI).
692    #[test]
693    fn wrap_styled_line_separator_before_styled_span_is_unstyled() {
694        let underlined = Style::new().add_modifier(ratatui::style::Modifier::UNDERLINED);
695        let line = Line::from(vec![
696            Span::raw("  "),
697            Span::raw("some filler words long enough to force a wrap here "),
698            Span::styled("underlined-link-text", underlined),
699            Span::raw(" and a bit more trailing filler after the link"),
700        ]);
701        let wrapped = wrap_styled_line(line, 30, 2);
702        assert!(wrapped.len() >= 2, "fixture must actually wrap");
703        for l in &wrapped {
704            for s in &l.spans {
705                if s.content.chars().all(|c| c == ' ') {
706                    assert_eq!(
707                        s.style,
708                        Style::default(),
709                        "whitespace span {:?} must be unstyled",
710                        s.content
711                    );
712                }
713            }
714        }
715    }
716
717    /// A span boundary WITHOUT source whitespace is not a word boundary: the
718    /// dimmed "(url)" suffix a markdown link gets, followed by a bare "." text
719    /// span, must stay "(url)." — not gain a phantom space ("(url) .").
720    #[test]
721    fn wrap_styled_line_no_phantom_space_at_span_boundary() {
722        let dim = Style::new().fg(ratatui::style::Color::DarkGray);
723        let line = Line::from(vec![
724            Span::raw("  "),
725            Span::raw("filler text that pushes the line well past the width limit "),
726            Span::styled("(https://example.com)".to_string(), dim),
727            Span::raw("."),
728        ]);
729        let wrapped = wrap_styled_line(line, 30, 2);
730        assert!(wrapped.len() >= 2, "fixture must actually wrap");
731        let text: String = wrapped
732            .iter()
733            .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
734            .collect();
735        assert!(
736            text.contains("(https://example.com)."),
737            "period must stay glued to the URL suffix; got {text:?}"
738        );
739        assert!(
740            !text.contains("(https://example.com) ."),
741            "no phantom space before the period; got {text:?}"
742        );
743    }
744
745    /// A style change mid-word ("**bold**suffix") is not a word boundary: the
746    /// two fragments must land on the same row as one token, each keeping its
747    /// own style.
748    #[test]
749    fn wrap_styled_line_keeps_mid_word_style_change_glued() {
750        let bold = Style::new().add_modifier(ratatui::style::Modifier::BOLD);
751        let line = Line::from(vec![
752            Span::raw("  "),
753            Span::raw("leading filler words to force wrapping "),
754            Span::styled("bold", bold),
755            Span::raw("suffix"),
756            Span::raw(" trailing filler words to force more wrapping"),
757        ]);
758        let wrapped = wrap_styled_line(line, 30, 2);
759        assert!(wrapped.len() >= 2, "fixture must actually wrap");
760        let rows: Vec<String> = wrapped
761            .iter()
762            .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
763            .collect();
764        assert_eq!(
765            rows.iter().filter(|r| r.contains("boldsuffix")).count(),
766            1,
767            "glued token must land whole on exactly one row; rows: {rows:?}"
768        );
769        for l in &wrapped {
770            for s in &l.spans {
771                if s.content.as_ref() == "bold" {
772                    assert_eq!(s.style, bold, "bold fragment keeps its modifier");
773                }
774                if s.content.as_ref() == "suffix" {
775                    assert_eq!(s.style, Style::default(), "suffix fragment stays plain");
776                }
777            }
778        }
779    }
780
781    /// An over-long glued token made of differently styled fragments must
782    /// hard-break across rows with each fragment's style preserved and no
783    /// content lost — it enters the break path as ONE token, not two words.
784    #[test]
785    fn wrap_styled_line_hard_breaks_multi_fragment_token_preserving_styles() {
786        let red = Style::new().fg(ratatui::style::Color::Red);
787        let blue = Style::new().fg(ratatui::style::Color::Blue);
788        let line = Line::from(vec![
789            Span::raw("  "),
790            Span::styled("a".repeat(40), red),
791            Span::styled("b".repeat(40), blue),
792        ]);
793        let width = 24;
794        let wrapped = wrap_styled_line(line, width, 2);
795        assert!(
796            wrapped.len() >= 4,
797            "80-cell token at width 24 must span >= 4 rows; got {}",
798            wrapped.len()
799        );
800        let mut reconstructed = String::new();
801        for l in &wrapped {
802            let row_cells: usize = l.spans.iter().map(|s| s.content.width()).sum();
803            assert!(
804                row_cells <= width,
805                "row exceeds width: {row_cells} > {width}"
806            );
807            for s in &l.spans {
808                if s.content.trim().is_empty() {
809                    continue;
810                }
811                let expected = if s.content.contains('a') { red } else { blue };
812                assert!(
813                    !(s.content.contains('a') && s.content.contains('b')),
814                    "fragments must not merge across the style boundary"
815                );
816                assert_eq!(s.style, expected, "fragment style preserved across break");
817                reconstructed.push_str(s.content.as_ref());
818            }
819        }
820        assert_eq!(
821            reconstructed,
822            format!("{}{}", "a".repeat(40), "b".repeat(40)),
823            "hard-break must preserve the whole glued token"
824        );
825    }
826
827    /// A whitespace-only span between two text spans still separates words —
828    /// gluing only happens where the source truly has no whitespace.
829    #[test]
830    fn wrap_styled_line_whitespace_only_span_is_word_boundary() {
831        let line = Line::from(vec![
832            Span::raw("  "),
833            Span::raw("filler words that push this line past the wrap width "),
834            Span::raw("foo"),
835            Span::raw(" "),
836            Span::raw("bar"),
837        ]);
838        let wrapped = wrap_styled_line(line, 30, 2);
839        assert!(wrapped.len() >= 2, "fixture must actually wrap");
840        let text: String = wrapped
841            .iter()
842            .map(|l| {
843                l.spans
844                    .iter()
845                    .map(|s| s.content.as_ref())
846                    .collect::<String>()
847            })
848            .collect::<Vec<_>>()
849            .join("\n");
850        assert!(
851            text.contains("foo bar") || text.contains("foo\n  bar"),
852            "whitespace-only span must keep the words apart; got {text:?}"
853        );
854        assert!(
855            !text.contains("foobar"),
856            "words must not glue; got {text:?}"
857        );
858    }
859}