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    .expect("invalid URL/path token regex")
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    fn trim_trailing_wrap_whitespace(spans: &mut Vec<Span<'static>>) {
74        while let Some(last) = spans.last_mut() {
75            let trimmed_len = last.content.trim_end_matches(char::is_whitespace).len();
76            if trimmed_len == last.content.len() {
77                break;
78            }
79            if trimmed_len == 0 {
80                spans.pop();
81                continue;
82            }
83            last.content.to_mut().truncate(trimmed_len);
84            break;
85        }
86    }
87
88    let flush_line = |spans: &mut Vec<Span<'static>>, result: &mut Vec<Line<'static>>| {
89        if spans.is_empty() {
90            result.push(Line::default());
91        } else {
92            trim_trailing_wrap_whitespace(spans);
93            result.push(Line::from(std::mem::take(spans)));
94        }
95    };
96
97    // Merge spans into a single style for simplicity when dealing with URLs
98    let default_style = line.spans.first().map(|s| s.style).unwrap_or_default();
99
100    let push_wrapped_token = |token: &str,
101                              current_line: &mut Vec<Span<'static>>,
102                              current_width: &mut usize,
103                              result: &mut Vec<Line<'static>>| {
104        for grapheme in UnicodeSegmentation::graphemes(token, true) {
105            let grapheme_width = grapheme.width();
106            if grapheme_width == 0 {
107                current_line.push(Span::styled(grapheme.to_string(), default_style));
108                continue;
109            }
110            if *current_width + grapheme_width > max_width && *current_width > 0 {
111                flush_line(current_line, result);
112                *current_width = 0;
113            }
114            current_line.push(Span::styled(grapheme.to_string(), default_style));
115            *current_width += grapheme_width;
116        }
117    };
118
119    let push_wrapped_text = |segment: &str,
120                             current_line: &mut Vec<Span<'static>>,
121                             current_width: &mut usize,
122                             result: &mut Vec<Line<'static>>| {
123        for piece in segment.split_inclusive('\n') {
124            let mut text = piece;
125            let mut had_newline = false;
126            if let Some(stripped) = text.strip_suffix('\n') {
127                text = stripped;
128                had_newline = true;
129                if let Some(without_carriage) = text.strip_suffix('\r') {
130                    text = without_carriage;
131                }
132            }
133
134            for token in UnicodeSegmentation::split_word_bounds(text) {
135                if token.is_empty() {
136                    continue;
137                }
138
139                let token_width = token.width();
140                if token_width == 0 {
141                    current_line.push(Span::styled(token.to_string(), default_style));
142                    continue;
143                }
144
145                let token_is_whitespace = token.chars().all(char::is_whitespace);
146                let has_content = *current_width > 0;
147
148                if token_is_whitespace && !result.is_empty() && !has_content {
149                    continue;
150                }
151
152                if *current_width + token_width <= max_width {
153                    current_line.push(Span::styled(token.to_string(), default_style));
154                    *current_width += token_width;
155                    continue;
156                }
157
158                if token_is_whitespace {
159                    if has_content {
160                        flush_line(current_line, result);
161                        *current_width = 0;
162                    }
163                    continue;
164                }
165
166                if token_width <= max_width {
167                    if has_content {
168                        flush_line(current_line, result);
169                        *current_width = 0;
170                    }
171                    current_line.push(Span::styled(token.to_string(), default_style));
172                    *current_width += token_width;
173                    continue;
174                }
175
176                push_wrapped_token(token, current_line, current_width, result);
177            }
178
179            if had_newline {
180                flush_line(current_line, result);
181                *current_width = 0;
182            }
183        }
184    };
185
186    for (url_start, url_end, url_text) in urls {
187        // Process text before this URL
188        if *url_start > text_pos {
189            push_wrapped_text(
190                &text[text_pos..*url_start],
191                &mut current_line,
192                &mut current_width,
193                &mut result,
194            );
195        }
196
197        // Add URL — keep atomic if it fits, otherwise break it across lines
198        let url_width = url_text.width();
199        if url_width <= max_width {
200            if current_width > 0 && current_width + url_width > max_width {
201                flush_line(&mut current_line, &mut result);
202                current_width = 0;
203            }
204            current_line.push(Span::styled(url_text.to_string(), default_style));
205            current_width += url_width;
206        } else {
207            // URL is wider than max_width — break it grapheme-by-grapheme
208            if current_width > 0 {
209                flush_line(&mut current_line, &mut result);
210                current_width = 0;
211            }
212            push_wrapped_token(url_text, &mut current_line, &mut current_width, &mut result);
213        }
214
215        text_pos = *url_end;
216    }
217
218    // Process remaining text after last URL
219    if text_pos < text.len() {
220        push_wrapped_text(
221            &text[text_pos..],
222            &mut current_line,
223            &mut current_width,
224            &mut result,
225        );
226    }
227
228    flush_line(&mut current_line, &mut result);
229    if result.is_empty() {
230        result.push(Line::default());
231    }
232    result
233}
234
235/// Wrap multiple lines with URL preservation.
236pub fn wrap_lines_preserving_urls(
237    lines: Vec<Line<'static>>,
238    max_width: usize,
239) -> Vec<Line<'static>> {
240    if max_width == 0 {
241        return vec![Line::default()];
242    }
243    lines
244        .into_iter()
245        .flat_map(|line| wrap_line_preserving_urls(line, max_width))
246        .collect()
247}
248
249/// Calculate wrapped height using Paragraph::line_count.
250pub fn calculate_wrapped_height(text: &str, width: u16) -> usize {
251    if width == 0 {
252        return text.lines().count().max(1);
253    }
254    Paragraph::new(text).line_count(width)
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn test_url_detection() {
263        assert!(contains_preserved_token("https://example.com"));
264        assert!(contains_preserved_token("example.com/path"));
265        assert!(contains_preserved_token("localhost:8080"));
266        assert!(contains_preserved_token("192.168.1.1:8080"));
267        assert!(!contains_preserved_token("not a url"));
268    }
269
270    #[test]
271    fn test_file_path_detection() {
272        assert!(contains_preserved_token("src/main.rs"));
273        assert!(contains_preserved_token("./src/main.rs"));
274        assert!(contains_preserved_token("/tmp/example.txt"));
275        assert!(contains_preserved_token("\"./docs/My Notes.md\""));
276        assert!(contains_preserved_token(
277            "`/Users/example/Library/Application Support/Code/User/settings.json`"
278        ));
279    }
280
281    #[test]
282    fn test_url_only_preserved() {
283        let line = Line::from(Span::raw("https://example.com"));
284        let wrapped = wrap_line_preserving_urls(line, 80);
285        assert_eq!(wrapped.len(), 1);
286        assert!(
287            wrapped[0]
288                .spans
289                .iter()
290                .any(|s| s.content.contains("https://"))
291        );
292    }
293
294    #[test]
295    fn test_mixed_content() {
296        let line = Line::from(Span::raw("See https://example.com for info"));
297        let wrapped = wrap_line_preserving_urls(line, 25);
298        let all_text: String = wrapped
299            .iter()
300            .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
301            .collect();
302        assert!(all_text.contains("https://example.com"));
303        assert!(all_text.contains("See"));
304    }
305
306    #[test]
307    fn test_quoted_path_with_spaces_is_preserved() {
308        let line = Line::from(Span::raw("Open \"./docs/My Notes.md\" for details"));
309        let wrapped = wrap_line_preserving_urls(line, 18);
310        let all_text: String = wrapped
311            .iter()
312            .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
313            .collect();
314
315        assert!(all_text.contains("\"./docs/My Notes.md\""));
316    }
317
318    #[test]
319    fn test_long_url_breaks_across_lines() {
320        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";
321        let line = Line::from(Span::raw(long_url.to_string()));
322        let wrapped = wrap_line_preserving_urls(line, 80);
323        assert!(
324            wrapped.len() > 1,
325            "Long URL should wrap across multiple lines"
326        );
327        let all_text: String = wrapped
328            .iter()
329            .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
330            .collect();
331        assert_eq!(all_text, long_url, "All characters should be preserved");
332    }
333
334    #[test]
335    fn test_no_url_delegates() {
336        let line = Line::from(Span::raw("Regular text without URLs"));
337        let wrapped = wrap_line_preserving_urls(line, 10);
338        assert!(!wrapped.is_empty());
339    }
340
341    #[test]
342    fn test_mixed_content_prefers_word_boundaries_around_urls() {
343        let line = Line::from(Span::raw("alpha https://x.io beta gamma"));
344        let wrapped = wrap_line_preserving_urls(line, 12);
345        let rendered: Vec<String> = wrapped
346            .iter()
347            .map(|line| {
348                line.spans
349                    .iter()
350                    .map(|span| span.content.as_ref())
351                    .collect()
352            })
353            .collect();
354
355        assert_eq!(
356            rendered,
357            vec![
358                "alpha".to_string(),
359                "https://x.io".to_string(),
360                "beta gamma".to_string()
361            ]
362        );
363    }
364}