Skip to main content

vtcode_tui/core_tui/session/
wrapping.rs

1//! URL-preserving text wrapping.
2//!
3//! Wraps text while keeping URLs as atomic units to preserve terminal link detection.
4
5use ratatui::prelude::*;
6use ratatui::widgets::Paragraph;
7use regex::Regex;
8use std::sync::LazyLock;
9use unicode_width::UnicodeWidthStr;
10
11/// URL detection pattern - matches common URL formats.
12static URL_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
13    Regex::new(
14        r"[a-zA-Z][a-zA-Z0-9+.-]*://[^\s<>\[\]{}|^]+|[a-zA-Z0-9][-a-zA-Z0-9]*\.[a-zA-Z]{2,}(/[^\s<>\[\]{}|^]*)?|localhost:\d+|\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d+)?",
15    )
16    .unwrap()
17});
18
19/// Check if text contains a URL.
20pub fn contains_url(text: &str) -> bool {
21    URL_PATTERN.is_match(text)
22}
23
24/// Wrap a line, preserving URLs as atomic units.
25///
26/// - Lines without URLs: delegated to standard wrapping
27/// - URL-only lines: returned unwrapped if they fit
28/// - Mixed lines: URLs kept intact, surrounding text wrapped normally
29pub fn wrap_line_preserving_urls(line: Line<'static>, max_width: usize) -> Vec<Line<'static>> {
30    if max_width == 0 {
31        return vec![Line::default()];
32    }
33
34    let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
35
36    // No URLs - use standard wrapping (delegates to text_utils)
37    if !contains_url(&text) {
38        return super::text_utils::wrap_line(line, max_width);
39    }
40
41    // Find all URLs in the text
42    let urls: Vec<_> = URL_PATTERN
43        .find_iter(&text)
44        .map(|m| (m.start(), m.end(), m.as_str()))
45        .collect();
46
47    // Single URL that fits - return unwrapped for terminal link detection
48    if urls.len() == 1 && urls[0].0 == 0 && urls[0].1 == text.len() && text.width() <= max_width {
49        return vec![line];
50    }
51    // URL too wide - fall through to wrap it
52
53    // Mixed content - split around URLs and wrap each segment
54    wrap_mixed_content(line, max_width, &urls)
55}
56
57/// Wrap text that contains URLs, keeping URLs intact.
58fn wrap_mixed_content(
59    line: Line<'static>,
60    max_width: usize,
61    urls: &[(usize, usize, &str)],
62) -> Vec<Line<'static>> {
63    use unicode_segmentation::UnicodeSegmentation;
64    use unicode_width::UnicodeWidthStr;
65
66    let mut result = Vec::new();
67    let mut current_line: Vec<Span<'static>> = Vec::new();
68    let mut current_width = 0usize;
69    let mut text_pos = 0usize;
70
71    let flush_line = |spans: &mut Vec<Span<'static>>, result: &mut Vec<Line<'static>>| {
72        if spans.is_empty() {
73            result.push(Line::default());
74        } else {
75            result.push(Line::from(std::mem::take(spans)));
76        }
77    };
78
79    // Merge spans into a single style for simplicity when dealing with URLs
80    let default_style = line.spans.first().map(|s| s.style).unwrap_or_default();
81
82    for (url_start, url_end, url_text) in urls {
83        // Process text before this URL
84        if *url_start > text_pos {
85            let before = &line
86                .spans
87                .iter()
88                .map(|s| s.content.as_ref())
89                .collect::<String>()[text_pos..*url_start];
90
91            for grapheme in UnicodeSegmentation::graphemes(before, true) {
92                let gw = grapheme.width();
93                if current_width + gw > max_width && current_width > 0 {
94                    flush_line(&mut current_line, &mut result);
95                    current_width = 0;
96                }
97                current_line.push(Span::styled(grapheme.to_string(), default_style));
98                current_width += gw;
99            }
100        }
101
102        // Add URL as atomic unit
103        let url_width = url_text.width();
104        if current_width > 0 && current_width + url_width > max_width {
105            flush_line(&mut current_line, &mut result);
106            current_width = 0;
107        }
108        current_line.push(Span::styled(url_text.to_string(), default_style));
109        current_width += url_width;
110
111        text_pos = *url_end;
112    }
113
114    // Process remaining text after last URL
115    if text_pos < line.spans.iter().map(|s| s.content.len()).sum::<usize>() {
116        let full_text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
117        let remaining = &full_text[text_pos..];
118
119        for grapheme in UnicodeSegmentation::graphemes(remaining, true) {
120            let gw = grapheme.width();
121            if current_width + gw > max_width && current_width > 0 {
122                flush_line(&mut current_line, &mut result);
123                current_width = 0;
124            }
125            current_line.push(Span::styled(grapheme.to_string(), default_style));
126            current_width += gw;
127        }
128    }
129
130    flush_line(&mut current_line, &mut result);
131    if result.is_empty() {
132        result.push(Line::default());
133    }
134    result
135}
136
137/// Wrap multiple lines with URL preservation.
138pub fn wrap_lines_preserving_urls(
139    lines: Vec<Line<'static>>,
140    max_width: usize,
141) -> Vec<Line<'static>> {
142    if max_width == 0 {
143        return vec![Line::default()];
144    }
145    lines
146        .into_iter()
147        .flat_map(|line| wrap_line_preserving_urls(line, max_width))
148        .collect()
149}
150
151/// Calculate wrapped height using Paragraph::line_count.
152pub fn calculate_wrapped_height(text: &str, width: u16) -> usize {
153    if width == 0 {
154        return text.lines().count().max(1);
155    }
156    Paragraph::new(text).line_count(width)
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn test_url_detection() {
165        assert!(contains_url("https://example.com"));
166        assert!(contains_url("example.com/path"));
167        assert!(contains_url("localhost:8080"));
168        assert!(contains_url("192.168.1.1:8080"));
169        assert!(!contains_url("not a url"));
170    }
171
172    #[test]
173    fn test_url_only_preserved() {
174        let line = Line::from(Span::raw("https://example.com"));
175        let wrapped = wrap_line_preserving_urls(line, 80);
176        assert_eq!(wrapped.len(), 1);
177        assert!(
178            wrapped[0]
179                .spans
180                .iter()
181                .any(|s| s.content.contains("https://"))
182        );
183    }
184
185    #[test]
186    fn test_mixed_content() {
187        let line = Line::from(Span::raw("See https://example.com for info"));
188        let wrapped = wrap_line_preserving_urls(line, 25);
189        let all_text: String = wrapped
190            .iter()
191            .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
192            .collect();
193        assert!(all_text.contains("https://example.com"));
194        assert!(all_text.contains("See"));
195    }
196
197    #[test]
198    fn test_no_url_delegates() {
199        let line = Line::from(Span::raw("Regular text without URLs"));
200        let wrapped = wrap_line_preserving_urls(line, 10);
201        assert!(!wrapped.is_empty());
202    }
203}