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