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