Skip to main content

koda_cli/
mouse_select.rs

1//! Mouse text selection for fullscreen TUI.
2//!
3//! When mouse capture is enabled (for scroll wheel), native terminal
4//! text selection is unavailable. This module implements click-drag
5//! selection in the history panel with automatic clipboard copy.
6//!
7//! Design: line-granularity selection with character-level endpoints.
8//! The user clicks and drags in the history area; selected text is
9//! highlighted with inverted colors. On mouse release, the selection
10//! is copied to clipboard automatically.
11
12use ratatui::{
13    style::{Color, Modifier, Style},
14    text::{Line, Span},
15};
16
17/// A position in the history panel.
18///
19/// `row` is in **buffer space** (absolute visual row index across the
20/// entire scroll buffer, accounting for line wrapping). This makes
21/// selections stable across scroll operations — the anchor doesn't
22/// become stale when the viewport moves.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub(crate) struct VisualPos {
25    /// Absolute visual row in the scroll buffer (0 = first row).
26    pub row: u16,
27    /// Column (0-based).
28    pub col: u16,
29}
30
31/// Active text selection state.
32#[derive(Debug, Clone)]
33pub(crate) struct Selection {
34    /// Where the drag started (anchor).
35    pub anchor: VisualPos,
36    /// Current drag position (cursor).
37    pub cursor: VisualPos,
38    /// The scroll-from-top offset captured at MouseDown time.
39    ///
40    /// Used to convert screen rows → buffer rows consistently across
41    /// the entire drag (immune to buffer growth during inference).
42    pub scroll_from_top: u16,
43}
44
45impl Selection {
46    /// Return (start, end) normalized so start ≤ end.
47    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    /// Check if a visual row is within the selection range.
58    #[allow(dead_code)] // used in render path, will be wired for per-row highlighting
59    pub fn contains_row(&self, row: u16) -> bool {
60        let (start, end) = self.ordered();
61        row >= start.row && row <= end.row
62    }
63}
64
65/// Build ALL visual rows from logical lines, accounting for line wrapping.
66///
67/// Returns one String per visual row across the entire scroll buffer.
68/// Also returns a parallel vec of gutter widths per visual row.
69/// Used for buffer-space text extraction during copy operations.
70pub(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                // Only the first visual row of a wrapped line has the gutter
90                visual_gutters.push(if j == 0 { gw } else { 0 });
91            }
92        }
93    }
94
95    (visual_rows, visual_gutters)
96}
97
98/// Extract the plain text content visible in the history area.
99///
100/// Returns one String per visual row, accounting for line wrapping.
101/// The returned vec has exactly `viewport_height` entries (or fewer
102/// if the buffer is shorter than the viewport).
103#[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    // Build all visual rows from logical lines
113    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            // Wrap at viewport_width
119            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    // Slice to the visible window
127    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
136/// Extract the selected text from visual rows, skipping gutter columns.
137///
138/// `rows` and `gutters` must be parallel (from `build_all_visual_rows`).
139/// For each row with a non-zero gutter width, that many leading columns
140/// are excluded from the extracted text (NoSelect behavior).
141pub(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        // Skip gutter columns (NoSelect)
170        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
186/// Apply selection highlighting to lines being rendered.
187///
188/// Returns modified lines with inverted styles on selected regions.
189/// Selection coordinates are in **buffer space** (absolute visual rows).
190pub(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        // Selection is in buffer space — compare directly against visual_row
215        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
235/// Copy text to system clipboard, returning a user-friendly message.
236pub(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        // A line that wraps at width 10
309        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    /// Simulates a cross-page selection: anchor at buffer row 2, cursor at
393    /// buffer row 8, with a viewport that only shows 5 rows at a time.
394    /// The selection should capture all rows 2–8 regardless of viewport.
395    #[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 NoSelect: gutter columns are skipped during text extraction.
416    #[test]
417    fn test_noselect_gutter_skipped() {
418        // Simulate diff lines with 7-char gutter: "{:>4} {} "
419        // e.g. "   1   fn main() {" (context, sigil=' ')
420        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        // Should NOT contain line numbers or +/- markers
434        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        // Should contain the code content
438        assert!(text.contains("fn main()"));
439        assert!(text.contains("println"));
440    }
441}