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 .unwrap()
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 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 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 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 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 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 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
151pub 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
165pub 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}