vtcode_tui/core_tui/session/
wrapping.rs1use ratatui::prelude::*;
7use ratatui::widgets::Paragraph;
8use regex::Regex;
9use std::sync::LazyLock;
10use unicode_width::UnicodeWidthStr;
11
12static 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
20pub fn contains_preserved_token(text: &str) -> bool {
22 PRESERVED_TOKEN_PATTERN.is_match(text)
23}
24
25pub 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 if !contains_preserved_token(&text) {
39 return super::text_utils::wrap_line(line, max_width);
40 }
41
42 let urls: Vec<_> = PRESERVED_TOKEN_PATTERN
44 .find_iter(&text)
45 .map(|m| (m.start(), m.end(), m.as_str()))
46 .collect();
47
48 if urls.len() == 1 && urls[0].0 == 0 && urls[0].1 == text.len() && text.width() <= max_width {
50 return vec![line];
51 }
52 wrap_mixed_content(line, &text, max_width, &urls)
56}
57
58fn 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 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 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 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 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 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
235pub 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
249pub 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}