Skip to main content

vtcode_tui/core_tui/session/
wrapping.rs

1//! URL and path-preserving text wrapping.
2//!
3//! Wraps text while keeping URLs and file paths as atomic units to preserve
4//! terminal link detection and transcript file hit-testing.
5
6use ratatui::prelude::*;
7use ratatui::widgets::Paragraph;
8use regex::Regex;
9use std::sync::LazyLock;
10use unicode_width::UnicodeWidthStr;
11
12/// URL/file token detection pattern - matches common URL formats and path-like tokens.
13static PRESERVED_TOKEN_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
14    Regex::new(
15        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+)?|`(?:file://|~/|/|\./|\.\./|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/])[^`]+`|"(?:file://|~/|/|\./|\.\./|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/])[^"]+"|'(?:file://|~/|/|\./|\.\./|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/])[^']+'|(?:\./|\../|~/|/)[^\s<>\[\]{}|^]+|(?:[A-Za-z]:[\\/][^\s<>\[\]{}|^]+)|(?:[A-Za-z0-9._-]+[\\/][^\s<>\[\]{}|^]+)"#,
16    )
17    .unwrap()
18});
19
20/// Check if text contains a preserved URL/path token.
21pub fn contains_preserved_token(text: &str) -> bool {
22    PRESERVED_TOKEN_PATTERN.is_match(text)
23}
24
25/// Wrap a line, preserving URLs as atomic units.
26///
27/// - Lines without URLs: delegated to standard wrapping
28/// - URL-only lines: returned unwrapped if they fit
29/// - Mixed lines: URLs kept intact, surrounding text wrapped normally
30pub fn wrap_line_preserving_urls(line: Line<'static>, max_width: usize) -> Vec<Line<'static>> {
31    if max_width == 0 {
32        return vec![Line::default()];
33    }
34
35    let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
36
37    // No URLs - use standard wrapping (delegates to text_utils)
38    if !contains_preserved_token(&text) {
39        return super::text_utils::wrap_line(line, max_width);
40    }
41
42    // Find all preserved tokens in the text
43    let urls: Vec<_> = PRESERVED_TOKEN_PATTERN
44        .find_iter(&text)
45        .map(|m| (m.start(), m.end(), m.as_str()))
46        .collect();
47
48    // Single URL that fits - return unwrapped for terminal link detection
49    if urls.len() == 1 && urls[0].0 == 0 && urls[0].1 == text.len() && text.width() <= max_width {
50        return vec![line];
51    }
52    // URL too wide - fall through to wrap it
53
54    // Mixed content - split around URLs and wrap each segment
55    wrap_mixed_content(line, &text, max_width, &urls)
56}
57
58/// Wrap text that contains URLs, keeping URLs intact.
59fn wrap_mixed_content(
60    line: Line<'static>,
61    text: &str,
62    max_width: usize,
63    urls: &[(usize, usize, &str)],
64) -> Vec<Line<'static>> {
65    use unicode_segmentation::UnicodeSegmentation;
66    use unicode_width::UnicodeWidthStr;
67
68    let mut result = Vec::with_capacity(urls.len() + 1);
69    let mut current_line: Vec<Span<'static>> = Vec::new();
70    let mut current_width = 0usize;
71    let mut text_pos = 0usize;
72
73    let flush_line = |spans: &mut Vec<Span<'static>>, result: &mut Vec<Line<'static>>| {
74        if spans.is_empty() {
75            result.push(Line::default());
76        } else {
77            result.push(Line::from(std::mem::take(spans)));
78        }
79    };
80
81    // Merge spans into a single style for simplicity when dealing with URLs
82    let default_style = line.spans.first().map(|s| s.style).unwrap_or_default();
83
84    for (url_start, url_end, url_text) in urls {
85        // Process text before this URL
86        if *url_start > text_pos {
87            let before = &text[text_pos..*url_start];
88
89            for grapheme in UnicodeSegmentation::graphemes(before, true) {
90                let gw = grapheme.width();
91                if current_width + gw > max_width && current_width > 0 {
92                    flush_line(&mut current_line, &mut result);
93                    current_width = 0;
94                }
95                current_line.push(Span::styled(grapheme.to_string(), default_style));
96                current_width += gw;
97            }
98        }
99
100        // Add URL — keep atomic if it fits, otherwise break it across lines
101        let url_width = url_text.width();
102        if url_width <= max_width {
103            if current_width > 0 && current_width + url_width > max_width {
104                flush_line(&mut current_line, &mut result);
105                current_width = 0;
106            }
107            current_line.push(Span::styled(url_text.to_string(), default_style));
108            current_width += url_width;
109        } else {
110            // URL is wider than max_width — break it grapheme-by-grapheme
111            if current_width > 0 {
112                flush_line(&mut current_line, &mut result);
113                current_width = 0;
114            }
115            for grapheme in UnicodeSegmentation::graphemes(*url_text, true) {
116                let gw = grapheme.width();
117                if current_width + gw > max_width && current_width > 0 {
118                    flush_line(&mut current_line, &mut result);
119                    current_width = 0;
120                }
121                current_line.push(Span::styled(grapheme.to_string(), default_style));
122                current_width += gw;
123            }
124        }
125
126        text_pos = *url_end;
127    }
128
129    // Process remaining text after last URL
130    if text_pos < text.len() {
131        let remaining = &text[text_pos..];
132
133        for grapheme in UnicodeSegmentation::graphemes(remaining, true) {
134            let gw = grapheme.width();
135            if current_width + gw > max_width && current_width > 0 {
136                flush_line(&mut current_line, &mut result);
137                current_width = 0;
138            }
139            current_line.push(Span::styled(grapheme.to_string(), default_style));
140            current_width += gw;
141        }
142    }
143
144    flush_line(&mut current_line, &mut result);
145    if result.is_empty() {
146        result.push(Line::default());
147    }
148    result
149}
150
151/// Wrap multiple lines with URL preservation.
152pub fn wrap_lines_preserving_urls(
153    lines: Vec<Line<'static>>,
154    max_width: usize,
155) -> Vec<Line<'static>> {
156    if max_width == 0 {
157        return vec![Line::default()];
158    }
159    lines
160        .into_iter()
161        .flat_map(|line| wrap_line_preserving_urls(line, max_width))
162        .collect()
163}
164
165/// Calculate wrapped height using Paragraph::line_count.
166pub fn calculate_wrapped_height(text: &str, width: u16) -> usize {
167    if width == 0 {
168        return text.lines().count().max(1);
169    }
170    Paragraph::new(text).line_count(width)
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn test_url_detection() {
179        assert!(contains_preserved_token("https://example.com"));
180        assert!(contains_preserved_token("example.com/path"));
181        assert!(contains_preserved_token("localhost:8080"));
182        assert!(contains_preserved_token("192.168.1.1:8080"));
183        assert!(!contains_preserved_token("not a url"));
184    }
185
186    #[test]
187    fn test_file_path_detection() {
188        assert!(contains_preserved_token("src/main.rs"));
189        assert!(contains_preserved_token("./src/main.rs"));
190        assert!(contains_preserved_token("/tmp/example.txt"));
191        assert!(contains_preserved_token("\"./docs/My Notes.md\""));
192        assert!(contains_preserved_token(
193            "`/Users/example/Library/Application Support/Code/User/settings.json`"
194        ));
195    }
196
197    #[test]
198    fn test_url_only_preserved() {
199        let line = Line::from(Span::raw("https://example.com"));
200        let wrapped = wrap_line_preserving_urls(line, 80);
201        assert_eq!(wrapped.len(), 1);
202        assert!(
203            wrapped[0]
204                .spans
205                .iter()
206                .any(|s| s.content.contains("https://"))
207        );
208    }
209
210    #[test]
211    fn test_mixed_content() {
212        let line = Line::from(Span::raw("See https://example.com for info"));
213        let wrapped = wrap_line_preserving_urls(line, 25);
214        let all_text: String = wrapped
215            .iter()
216            .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
217            .collect();
218        assert!(all_text.contains("https://example.com"));
219        assert!(all_text.contains("See"));
220    }
221
222    #[test]
223    fn test_quoted_path_with_spaces_is_preserved() {
224        let line = Line::from(Span::raw("Open \"./docs/My Notes.md\" for details"));
225        let wrapped = wrap_line_preserving_urls(line, 18);
226        let all_text: String = wrapped
227            .iter()
228            .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
229            .collect();
230
231        assert!(all_text.contains("\"./docs/My Notes.md\""));
232    }
233
234    #[test]
235    fn test_long_url_breaks_across_lines() {
236        let long_url = "https://auth.openai.com/oauth/authorize?response_type=code&client_id=app_EMoamEEZ73f0CkXaXp7hrann&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback&scope=openid";
237        let line = Line::from(Span::raw(long_url.to_string()));
238        let wrapped = wrap_line_preserving_urls(line, 80);
239        assert!(
240            wrapped.len() > 1,
241            "Long URL should wrap across multiple lines"
242        );
243        let all_text: String = wrapped
244            .iter()
245            .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
246            .collect();
247        assert_eq!(all_text, long_url, "All characters should be preserved");
248    }
249
250    #[test]
251    fn test_no_url_delegates() {
252        let line = Line::from(Span::raw("Regular text without URLs"));
253        let wrapped = wrap_line_preserving_urls(line, 10);
254        assert!(!wrapped.is_empty());
255    }
256}