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 the system clipboard, returning a short status phrase.
236///
237/// Delegates to [`crate::clipboard`] which auto-selects arboard (local),
238/// OSC 52 (SSH), or OSC 52 + tmux passthrough depending on the environment.
239pub(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        // A line that wraps at width 10
305        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    /// Simulates a cross-page selection: anchor at buffer row 2, cursor at
389    /// buffer row 8, with a viewport that only shows 5 rows at a time.
390    /// The selection should capture all rows 2–8 regardless of viewport.
391    #[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 NoSelect: gutter columns are skipped during text extraction.
412    #[test]
413    fn test_noselect_gutter_skipped() {
414        // Simulate diff lines with 7-char gutter: "{:>4} {} "
415        // e.g. "   1   fn main() {" (context, sigil=' ')
416        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        // Should NOT contain line numbers or +/- markers
430        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        // Should contain the code content
434        assert!(text.contains("fn main()"));
435        assert!(text.contains("println"));
436    }
437}