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> {
237 match arboard::Clipboard::new().and_then(|mut cb| cb.set_text(text)) {
238 Ok(()) => {
239 let preview: String = text.chars().take(50).collect();
240 let lines = text.lines().count();
241 Ok(format!("Copied {lines} line(s): {preview}…"))
242 }
243 Err(e) => Err(format!("Clipboard error: {e}")),
244 }
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250
251 fn make_line(text: &str) -> Line<'static> {
252 Line::from(text.to_string())
253 }
254
255 #[test]
256 fn test_selection_ordered() {
257 let sel = Selection {
258 anchor: VisualPos { row: 5, col: 10 },
259 cursor: VisualPos { row: 2, col: 3 },
260 scroll_from_top: 0,
261 };
262 let (start, end) = sel.ordered();
263 assert_eq!(start.row, 2);
264 assert_eq!(end.row, 5);
265 }
266
267 #[test]
268 fn test_selection_contains_row() {
269 let sel = Selection {
270 anchor: VisualPos { row: 2, col: 0 },
271 cursor: VisualPos { row: 5, col: 10 },
272 scroll_from_top: 0,
273 };
274 assert!(!sel.contains_row(1));
275 assert!(sel.contains_row(2));
276 assert!(sel.contains_row(3));
277 assert!(sel.contains_row(5));
278 assert!(!sel.contains_row(6));
279 }
280
281 #[test]
282 fn test_extract_visible_text_basic() {
283 let lines = vec![
284 make_line("line one"),
285 make_line("line two"),
286 make_line("line three"),
287 ];
288 let visible = extract_visible_text(&lines, 0, 80, 10);
289 assert_eq!(visible.len(), 3);
290 assert_eq!(visible[0], "line one");
291 assert_eq!(visible[2], "line three");
292 }
293
294 #[test]
295 fn test_extract_visible_text_with_scroll() {
296 let lines = vec![
297 make_line("line one"),
298 make_line("line two"),
299 make_line("line three"),
300 ];
301 let visible = extract_visible_text(&lines, 1, 80, 10);
302 assert_eq!(visible.len(), 2);
303 assert_eq!(visible[0], "line two");
304 }
305
306 #[test]
307 fn test_extract_visible_text_with_wrapping() {
308 let lines = vec![make_line("abcdefghij12345")];
310 let visible = extract_visible_text(&lines, 0, 10, 10);
311 assert_eq!(visible.len(), 2);
312 assert_eq!(visible[0], "abcdefghij");
313 assert_eq!(visible[1], "12345");
314 }
315
316 fn no_gutters(n: usize) -> Vec<u16> {
317 vec![0; n]
318 }
319
320 #[test]
321 fn test_extract_selected_text_single_line() {
322 let rows = vec!["hello world".to_string()];
323 let sel = Selection {
324 anchor: VisualPos { row: 0, col: 6 },
325 cursor: VisualPos { row: 0, col: 10 },
326 scroll_from_top: 0,
327 };
328 let text = extract_selected_text(&rows, &no_gutters(1), &sel);
329 assert_eq!(text, "world");
330 }
331
332 #[test]
333 fn test_extract_selected_text_multi_line() {
334 let rows = vec![
335 "first line".to_string(),
336 "second line".to_string(),
337 "third line".to_string(),
338 ];
339 let sel = Selection {
340 anchor: VisualPos { row: 0, col: 6 },
341 cursor: VisualPos { row: 2, col: 4 },
342 scroll_from_top: 0,
343 };
344 let text = extract_selected_text(&rows, &no_gutters(3), &sel);
345 assert_eq!(text, "line\nsecond line\nthird");
346 }
347
348 #[test]
349 fn test_copy_to_clipboard_format() {
350 let rows = vec!["hello".to_string(), "world".to_string()];
351 let sel = Selection {
352 anchor: VisualPos { row: 0, col: 0 },
353 cursor: VisualPos { row: 1, col: 4 },
354 scroll_from_top: 0,
355 };
356 let text = extract_selected_text(&rows, &no_gutters(2), &sel);
357 assert_eq!(text, "hello\nworld");
358 }
359
360 #[test]
361 fn test_build_all_visual_rows_basic() {
362 let lines = vec![
363 make_line("line one"),
364 make_line("line two"),
365 make_line("line three"),
366 ];
367 let (rows, _) = build_all_visual_rows(&lines, &no_gutters(3), 80);
368 assert_eq!(rows.len(), 3);
369 assert_eq!(rows[0], "line one");
370 assert_eq!(rows[2], "line three");
371 }
372
373 #[test]
374 fn test_build_all_visual_rows_with_wrapping() {
375 let lines = vec![make_line("abcdefghij12345")];
376 let (rows, _) = build_all_visual_rows(&lines, &no_gutters(1), 10);
377 assert_eq!(rows.len(), 2);
378 assert_eq!(rows[0], "abcdefghij");
379 assert_eq!(rows[1], "12345");
380 }
381
382 #[test]
383 fn test_build_all_visual_rows_empty_lines() {
384 let lines = vec![make_line("hello"), make_line(""), make_line("world")];
385 let (rows, _) = build_all_visual_rows(&lines, &no_gutters(3), 80);
386 assert_eq!(rows.len(), 3);
387 assert_eq!(rows[0], "hello");
388 assert_eq!(rows[1], "");
389 assert_eq!(rows[2], "world");
390 }
391
392 #[test]
396 fn test_cross_page_selection() {
397 let lines: Vec<Line<'_>> = (0..20).map(|i| make_line(&format!("line {i}"))).collect();
398 let (all_rows, all_gutters) = build_all_visual_rows(&lines, &no_gutters(20), 80);
399 assert_eq!(all_rows.len(), 20);
400
401 let sel = Selection {
402 anchor: VisualPos { row: 2, col: 0 },
403 cursor: VisualPos { row: 8, col: 5 },
404 scroll_from_top: 0,
405 };
406 let text = extract_selected_text(&all_rows, &all_gutters, &sel);
407 assert!(text.contains("line 2"));
408 assert!(text.contains("line 5"));
409 assert!(text.contains("line 8"));
410 assert!(!text.contains("line 1\n"));
411 assert!(!text.contains("line 9"));
412 assert_eq!(text.lines().count(), 7);
413 }
414
415 #[test]
417 fn test_noselect_gutter_skipped() {
418 let rows = vec![
421 " 1 fn main() {".to_string(),
422 " 2 - println!(\"hello\");".to_string(),
423 " 2 + println!(\"world\");".to_string(),
424 " 3 }".to_string(),
425 ];
426 let gutters = vec![7u16, 7, 7, 7];
427 let sel = Selection {
428 anchor: VisualPos { row: 0, col: 0 },
429 cursor: VisualPos { row: 3, col: 30 },
430 scroll_from_top: 0,
431 };
432 let text = extract_selected_text(&rows, &gutters, &sel);
433 assert!(!text.contains(" 1"), "should skip gutter: {text}");
435 assert!(!text.contains(" - "), "should skip sigil: {text}");
436 assert!(!text.contains(" + "), "should skip sigil: {text}");
437 assert!(text.contains("fn main()"));
439 assert!(text.contains("println"));
440 }
441}