Skip to main content

supercode_frontend_tui/foundation/
wrapping.rs

1// Derived from OpenAI Codex: codex-rs/tui/src/wrapping.rs
2// Pinned source: 8604689ec5e3437eb79802d8d72249b7722fbf5b
3// Copyright 2025 OpenAI
4// Licensed under the Apache License, Version 2.0.
5// Modified by the Supercode contributors; see docs/legal/codex-frontend-extraction.toml.
6
7//! Word-wrapping with URL-aware heuristics.
8//!
9//! The TUI renders text that frequently contains URLs — command output,
10//! markdown, agent messages, tool-call results. Standard `textwrap`
11//! hyphenation treats `/` and `-` as split points, which breaks URLs
12//! across lines and makes them unclickable in terminal emulators.
13//!
14//! This module provides two wrapping paths:
15//!
16//! - **Standard** (`word_wrap_line`, `word_wrap_lines`): delegates to
17//!   `textwrap` with the caller's options unchanged. Used when the
18//!   content is known to be plain prose.
19//! - **Adaptive** (`adaptive_wrap_line`, `adaptive_wrap_lines`):
20//!   inspects the line for URL-like tokens; if any are found, the
21//!   wrapping keeps URL tokens intact. Mixed URL/prose lines still wrap
22//!   ordinary prose at word boundaries, only splitting a non-URL token
23//!   when that token is itself wider than the available row width.
24//!
25//! Callers that *might* encounter URLs should use the `adaptive_*`
26//! functions. Callers that definitely will not (code blocks, pure
27//! numeric output) can use the standard path for speed.
28//!
29//! URL detection is heuristic — see [`text_contains_url_like`] for the
30//! rules. False positives suppress hyphenation for that line; false
31//! negatives let a URL get split. The heuristic is intentionally
32//! conservative: file paths like `src/main.rs` are not matched.
33
34use ratatui::text::Line;
35use ratatui::text::Span;
36use std::borrow::Cow;
37use std::ops::Range;
38use textwrap::core::display_width;
39use textwrap::core::Word;
40use textwrap::Options;
41use textwrap::WordSeparator;
42
43fn push_owned_lines(src: &[Line<'_>], out: &mut Vec<Line<'static>>) {
44    out.extend(src.iter().map(|line| {
45        Line {
46            style: line.style,
47            alignment: line.alignment,
48            spans: line
49                .spans
50                .iter()
51                .map(|span| Span::styled(span.content.to_string(), span.style))
52                .collect(),
53        }
54    }));
55}
56
57/// Returns byte-ranges into `text` for each wrapped line, including
58/// trailing whitespace and a +1 sentinel byte. Used by the textarea
59/// cursor-position logic.
60pub fn wrap_ranges<'a, O>(text: &str, width_or_options: O) -> Vec<Range<usize>>
61where
62    O: Into<Options<'a>>,
63{
64    let opts = width_or_options.into();
65    let mut lines: Vec<Range<usize>> = Vec::new();
66    let mut cursor = 0usize;
67    for (line_index, line) in textwrap::wrap(text, &opts).iter().enumerate() {
68        match line {
69            std::borrow::Cow::Borrowed(slice) => {
70                let range = borrowed_slice_range(text, slice).unwrap_or_else(|| {
71                    let synthetic_prefix = if line_index == 0 {
72                        opts.initial_indent
73                    } else {
74                        opts.subsequent_indent
75                    };
76                    map_owned_wrapped_line_to_range(text, cursor, slice, synthetic_prefix)
77                });
78                let start = range.start;
79                let end = range.end;
80                let trailing_spaces = text[end..].chars().take_while(|c| *c == ' ').count();
81                lines.push(start..end + trailing_spaces + 1);
82                cursor = end + trailing_spaces;
83            }
84            std::borrow::Cow::Owned(slice) => {
85                let synthetic_prefix = if line_index == 0 {
86                    opts.initial_indent
87                } else {
88                    opts.subsequent_indent
89                };
90                let mapped = map_owned_wrapped_line_to_range(text, cursor, slice, synthetic_prefix);
91                let trailing_spaces = text[mapped.end..].chars().take_while(|c| *c == ' ').count();
92                lines.push(mapped.start..mapped.end + trailing_spaces + 1);
93                cursor = mapped.end + trailing_spaces;
94            }
95        }
96    }
97    lines
98}
99
100/// Like `wrap_ranges` but returns ranges without trailing whitespace and
101/// without the sentinel extra byte. Suitable for general wrapping where
102/// trailing spaces should not be preserved.
103pub fn wrap_ranges_trim<'a, O>(text: &str, width_or_options: O) -> Vec<Range<usize>>
104where
105    O: Into<Options<'a>>,
106{
107    let opts = width_or_options.into();
108    let mut lines: Vec<Range<usize>> = Vec::new();
109    let mut cursor = 0usize;
110    for (line_index, line) in textwrap::wrap(text, &opts).iter().enumerate() {
111        match line {
112            std::borrow::Cow::Borrowed(slice) => {
113                let range = borrowed_slice_range(text, slice).unwrap_or_else(|| {
114                    let synthetic_prefix = if line_index == 0 {
115                        opts.initial_indent
116                    } else {
117                        opts.subsequent_indent
118                    };
119                    map_owned_wrapped_line_to_range(text, cursor, slice, synthetic_prefix)
120                });
121                cursor = range.end;
122                lines.push(range);
123            }
124            std::borrow::Cow::Owned(slice) => {
125                let synthetic_prefix = if line_index == 0 {
126                    opts.initial_indent
127                } else {
128                    opts.subsequent_indent
129                };
130                let mapped = map_owned_wrapped_line_to_range(text, cursor, slice, synthetic_prefix);
131                lines.push(mapped.clone());
132                cursor = mapped.end;
133            }
134        }
135    }
136    lines
137}
138
139fn borrowed_slice_range(text: &str, slice: &str) -> Option<Range<usize>> {
140    let text_start = text.as_ptr() as usize;
141    let text_end = text_start.checked_add(text.len())?;
142    let slice_start = slice.as_ptr() as usize;
143    let slice_end = slice_start.checked_add(slice.len())?;
144
145    if slice_start < text_start || slice_end > text_end {
146        return None;
147    }
148
149    Some((slice_start - text_start)..(slice_end - text_start))
150}
151
152/// Maps an owned (materialized) wrapped line back to a byte range in `text`.
153///
154/// `textwrap` returns `Cow::Owned` when it inserts a hyphenation penalty
155/// character (typically `-`) that does not exist in the source. This
156/// function walks the owned string character-by-character against the
157/// source, skipping trailing penalty chars, and returns the
158/// corresponding source byte range starting from `cursor`.
159fn map_owned_wrapped_line_to_range(
160    text: &str,
161    cursor: usize,
162    wrapped: &str,
163    synthetic_prefix: &str,
164) -> Range<usize> {
165    let wrapped = if synthetic_prefix.is_empty() {
166        wrapped
167    } else {
168        wrapped.strip_prefix(synthetic_prefix).unwrap_or(wrapped)
169    };
170
171    let mut start = cursor;
172    while start < text.len() && !wrapped.starts_with(' ') {
173        let Some(ch) = text[start..].chars().next() else {
174            break;
175        };
176        if ch != ' ' {
177            break;
178        }
179        start += ch.len_utf8();
180    }
181
182    let mut end = start;
183    let mut saw_source_char = false;
184    let mut chars = wrapped.chars().peekable();
185    while let Some(ch) = chars.next() {
186        if end < text.len() {
187            let Some(src) = text[end..].chars().next() else {
188                unreachable!("checked end < text.len()");
189            };
190            if ch == src {
191                end += src.len_utf8();
192                saw_source_char = true;
193                continue;
194            }
195        }
196
197        // textwrap can materialize owned lines when penalties are inserted.
198        // The default penalty is a trailing '-'; it does not correspond to
199        // source bytes, so we skip it while keeping byte ranges in source text.
200        if ch == '-' && chars.peek().is_none() {
201            continue;
202        }
203
204        // Non-source chars can be synthesized by textwrap in owned output
205        // (e.g. non-space indent prefixes). Keep going and map the source bytes
206        // we can confidently match instead of crashing the app.
207        if !saw_source_char {
208            continue;
209        }
210
211        tracing::warn!(
212            wrapped = %wrapped,
213            cursor,
214            end,
215            "wrap_ranges: could not fully map owned line; returning partial source range"
216        );
217        break;
218    }
219
220    start..end
221}
222
223/// Returns `true` if any whitespace-delimited token in `line` looks like a URL.
224///
225/// Concatenates all span contents and delegates to [`text_contains_url_like`].
226pub fn line_contains_url_like(line: &Line<'_>) -> bool {
227    let text: String = line
228        .spans
229        .iter()
230        .map(|span| span.content.as_ref())
231        .collect();
232    text_contains_url_like(&text)
233}
234
235/// Returns `true` if `line` contains both a URL-like token and at least one
236/// substantive non-URL token.
237///
238/// Decorative marker tokens (for example list prefixes like `-`, `1.`, `|`,
239/// `│`) are ignored for the non-URL side of this check.
240pub fn line_has_mixed_url_and_non_url_tokens(line: &Line<'_>) -> bool {
241    let text: String = line
242        .spans
243        .iter()
244        .map(|span| span.content.as_ref())
245        .collect();
246    text_has_mixed_url_and_non_url_tokens(&text)
247}
248
249/// Returns `true` if any whitespace-delimited token in `text` looks like a URL.
250///
251/// Recognized patterns:
252/// - Absolute URLs with a scheme (`https://…`, `ftp://…`, custom `myapp://…`).
253/// - Bare domain URLs (`example.com/path`, `www.example.com`, `localhost:3000/api`).
254/// - IPv4 hosts with a path (`192.168.1.1:8080/health`).
255///
256/// Surrounding punctuation (`()[]{}< >,.;:!'"`) is stripped before
257/// checking. Tokens that look like file paths (`src/main.rs`, `foo/bar`)
258/// are intentionally rejected — the host portion must be a valid domain
259/// name (with a recognized TLD), an IPv4 address, or `localhost`.
260pub fn text_contains_url_like(text: &str) -> bool {
261    text.split_ascii_whitespace().any(is_url_like_token)
262}
263
264/// Returns `true` if `text` contains at least one URL-like token and at least
265/// one substantive non-URL token.
266fn text_has_mixed_url_and_non_url_tokens(text: &str) -> bool {
267    let mut saw_url = false;
268    let mut saw_non_url = false;
269
270    for raw_token in text.split_ascii_whitespace() {
271        if is_url_like_token(raw_token) {
272            saw_url = true;
273        } else if is_substantive_non_url_token(raw_token) {
274            saw_non_url = true;
275        }
276
277        if saw_url && saw_non_url {
278            return true;
279        }
280    }
281
282    false
283}
284
285/// Decides whether a single whitespace-delimited token is URL-like.
286///
287/// Strips surrounding punctuation, then checks for an absolute URL
288/// (with `://`) or a bare domain URL (recognized host + path/query/fragment).
289fn is_url_like_token(raw_token: &str) -> bool {
290    let token = trim_url_token(raw_token);
291    !token.is_empty() && (is_absolute_url_like(token) || is_bare_url_like(token))
292}
293
294fn is_substantive_non_url_token(raw_token: &str) -> bool {
295    let token = trim_url_token(raw_token);
296    if token.is_empty() || is_decorative_marker_token(raw_token, token) {
297        return false;
298    }
299
300    token.chars().any(char::is_alphanumeric)
301}
302
303fn is_decorative_marker_token(raw_token: &str, token: &str) -> bool {
304    let raw = raw_token.trim();
305    matches!(
306        raw,
307        "-" | "*"
308            | "+"
309            | "•"
310            | "◦"
311            | "▪"
312            | ">"
313            | "|"
314            | "│"
315            | "┆"
316            | "└"
317            | "├"
318            | "┌"
319            | "┐"
320            | "┘"
321            | "┼"
322    ) || is_ordered_list_marker(raw, token)
323}
324
325fn is_ordered_list_marker(raw_token: &str, token: &str) -> bool {
326    token.chars().all(|c| c.is_ascii_digit())
327        && (raw_token.ends_with('.') || raw_token.ends_with(')'))
328}
329
330fn trim_url_token(token: &str) -> &str {
331    token.trim_matches(|c: char| {
332        matches!(
333            c,
334            '(' | ')'
335                | '['
336                | ']'
337                | '{'
338                | '}'
339                | '<'
340                | '>'
341                | ','
342                | '.'
343                | ';'
344                | ':'
345                | '!'
346                | '\''
347                | '"'
348        )
349    })
350}
351
352/// Checks for `scheme://host` patterns. Uses `url::Url::parse` for
353/// well-known schemes; falls back to `has_valid_scheme_prefix` for
354/// custom schemes that the `url` crate rejects.
355fn is_absolute_url_like(token: &str) -> bool {
356    if !token.contains("://") {
357        return false;
358    }
359
360    if let Ok(url) = url::Url::parse(token) {
361        let scheme = url.scheme().to_ascii_lowercase();
362        if matches!(
363            scheme.as_str(),
364            "http" | "https" | "ftp" | "ftps" | "ws" | "wss"
365        ) {
366            return url.host_str().is_some();
367        }
368        return true;
369    }
370
371    has_valid_scheme_prefix(token)
372}
373
374fn has_valid_scheme_prefix(token: &str) -> bool {
375    let Some((scheme, rest)) = token.split_once("://") else {
376        return false;
377    };
378    if scheme.is_empty() || rest.is_empty() {
379        return false;
380    }
381
382    let mut chars = scheme.chars();
383    let Some(first) = chars.next() else {
384        return false;
385    };
386    first.is_ascii_alphabetic()
387        && chars.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.')
388}
389
390/// Checks for bare-domain URLs without a scheme: `host[:port]/path`,
391/// `host[:port]?query`, or `host[:port]#fragment`.
392///
393/// Requires that the host is `localhost`, an IPv4 address, or a valid
394/// domain name. Bare `host.tld` without a path/query/fragment is only
395/// accepted when the host starts with `www.`.
396///
397/// IPv6 bracket notation (`[::1]:8080`) is intentionally not handled.
398fn is_bare_url_like(token: &str) -> bool {
399    let (host_port, has_trailer) = split_host_port_and_trailer(token);
400    if host_port.is_empty() {
401        return false;
402    }
403
404    // Require URL-ish trailer for bare hosts unless token starts with www.
405    if !has_trailer && !host_port.to_ascii_lowercase().starts_with("www.") {
406        return false;
407    }
408
409    let (host, port) = split_host_and_port(host_port);
410    if host.is_empty() {
411        return false;
412    }
413    if let Some(port) = port {
414        if !is_valid_port(port) {
415            return false;
416        }
417    }
418
419    host.eq_ignore_ascii_case("localhost") || is_ipv4(host) || is_domain_name(host)
420}
421
422fn split_host_port_and_trailer(token: &str) -> (&str, bool) {
423    if let Some(idx) = token.find(['/', '?', '#']) {
424        (&token[..idx], true)
425    } else {
426        (token, false)
427    }
428}
429
430fn split_host_and_port(host_port: &str) -> (&str, Option<&str>) {
431    // We intentionally do not treat bracketed IPv6 as URL-like in this first pass.
432    if host_port.starts_with('[') {
433        return (host_port, None);
434    }
435
436    if let Some((host, port)) = host_port.rsplit_once(':') {
437        if !host.is_empty() && !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()) {
438            return (host, Some(port));
439        }
440    }
441
442    (host_port, None)
443}
444
445fn is_valid_port(port: &str) -> bool {
446    if port.is_empty() || port.len() > 5 || !port.chars().all(|c| c.is_ascii_digit()) {
447        return false;
448    }
449
450    port.parse::<u16>().is_ok()
451}
452
453fn is_ipv4(host: &str) -> bool {
454    let parts: Vec<&str> = host.split('.').collect();
455    if parts.len() != 4 {
456        return false;
457    }
458
459    parts
460        .iter()
461        .all(|part| !part.is_empty() && part.parse::<u8>().is_ok())
462}
463
464fn is_domain_name(host: &str) -> bool {
465    let host = host.to_ascii_lowercase();
466    if !host.contains('.') {
467        return false;
468    }
469
470    let mut labels = host.split('.');
471    let Some(tld) = labels.next_back() else {
472        return false;
473    };
474    if !is_tld(tld) {
475        return false;
476    }
477
478    labels.all(is_domain_label)
479}
480
481fn is_tld(label: &str) -> bool {
482    (2..=63).contains(&label.len()) && label.chars().all(|c| c.is_ascii_alphabetic())
483}
484
485fn is_domain_label(label: &str) -> bool {
486    if label.is_empty() || label.len() > 63 {
487        return false;
488    }
489
490    let mut chars = label.chars();
491    let Some(first) = chars.next() else {
492        return false;
493    };
494    let Some(last) = label.chars().next_back() else {
495        return false;
496    };
497
498    first.is_ascii_alphanumeric()
499        && last.is_ascii_alphanumeric()
500        && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
501}
502
503/// Reconfigures wrapping options so that URL-like tokens are never split.
504///
505/// Sets `AsciiSpace` word separation (so `/` and `-` inside URLs are
506/// not treated as break points), disables `break_words`, and prevents
507/// per-word hyphenation. Mixed URL/prose lines use a dedicated wrapper
508/// so normal prose can still wrap cleanly around the preserved URL token.
509pub fn url_preserving_wrap_options<'a>(opts: RtOptions<'a>) -> RtOptions<'a> {
510    opts.word_separator(textwrap::WordSeparator::AsciiSpace)
511        .word_splitter(textwrap::WordSplitter::NoHyphenation)
512        .break_words(/*break_words*/ false)
513}
514
515/// Wraps a single ratatui `Line`, automatically switching to
516/// URL-preserving options when the line contains a URL-like token.
517///
518/// When no URL is detected, wrapping behavior is identical to
519/// [`word_wrap_line`]. URL-only lines use [`url_preserving_wrap_options`]
520/// so terminal link detection keeps seeing one intact token. Mixed URL/prose
521/// lines use a token-aware wrapper so ordinary prose still moves as whole words
522/// while a genuinely overlong non-URL token can still split if needed.
523#[must_use]
524pub fn adaptive_wrap_line<'a>(line: &'a Line<'a>, base: RtOptions<'a>) -> Vec<Line<'a>> {
525    if !line_contains_url_like(line) {
526        return word_wrap_line(line, base);
527    }
528
529    if line_has_mixed_url_and_non_url_tokens(line) {
530        mixed_url_wrap_line(line, base)
531    } else {
532        word_wrap_line(line, url_preserving_wrap_options(base))
533    }
534}
535
536/// Wraps multiple input lines with URL-aware heuristics, applying
537/// `initial_indent` to the first line and `subsequent_indent` to the
538/// rest. Each line is independently checked for URLs; URL detection on
539/// one line does not affect wrapping of the others.
540///
541/// This is the multi-line counterpart to [`adaptive_wrap_line`] and is
542/// the primary wrapping entry point for most history-cell rendering.
543#[allow(private_bounds)]
544pub fn adaptive_wrap_lines<'a, I, L>(
545    lines: I,
546    width_or_options: RtOptions<'a>,
547) -> Vec<Line<'static>>
548where
549    I: IntoIterator<Item = L>,
550    L: IntoLineInput<'a>,
551{
552    let base_opts = width_or_options;
553    let mut out: Vec<Line<'static>> = Vec::new();
554
555    for (idx, line) in lines.into_iter().enumerate() {
556        let line_input = line.into_line_input();
557        let opts = if idx == 0 {
558            base_opts.clone()
559        } else {
560            base_opts
561                .clone()
562                .initial_indent(base_opts.subsequent_indent.clone())
563        };
564
565        let wrapped = adaptive_wrap_line(line_input.as_ref(), opts);
566        push_owned_lines(&wrapped, &mut out);
567    }
568
569    out
570}
571
572#[derive(Debug, Clone)]
573pub struct RtOptions<'a> {
574    /// The width in columns at which the text will be wrapped.
575    pub width: usize,
576    /// Line ending used for breaking lines.
577    pub line_ending: textwrap::LineEnding,
578    /// Indentation used for the first line of output. See the
579    /// [`Options::initial_indent`] method.
580    pub initial_indent: Line<'a>,
581    /// Indentation used for subsequent lines of output. See the
582    /// [`Options::subsequent_indent`] method.
583    pub subsequent_indent: Line<'a>,
584    /// Allow long words to be broken if they cannot fit on a line.
585    /// When set to `false`, some lines may be longer than
586    /// `self.width`. See the [`Options::break_words`] method.
587    pub break_words: bool,
588    /// Wrapping algorithm to use, see the implementations of the
589    /// [`WrapAlgorithm`] trait for details.
590    pub wrap_algorithm: textwrap::WrapAlgorithm,
591    /// The line breaking algorithm to use, see the [`WordSeparator`]
592    /// trait for an overview and possible implementations.
593    pub word_separator: textwrap::WordSeparator,
594    /// The method for splitting words. This can be used to prohibit
595    /// splitting words on hyphens, or it can be used to implement
596    /// language-aware machine hyphenation.
597    pub word_splitter: textwrap::WordSplitter,
598}
599impl From<usize> for RtOptions<'_> {
600    fn from(width: usize) -> Self {
601        RtOptions::new(width)
602    }
603}
604
605#[allow(dead_code)]
606impl<'a> RtOptions<'a> {
607    pub fn new(width: usize) -> Self {
608        RtOptions {
609            width,
610            line_ending: textwrap::LineEnding::LF,
611            initial_indent: Line::default(),
612            subsequent_indent: Line::default(),
613            break_words: true,
614            word_separator: textwrap::WordSeparator::new(),
615            wrap_algorithm: textwrap::WrapAlgorithm::FirstFit,
616            word_splitter: textwrap::WordSplitter::HyphenSplitter,
617        }
618    }
619
620    pub fn line_ending(self, line_ending: textwrap::LineEnding) -> Self {
621        RtOptions {
622            line_ending,
623            ..self
624        }
625    }
626
627    pub fn width(self, width: usize) -> Self {
628        RtOptions { width, ..self }
629    }
630
631    pub fn initial_indent(self, initial_indent: Line<'a>) -> Self {
632        RtOptions {
633            initial_indent,
634            ..self
635        }
636    }
637
638    pub fn subsequent_indent(self, subsequent_indent: Line<'a>) -> Self {
639        RtOptions {
640            subsequent_indent,
641            ..self
642        }
643    }
644
645    pub fn break_words(self, break_words: bool) -> Self {
646        RtOptions {
647            break_words,
648            ..self
649        }
650    }
651
652    pub fn word_separator(self, word_separator: textwrap::WordSeparator) -> RtOptions<'a> {
653        RtOptions {
654            word_separator,
655            ..self
656        }
657    }
658
659    pub fn wrap_algorithm(self, wrap_algorithm: textwrap::WrapAlgorithm) -> RtOptions<'a> {
660        RtOptions {
661            wrap_algorithm,
662            ..self
663        }
664    }
665
666    pub fn word_splitter(self, word_splitter: textwrap::WordSplitter) -> RtOptions<'a> {
667        RtOptions {
668            word_splitter,
669            ..self
670        }
671    }
672}
673
674#[must_use]
675pub fn word_wrap_line<'a, O>(line: &'a Line<'a>, width_or_options: O) -> Vec<Line<'a>>
676where
677    O: Into<RtOptions<'a>>,
678{
679    let (flat, span_bounds) = flatten_line(line);
680
681    let rt_opts: RtOptions<'a> = width_or_options.into();
682    let opts = Options::new(rt_opts.width)
683        .line_ending(rt_opts.line_ending)
684        .break_words(rt_opts.break_words)
685        .wrap_algorithm(rt_opts.wrap_algorithm)
686        .word_separator(rt_opts.word_separator)
687        .word_splitter(rt_opts.word_splitter);
688
689    let mut out: Vec<Line<'a>> = Vec::new();
690
691    // Compute first line range with reduced width due to initial indent.
692    let initial_width_available = opts
693        .width
694        .saturating_sub(rt_opts.initial_indent.width())
695        .max(1);
696    let initial_wrapped = wrap_ranges_trim(&flat, opts.clone().width(initial_width_available));
697    let Some(first_line_range) = initial_wrapped.first() else {
698        return vec![rt_opts.initial_indent.clone()];
699    };
700
701    // Build first wrapped line with initial indent.
702    let mut first_line = rt_opts.initial_indent.clone().style(line.style);
703    {
704        let sliced = slice_line_spans(line, &span_bounds, first_line_range);
705        let mut spans = first_line.spans;
706        spans.append(
707            &mut sliced
708                .spans
709                .into_iter()
710                .map(|s| s.patch_style(line.style))
711                .collect(),
712        );
713        first_line.spans = spans;
714        out.push(first_line);
715    }
716
717    // Wrap the remainder using subsequent indent width and map back to original indices.
718    let base = first_line_range.end;
719    let skip_leading_spaces = flat[base..].chars().take_while(|c| *c == ' ').count();
720    let base = base + skip_leading_spaces;
721    let subsequent_width_available = opts
722        .width
723        .saturating_sub(rt_opts.subsequent_indent.width())
724        .max(1);
725    let remaining_wrapped = wrap_ranges_trim(&flat[base..], opts.width(subsequent_width_available));
726    for r in &remaining_wrapped {
727        if r.is_empty() {
728            continue;
729        }
730        let mut subsequent_line = rt_opts.subsequent_indent.clone().style(line.style);
731        let offset_range = (r.start + base)..(r.end + base);
732        let sliced = slice_line_spans(line, &span_bounds, &offset_range);
733        let mut spans = subsequent_line.spans;
734        spans.append(
735            &mut sliced
736                .spans
737                .into_iter()
738                .map(|s| s.patch_style(line.style))
739                .collect(),
740        );
741        subsequent_line.spans = spans;
742        out.push(subsequent_line);
743    }
744
745    out
746}
747
748#[derive(Clone, Debug)]
749struct MixedUrlWord {
750    range: Range<usize>,
751    is_url: bool,
752}
753
754impl MixedUrlWord {
755    fn width(&self, text: &str) -> usize {
756        display_width(&text[self.range.clone()])
757    }
758}
759
760fn mixed_url_wrap_line<'a>(line: &'a Line<'a>, rt_opts: RtOptions<'a>) -> Vec<Line<'a>> {
761    let (flat, span_bounds) = flatten_line(line);
762    let initial_width_available = rt_opts
763        .width
764        .saturating_sub(rt_opts.initial_indent.width())
765        .max(1);
766    let subsequent_width_available = rt_opts
767        .width
768        .saturating_sub(rt_opts.subsequent_indent.width())
769        .max(1);
770    let ranges = mixed_url_wrap_ranges(&flat, initial_width_available, subsequent_width_available);
771
772    let mut out = Vec::new();
773    for (idx, range) in ranges.iter().enumerate() {
774        let mut wrapped_line = if idx == 0 {
775            rt_opts.initial_indent.clone()
776        } else {
777            rt_opts.subsequent_indent.clone()
778        }
779        .style(line.style);
780        let sliced = slice_line_spans(line, &span_bounds, range);
781        let mut spans = wrapped_line.spans;
782        spans.extend(
783            sliced
784                .spans
785                .into_iter()
786                .map(|span| span.patch_style(line.style)),
787        );
788        wrapped_line.spans = spans;
789        out.push(wrapped_line);
790    }
791
792    if out.is_empty() {
793        vec![rt_opts.initial_indent.clone()]
794    } else {
795        out
796    }
797}
798
799fn mixed_url_wrap_ranges(
800    text: &str,
801    initial_width: usize,
802    subsequent_width: usize,
803) -> Vec<Range<usize>> {
804    let leading_space_width = text.chars().take_while(|ch| *ch == ' ').count();
805    let mut words = Vec::new();
806    let mut cursor = 0usize;
807    for word in WordSeparator::AsciiSpace.find_words(text) {
808        let word_start = cursor;
809        let word_end = word_start + word.word.len();
810        let trailing_space_end = word_end + word.whitespace.len();
811        if !word.word.is_empty() {
812            words.push(MixedUrlWord {
813                range: word_start..word_end,
814                is_url: is_url_like_token(word.word),
815            });
816        }
817        cursor = trailing_space_end;
818    }
819
820    let mut lines = Vec::new();
821    let mut line_start = None;
822    let mut line_end = 0usize;
823    let mut line_width = 0usize;
824    let mut line_limit = initial_width.max(1);
825
826    for word in words {
827        let mut pending = split_mixed_url_word(text, word, line_limit);
828        let mut pending_idx = 0usize;
829
830        while let Some(piece) = pending.get(pending_idx).cloned() {
831            let empty_line_prefix_width = if line_start.is_none() && lines.is_empty() {
832                leading_space_width
833            } else {
834                0
835            };
836            let empty_line_piece_limit = line_limit.saturating_sub(empty_line_prefix_width).max(1);
837            if line_start.is_none() && !piece.is_url && piece.width(text) > empty_line_piece_limit {
838                pending.splice(
839                    pending_idx..=pending_idx,
840                    split_mixed_url_word(text, piece, empty_line_piece_limit),
841                );
842                continue;
843            }
844
845            let piece_width = piece.width(text);
846            let inter_word_space = line_start
847                .map(|_| text[line_end..piece.range.start].len())
848                .unwrap_or(0);
849            let fits = if line_start.is_none() {
850                piece.is_url
851                    || empty_line_prefix_width + piece_width <= line_limit
852                    || empty_line_prefix_width >= line_limit
853            } else {
854                line_width + inter_word_space + piece_width <= line_limit
855            };
856
857            if fits {
858                if line_start.is_none() {
859                    let is_first_output_line = lines.is_empty();
860                    let start = if is_first_output_line {
861                        0
862                    } else {
863                        piece.range.start
864                    };
865                    line_start = Some(start);
866                    line_width = if is_first_output_line {
867                        leading_space_width + piece_width
868                    } else {
869                        piece_width
870                    };
871                } else {
872                    line_width += inter_word_space + piece_width;
873                }
874                line_end = piece.range.end;
875                pending_idx += 1;
876                continue;
877            }
878
879            if let Some(start) = line_start.take() {
880                lines.push(start..line_end);
881            }
882            line_end = 0;
883            line_width = 0;
884            line_limit = subsequent_width.max(1);
885        }
886    }
887
888    if let Some(start) = line_start {
889        lines.push(start..line_end);
890    }
891
892    lines
893}
894
895fn split_mixed_url_word(text: &str, word: MixedUrlWord, line_limit: usize) -> Vec<MixedUrlWord> {
896    if word.is_url || word.width(text) <= line_limit {
897        return vec![word];
898    }
899
900    let source = Word::from(&text[word.range.clone()]);
901    let mut offset = word.range.start;
902    let mut pieces = Vec::new();
903    for piece in source.break_apart(line_limit.max(1)) {
904        let end = offset + piece.word.len();
905        pieces.push(MixedUrlWord {
906            range: offset..end,
907            is_url: false,
908        });
909        offset = end;
910    }
911    pieces
912}
913
914fn flatten_line(line: &Line<'_>) -> (String, Vec<(Range<usize>, ratatui::style::Style)>) {
915    let mut flat = String::new();
916    let mut span_bounds = Vec::new();
917    let mut acc = 0usize;
918    for span in &line.spans {
919        let text = span.content.as_ref();
920        let start = acc;
921        flat.push_str(text);
922        acc += text.len();
923        span_bounds.push((start..acc, span.style));
924    }
925    (flat, span_bounds)
926}
927
928/// Utilities to allow wrapping either borrowed or owned lines.
929#[derive(Debug)]
930enum LineInput<'a> {
931    Borrowed(&'a Line<'a>),
932    Owned(Line<'a>),
933}
934
935impl<'a> LineInput<'a> {
936    fn as_ref(&self) -> &Line<'a> {
937        match self {
938            LineInput::Borrowed(line) => line,
939            LineInput::Owned(line) => line,
940        }
941    }
942}
943
944/// This trait makes it easier to pass whatever we need into word_wrap_lines.
945trait IntoLineInput<'a> {
946    fn into_line_input(self) -> LineInput<'a>;
947}
948
949impl<'a> IntoLineInput<'a> for &'a Line<'a> {
950    fn into_line_input(self) -> LineInput<'a> {
951        LineInput::Borrowed(self)
952    }
953}
954
955impl<'a> IntoLineInput<'a> for &'a mut Line<'a> {
956    fn into_line_input(self) -> LineInput<'a> {
957        LineInput::Borrowed(self)
958    }
959}
960
961impl<'a> IntoLineInput<'a> for Line<'a> {
962    fn into_line_input(self) -> LineInput<'a> {
963        LineInput::Owned(self)
964    }
965}
966
967impl<'a> IntoLineInput<'a> for String {
968    fn into_line_input(self) -> LineInput<'a> {
969        LineInput::Owned(Line::from(self))
970    }
971}
972
973impl<'a> IntoLineInput<'a> for &'a str {
974    fn into_line_input(self) -> LineInput<'a> {
975        LineInput::Owned(Line::from(self))
976    }
977}
978
979impl<'a> IntoLineInput<'a> for Cow<'a, str> {
980    fn into_line_input(self) -> LineInput<'a> {
981        LineInput::Owned(Line::from(self))
982    }
983}
984
985impl<'a> IntoLineInput<'a> for Span<'a> {
986    fn into_line_input(self) -> LineInput<'a> {
987        LineInput::Owned(Line::from(self))
988    }
989}
990
991impl<'a> IntoLineInput<'a> for Vec<Span<'a>> {
992    fn into_line_input(self) -> LineInput<'a> {
993        LineInput::Owned(Line::from(self))
994    }
995}
996
997/// Wrap a sequence of lines, applying the initial indent only to the very first
998/// output line, and using the subsequent indent for all later wrapped pieces.
999#[allow(private_bounds)] // IntoLineInput isn't public, but it doesn't really need to be.
1000pub fn word_wrap_lines<'a, I, O, L>(lines: I, width_or_options: O) -> Vec<Line<'static>>
1001where
1002    I: IntoIterator<Item = L>,
1003    L: IntoLineInput<'a>,
1004    O: Into<RtOptions<'a>>,
1005{
1006    let base_opts: RtOptions<'a> = width_or_options.into();
1007    let mut out: Vec<Line<'static>> = Vec::new();
1008
1009    for (idx, line) in lines.into_iter().enumerate() {
1010        let line_input = line.into_line_input();
1011        let opts = if idx == 0 {
1012            base_opts.clone()
1013        } else {
1014            let mut o = base_opts.clone();
1015            let sub = o.subsequent_indent.clone();
1016            o = o.initial_indent(sub);
1017            o
1018        };
1019        let wrapped = word_wrap_line(line_input.as_ref(), opts);
1020        push_owned_lines(&wrapped, &mut out);
1021    }
1022
1023    out
1024}
1025
1026#[allow(dead_code)]
1027pub fn word_wrap_lines_borrowed<'a, I, O>(lines: I, width_or_options: O) -> Vec<Line<'a>>
1028where
1029    I: IntoIterator<Item = &'a Line<'a>>,
1030    O: Into<RtOptions<'a>>,
1031{
1032    let base_opts: RtOptions<'a> = width_or_options.into();
1033    let mut out: Vec<Line<'a>> = Vec::new();
1034    let mut first = true;
1035    for line in lines.into_iter() {
1036        let opts = if first {
1037            base_opts.clone()
1038        } else {
1039            base_opts
1040                .clone()
1041                .initial_indent(base_opts.subsequent_indent.clone())
1042        };
1043        out.extend(word_wrap_line(line, opts));
1044        first = false;
1045    }
1046    out
1047}
1048
1049fn slice_line_spans<'a>(
1050    original: &'a Line<'a>,
1051    span_bounds: &[(Range<usize>, ratatui::style::Style)],
1052    range: &Range<usize>,
1053) -> Line<'a> {
1054    let start_byte = range.start;
1055    let end_byte = range.end;
1056    let mut acc: Vec<Span<'a>> = Vec::new();
1057    for (i, (range, style)) in span_bounds.iter().enumerate() {
1058        let s = range.start;
1059        let e = range.end;
1060        if e <= start_byte {
1061            continue;
1062        }
1063        if s >= end_byte {
1064            break;
1065        }
1066        let seg_start = start_byte.max(s);
1067        let seg_end = end_byte.min(e);
1068        if seg_end > seg_start {
1069            let local_start = seg_start - s;
1070            let local_end = seg_end - s;
1071            let content = original.spans[i].content.as_ref();
1072            let slice = &content[local_start..local_end];
1073            acc.push(Span {
1074                style: *style,
1075                content: std::borrow::Cow::Borrowed(slice),
1076            });
1077        }
1078        if e >= end_byte {
1079            break;
1080        }
1081    }
1082    Line {
1083        style: original.style,
1084        alignment: original.alignment,
1085        spans: acc,
1086    }
1087}