1use ratatui::{
13 style::{Color, Modifier, Style},
14 text::{Line, Span},
15};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub(crate) struct VisualPos {
25 pub row: u16,
27 pub col: u16,
29}
30
31#[derive(Debug, Clone)]
33pub(crate) struct Selection {
34 pub anchor: VisualPos,
36 pub cursor: VisualPos,
38 pub scroll_from_top: u16,
43}
44
45impl Selection {
46 pub fn ordered(&self) -> (VisualPos, VisualPos) {
48 if self.anchor.row < self.cursor.row
49 || (self.anchor.row == self.cursor.row && self.anchor.col <= self.cursor.col)
50 {
51 (self.anchor, self.cursor)
52 } else {
53 (self.cursor, self.anchor)
54 }
55 }
56
57 #[allow(dead_code)] pub fn contains_row(&self, row: u16) -> bool {
60 let (start, end) = self.ordered();
61 row >= start.row && row <= end.row
62 }
63}
64
65pub(crate) fn build_all_visual_rows(
71 lines: &[Line<'_>],
72 gutter_widths: &[u16],
73 viewport_width: usize,
74) -> (Vec<String>, Vec<u16>) {
75 let mut visual_rows: Vec<String> = Vec::new();
76 let mut visual_gutters: Vec<u16> = Vec::new();
77 let w = viewport_width.max(1);
78
79 for (i, line) in lines.iter().enumerate() {
80 let gw = gutter_widths.get(i).copied().unwrap_or(0);
81 let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
82 if text.is_empty() {
83 visual_rows.push(String::new());
84 visual_gutters.push(gw);
85 } else {
86 let chars: Vec<char> = text.chars().collect();
87 for (j, chunk) in chars.chunks(w).enumerate() {
88 visual_rows.push(chunk.iter().collect());
89 visual_gutters.push(if j == 0 { gw } else { 0 });
91 }
92 }
93 }
94
95 (visual_rows, visual_gutters)
96}
97
98#[cfg(test)]
104fn extract_visible_text(
105 lines: &[Line<'_>],
106 scroll_from_top: u16,
107 viewport_width: usize,
108 viewport_height: usize,
109) -> Vec<String> {
110 let mut visual_rows: Vec<String> = Vec::new();
111
112 for line in lines {
114 let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
115 if text.is_empty() {
116 visual_rows.push(String::new());
117 } else {
118 let chars: Vec<char> = text.chars().collect();
120 for chunk in chars.chunks(viewport_width.max(1)) {
121 visual_rows.push(chunk.iter().collect());
122 }
123 }
124 }
125
126 let start = scroll_from_top as usize;
128 let end = (start + viewport_height).min(visual_rows.len());
129 if start < visual_rows.len() {
130 visual_rows[start..end].to_vec()
131 } else {
132 Vec::new()
133 }
134}
135
136pub(crate) fn extract_selected_text(
142 rows: &[String],
143 gutters: &[u16],
144 selection: &Selection,
145) -> String {
146 let (start, end) = selection.ordered();
147 let mut result = String::new();
148
149 for row in start.row..=end.row {
150 let idx = row as usize;
151 if idx >= rows.len() {
152 break;
153 }
154 let line = &rows[idx];
155 let gutter_w = gutters.get(idx).copied().unwrap_or(0) as usize;
156 let chars: Vec<char> = line.chars().collect();
157
158 let col_start = if row == start.row {
159 start.col as usize
160 } else {
161 0
162 };
163 let col_end = if row == end.row {
164 (end.col as usize + 1).min(chars.len())
165 } else {
166 chars.len()
167 };
168
169 let effective_start = col_start.max(gutter_w);
171
172 if effective_start < chars.len() && effective_start < col_end {
173 let selected: String = chars[effective_start..col_end.min(chars.len())]
174 .iter()
175 .collect();
176 result.push_str(&selected);
177 }
178 if row < end.row {
179 result.push('\n');
180 }
181 }
182
183 result
184}
185
186pub(crate) fn apply_selection_highlight<'a>(
191 lines: Vec<Line<'a>>,
192 selection: &Selection,
193 _scroll_from_top: u16,
194 viewport_width: usize,
195 _history_y: u16,
196) -> Vec<Line<'a>> {
197 let (sel_start, sel_end) = selection.ordered();
198 let highlight = Style::default()
199 .bg(Color::Rgb(68, 68, 120))
200 .fg(Color::White)
201 .add_modifier(Modifier::BOLD);
202
203 let mut visual_row: u16 = 0;
204 let mut result = Vec::with_capacity(lines.len());
205
206 for line in lines {
207 let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
208 let rows_this_line = if text.is_empty() {
209 1
210 } else {
211 text.chars().count().div_ceil(viewport_width.max(1))
212 } as u16;
213
214 let line_end = visual_row + rows_this_line - 1;
216 let in_selection = line_end >= sel_start.row && visual_row <= sel_end.row;
217
218 if in_selection {
219 let highlighted_spans: Vec<Span<'a>> = line
220 .spans
221 .into_iter()
222 .map(|s| Span::styled(s.content, highlight))
223 .collect();
224 result.push(Line::from(highlighted_spans));
225 } else {
226 result.push(line);
227 }
228
229 visual_row += rows_this_line;
230 }
231
232 result
233}
234
235pub(crate) fn copy_to_clipboard(text: &str) -> Result<String, String> {
240 crate::clipboard::copy_to_clipboard(text)
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 fn make_line(text: &str) -> Line<'static> {
248 Line::from(text.to_string())
249 }
250
251 #[test]
252 fn test_selection_ordered() {
253 let sel = Selection {
254 anchor: VisualPos { row: 5, col: 10 },
255 cursor: VisualPos { row: 2, col: 3 },
256 scroll_from_top: 0,
257 };
258 let (start, end) = sel.ordered();
259 assert_eq!(start.row, 2);
260 assert_eq!(end.row, 5);
261 }
262
263 #[test]
264 fn test_selection_contains_row() {
265 let sel = Selection {
266 anchor: VisualPos { row: 2, col: 0 },
267 cursor: VisualPos { row: 5, col: 10 },
268 scroll_from_top: 0,
269 };
270 assert!(!sel.contains_row(1));
271 assert!(sel.contains_row(2));
272 assert!(sel.contains_row(3));
273 assert!(sel.contains_row(5));
274 assert!(!sel.contains_row(6));
275 }
276
277 #[test]
278 fn test_extract_visible_text_basic() {
279 let lines = vec![
280 make_line("line one"),
281 make_line("line two"),
282 make_line("line three"),
283 ];
284 let visible = extract_visible_text(&lines, 0, 80, 10);
285 assert_eq!(visible.len(), 3);
286 assert_eq!(visible[0], "line one");
287 assert_eq!(visible[2], "line three");
288 }
289
290 #[test]
291 fn test_extract_visible_text_with_scroll() {
292 let lines = vec![
293 make_line("line one"),
294 make_line("line two"),
295 make_line("line three"),
296 ];
297 let visible = extract_visible_text(&lines, 1, 80, 10);
298 assert_eq!(visible.len(), 2);
299 assert_eq!(visible[0], "line two");
300 }
301
302 #[test]
303 fn test_extract_visible_text_with_wrapping() {
304 let lines = vec![make_line("abcdefghij12345")];
306 let visible = extract_visible_text(&lines, 0, 10, 10);
307 assert_eq!(visible.len(), 2);
308 assert_eq!(visible[0], "abcdefghij");
309 assert_eq!(visible[1], "12345");
310 }
311
312 fn no_gutters(n: usize) -> Vec<u16> {
313 vec![0; n]
314 }
315
316 #[test]
317 fn test_extract_selected_text_single_line() {
318 let rows = vec!["hello world".to_string()];
319 let sel = Selection {
320 anchor: VisualPos { row: 0, col: 6 },
321 cursor: VisualPos { row: 0, col: 10 },
322 scroll_from_top: 0,
323 };
324 let text = extract_selected_text(&rows, &no_gutters(1), &sel);
325 assert_eq!(text, "world");
326 }
327
328 #[test]
329 fn test_extract_selected_text_multi_line() {
330 let rows = vec![
331 "first line".to_string(),
332 "second line".to_string(),
333 "third line".to_string(),
334 ];
335 let sel = Selection {
336 anchor: VisualPos { row: 0, col: 6 },
337 cursor: VisualPos { row: 2, col: 4 },
338 scroll_from_top: 0,
339 };
340 let text = extract_selected_text(&rows, &no_gutters(3), &sel);
341 assert_eq!(text, "line\nsecond line\nthird");
342 }
343
344 #[test]
345 fn test_copy_to_clipboard_format() {
346 let rows = vec!["hello".to_string(), "world".to_string()];
347 let sel = Selection {
348 anchor: VisualPos { row: 0, col: 0 },
349 cursor: VisualPos { row: 1, col: 4 },
350 scroll_from_top: 0,
351 };
352 let text = extract_selected_text(&rows, &no_gutters(2), &sel);
353 assert_eq!(text, "hello\nworld");
354 }
355
356 #[test]
357 fn test_build_all_visual_rows_basic() {
358 let lines = vec![
359 make_line("line one"),
360 make_line("line two"),
361 make_line("line three"),
362 ];
363 let (rows, _) = build_all_visual_rows(&lines, &no_gutters(3), 80);
364 assert_eq!(rows.len(), 3);
365 assert_eq!(rows[0], "line one");
366 assert_eq!(rows[2], "line three");
367 }
368
369 #[test]
370 fn test_build_all_visual_rows_with_wrapping() {
371 let lines = vec![make_line("abcdefghij12345")];
372 let (rows, _) = build_all_visual_rows(&lines, &no_gutters(1), 10);
373 assert_eq!(rows.len(), 2);
374 assert_eq!(rows[0], "abcdefghij");
375 assert_eq!(rows[1], "12345");
376 }
377
378 #[test]
379 fn test_build_all_visual_rows_empty_lines() {
380 let lines = vec![make_line("hello"), make_line(""), make_line("world")];
381 let (rows, _) = build_all_visual_rows(&lines, &no_gutters(3), 80);
382 assert_eq!(rows.len(), 3);
383 assert_eq!(rows[0], "hello");
384 assert_eq!(rows[1], "");
385 assert_eq!(rows[2], "world");
386 }
387
388 #[test]
392 fn test_cross_page_selection() {
393 let lines: Vec<Line<'_>> = (0..20).map(|i| make_line(&format!("line {i}"))).collect();
394 let (all_rows, all_gutters) = build_all_visual_rows(&lines, &no_gutters(20), 80);
395 assert_eq!(all_rows.len(), 20);
396
397 let sel = Selection {
398 anchor: VisualPos { row: 2, col: 0 },
399 cursor: VisualPos { row: 8, col: 5 },
400 scroll_from_top: 0,
401 };
402 let text = extract_selected_text(&all_rows, &all_gutters, &sel);
403 assert!(text.contains("line 2"));
404 assert!(text.contains("line 5"));
405 assert!(text.contains("line 8"));
406 assert!(!text.contains("line 1\n"));
407 assert!(!text.contains("line 9"));
408 assert_eq!(text.lines().count(), 7);
409 }
410
411 #[test]
413 fn test_noselect_gutter_skipped() {
414 let rows = vec![
417 " 1 fn main() {".to_string(),
418 " 2 - println!(\"hello\");".to_string(),
419 " 2 + println!(\"world\");".to_string(),
420 " 3 }".to_string(),
421 ];
422 let gutters = vec![7u16, 7, 7, 7];
423 let sel = Selection {
424 anchor: VisualPos { row: 0, col: 0 },
425 cursor: VisualPos { row: 3, col: 30 },
426 scroll_from_top: 0,
427 };
428 let text = extract_selected_text(&rows, &gutters, &sel);
429 assert!(!text.contains(" 1"), "should skip gutter: {text}");
431 assert!(!text.contains(" - "), "should skip sigil: {text}");
432 assert!(!text.contains(" + "), "should skip sigil: {text}");
433 assert!(text.contains("fn main()"));
435 assert!(text.contains("println"));
436 }
437}