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/// 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}