Skip to main content

vtcode_tui/core_tui/session/
mouse_selection.rs

1use ratatui::buffer::Buffer;
2use ratatui::crossterm::{clipboard::CopyToClipboard, execute};
3use ratatui::layout::Rect;
4use std::io::Write;
5#[cfg(test)]
6use std::path::PathBuf;
7#[cfg(test)]
8use std::sync::{Mutex, OnceLock};
9use std::time::{Duration, Instant};
10use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
11
12const DOUBLE_CLICK_INTERVAL: Duration = Duration::from_millis(450);
13
14/// Tracks mouse-driven text selection state for the TUI transcript.
15#[derive(Debug, Default)]
16pub struct MouseSelectionState {
17    /// Whether the user is currently dragging to select text.
18    pub is_selecting: bool,
19    /// Screen coordinates where the selection started (column, row).
20    pub start: (u16, u16),
21    /// Screen coordinates where the selection currently ends (column, row).
22    pub end: (u16, u16),
23    /// Whether a completed selection exists (ready for highlight rendering).
24    pub has_selection: bool,
25    /// Whether the current selection has already been copied to clipboard.
26    copied: bool,
27    /// Whether Ctrl+C was pressed to explicitly copy the current selection.
28    copy_requested: bool,
29    /// Tracks the previous mouse click so double-clicks can be detected.
30    last_click: Option<ClickRecord>,
31}
32
33#[derive(Clone, Copy, Debug)]
34struct ClickRecord {
35    column: u16,
36    row: u16,
37    at: Instant,
38}
39
40impl MouseSelectionState {
41    pub fn new() -> Self {
42        Self::default()
43    }
44
45    /// Begin a new selection at the given screen position.
46    pub fn start_selection(&mut self, col: u16, row: u16) {
47        self.is_selecting = true;
48        self.has_selection = false;
49        self.copied = false;
50        self.start = (col, row);
51        self.end = (col, row);
52    }
53
54    /// Set a selection directly, bypassing drag state.
55    pub fn set_selection(&mut self, start: (u16, u16), end: (u16, u16)) {
56        self.is_selecting = false;
57        self.has_selection = start != end;
58        self.copied = false;
59        self.start = start;
60        self.end = end;
61    }
62
63    /// Update the end position while dragging.
64    pub fn update_selection(&mut self, col: u16, row: u16) {
65        if self.is_selecting {
66            self.end = (col, row);
67            self.has_selection = true;
68        }
69    }
70
71    /// Finalize the selection on mouse-up.
72    pub fn finish_selection(&mut self, col: u16, row: u16) {
73        if self.is_selecting {
74            self.end = (col, row);
75            self.is_selecting = false;
76            // Only mark as having a selection if start != end
77            self.has_selection = self.start != self.end;
78        }
79    }
80
81    /// Adjust selection row coordinates after a scroll event.
82    ///
83    /// `row_delta` is positive when content moves down on screen (scroll up / showing
84    /// older content) and negative when content moves up (scroll down / showing newer
85    /// content).  If the adjustment pushes the selection completely off-screen the
86    /// selection is cleared.
87    pub fn adjust_for_scroll(&mut self, row_delta: i32) {
88        if !self.has_selection && !self.is_selecting {
89            return;
90        }
91        if row_delta == 0 {
92            return;
93        }
94
95        let new_start_row = self.start.1 as i32 + row_delta;
96        let new_end_row = self.end.1 as i32 + row_delta;
97
98        // If both ends are completely off-screen in the same direction, clear.
99        // Clamp to screen bounds (0..=viewport_height, roughly 0..=u16::MAX).
100        // If after clamping both are the same and off-screen, or both are off-screen
101        // in a way that suggests selection is gone, clear.
102
103        let clamped_start = new_start_row.clamp(0, i32::from(u16::MAX));
104        let clamped_end = new_end_row.clamp(0, i32::from(u16::MAX));
105
106        // If the selection is now completely off-screen in a way that means
107        // the original selection range was entirely off-screen, clear.
108        if (new_start_row < 0 && new_end_row < 0)
109            || (new_start_row > i32::from(u16::MAX) && new_end_row > i32::from(u16::MAX))
110        {
111            self.is_selecting = false;
112            self.has_selection = false;
113            self.copied = false;
114            self.copy_requested = false;
115            return;
116        }
117
118        self.start.1 = clamped_start as u16;
119        self.end.1 = clamped_end as u16;
120    }
121
122    /// Clear any active selection.
123    #[expect(dead_code)]
124    pub fn clear(&mut self) {
125        self.is_selecting = false;
126        self.has_selection = false;
127        self.copied = false;
128        self.copy_requested = false;
129        self.last_click = None;
130    }
131
132    /// Clears only the mouse click history used for double-click detection.
133    pub fn clear_click_history(&mut self) {
134        self.last_click = None;
135    }
136
137    /// Records a click and returns `true` when it matches the previous click closely enough
138    /// to be treated as a double click.
139    pub fn register_click(&mut self, col: u16, row: u16, at: Instant) -> bool {
140        let is_double_click = self.last_click.is_some_and(|last| {
141            last.column == col
142                && last.row == row
143                && at.saturating_duration_since(last.at) <= DOUBLE_CLICK_INTERVAL
144        });
145
146        self.last_click = Some(ClickRecord {
147            column: col,
148            row,
149            at,
150        });
151        is_double_click
152    }
153
154    /// Returns the selection range normalized so that `from` is before `to`.
155    fn normalized(&self) -> ((u16, u16), (u16, u16)) {
156        let (s, e) = (self.start, self.end);
157        if s.1 < e.1 || (s.1 == e.1 && s.0 <= e.0) {
158            (s, e)
159        } else {
160            (e, s)
161        }
162    }
163
164    /// Extract selected text from a ratatui `Buffer`.
165    pub fn extract_text(&self, buf: &Buffer, area: Rect) -> String {
166        if !self.has_selection && !self.is_selecting {
167            return String::new();
168        }
169
170        // Clamp to the actual buffer area to avoid out-of-range buffer indexing panics.
171        let area = area.intersection(buf.area);
172        if area.width == 0 || area.height == 0 {
173            return String::new();
174        }
175
176        let ((start_col, start_row), (end_col, end_row)) = self.normalized();
177        let mut result = String::new();
178
179        for row in start_row..=end_row {
180            if row < area.y || row >= area.bottom() {
181                continue;
182            }
183            let line_start = if row == start_row {
184                start_col.max(area.x)
185            } else {
186                area.x
187            };
188            let line_end = if row == end_row {
189                end_col.min(area.right())
190            } else {
191                area.right()
192            };
193
194            for col in line_start..line_end {
195                if col < area.x || col >= area.right() {
196                    continue;
197                }
198                let cell = &buf[(col, row)];
199                let symbol = cell.symbol();
200                if !symbol.is_empty() {
201                    result.push_str(symbol);
202                }
203            }
204
205            // Add newline between rows (but not after the last)
206            if row < end_row {
207                // Trim trailing whitespace from each line
208                let trimmed = result.trim_end().len();
209                result.truncate(trimmed);
210                result.push('\n');
211            }
212        }
213
214        // Trim trailing whitespace from the final line
215        let trimmed = result.trim_end();
216        trimmed.to_string()
217    }
218
219    /// Apply selection highlight (inverted colors) to the frame buffer.
220    pub fn apply_highlight(&self, buf: &mut Buffer, area: Rect) {
221        if !self.has_selection && !self.is_selecting {
222            return;
223        }
224
225        // Clamp to the actual buffer area to avoid out-of-range buffer indexing panics.
226        let area = area.intersection(buf.area);
227        if area.width == 0 || area.height == 0 {
228            return;
229        }
230
231        let ((start_col, start_row), (end_col, end_row)) = self.normalized();
232
233        for row in start_row..=end_row {
234            if row < area.y || row >= area.bottom() {
235                continue;
236            }
237            let line_start = if row == start_row {
238                start_col.max(area.x)
239            } else {
240                area.x
241            };
242            let line_end = if row == end_row {
243                end_col.min(area.right())
244            } else {
245                area.right()
246            };
247
248            for col in line_start..line_end {
249                if col < area.x || col >= area.right() {
250                    continue;
251                }
252                let cell = &mut buf[(col, row)];
253                // Swap foreground and background to show selection
254                let fg = cell.fg;
255                let bg = cell.bg;
256                cell.set_fg(bg);
257                cell.set_bg(fg);
258            }
259        }
260    }
261
262    /// Returns `true` if the selection needs to be copied (finalized and not yet copied).
263    pub fn needs_copy(&self) -> bool {
264        self.has_selection && !self.is_selecting && !self.copied
265    }
266
267    /// Returns `true` if an explicit copy was requested via Ctrl+C.
268    pub fn has_copy_request(&self) -> bool {
269        self.copy_requested
270    }
271
272    /// Request an explicit copy of the current selection (triggered by Ctrl+C).
273    pub fn request_copy(&mut self) {
274        if self.has_selection {
275            self.copy_requested = true;
276        }
277    }
278
279    /// Mark the selection as already copied.
280    pub fn mark_copied(&mut self) {
281        self.copied = true;
282    }
283
284    /// Copy the selected text to the system clipboard.
285    ///
286    /// Tries native OS clipboard utilities first (`pbcopy` on macOS, `xclip`/`xsel`
287    /// on Linux, `clip.exe` on Windows/WSL) for maximum compatibility, then falls
288    /// back to the OSC 52 escape sequence.
289    pub fn copy_to_clipboard(text: &str) {
290        if text.is_empty() {
291            return;
292        }
293
294        if Self::copy_via_native(text) {
295            return;
296        }
297
298        // Fallback: OSC 52 escape sequence
299        let _ = execute!(
300            std::io::stderr(),
301            CopyToClipboard::to_clipboard_from(text.as_bytes())
302        );
303        let _ = std::io::stderr().flush();
304    }
305
306    /// Attempt to copy text using native OS clipboard utilities.
307    /// Returns `true` if successful.
308    fn copy_via_native(text: &str) -> bool {
309        use std::process::Command;
310
311        #[cfg(test)]
312        if let Some(program) = clipboard_command_override() {
313            return spawn_clipboard_command(Command::new(program), text);
314        }
315
316        let candidates: &[&str] = if cfg!(target_os = "macos") {
317            &["pbcopy"]
318        } else if cfg!(target_os = "linux") {
319            &["xclip", "xsel"]
320        } else if cfg!(target_os = "windows") {
321            &["clip.exe"]
322        } else {
323            &[]
324        };
325
326        for program in candidates {
327            let mut cmd = Command::new(program);
328            match *program {
329                "xclip" => {
330                    cmd.arg("-selection").arg("clipboard");
331                }
332                "xsel" => {
333                    cmd.arg("--clipboard").arg("--input");
334                }
335                _ => {}
336            }
337            if spawn_clipboard_command(cmd, text) {
338                return true;
339            }
340        }
341        false
342    }
343}
344
345fn spawn_clipboard_command(mut cmd: std::process::Command, text: &str) -> bool {
346    use std::process::Stdio;
347
348    let Ok(mut child) = cmd
349        .stdin(Stdio::piped())
350        .stdout(Stdio::null())
351        .stderr(Stdio::null())
352        .spawn()
353    else {
354        return false;
355    };
356    if let Some(stdin) = child.stdin.as_mut() {
357        let _ = stdin.write_all(text.as_bytes());
358    }
359    drop(child.stdin.take());
360    child.wait().is_ok()
361}
362
363#[cfg(test)]
364static CLIPBOARD_COMMAND_OVERRIDE: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
365
366#[cfg(test)]
367pub(crate) fn set_clipboard_command_override(path: Option<PathBuf>) {
368    let lock = CLIPBOARD_COMMAND_OVERRIDE.get_or_init(|| Mutex::new(None));
369    if let Ok(mut guard) = lock.lock() {
370        *guard = path;
371    }
372}
373
374#[cfg(test)]
375pub(crate) fn clipboard_command_override() -> Option<PathBuf> {
376    let lock = CLIPBOARD_COMMAND_OVERRIDE.get_or_init(|| Mutex::new(None));
377    match lock.lock() {
378        Ok(guard) => guard.clone(),
379        Err(_) => None,
380    }
381}
382
383/// Return the half-open display-column range for the word under `column`.
384pub(crate) fn word_selection_range(text: &str, column: u16) -> Option<(u16, u16)> {
385    if text.is_empty() {
386        return None;
387    }
388
389    let chars: Vec<char> = text.chars().collect();
390    if chars.is_empty() {
391        return None;
392    }
393
394    let line_width = UnicodeWidthStr::width(text);
395    if usize::from(column) >= line_width {
396        return None;
397    }
398
399    let mut consumed = 0usize;
400    let mut char_index = 0usize;
401    for ch in &chars {
402        let width = UnicodeWidthChar::width(*ch).unwrap_or(0);
403        if consumed.saturating_add(width) > usize::from(column) {
404            break;
405        }
406        consumed = consumed.saturating_add(width);
407        char_index += 1;
408    }
409
410    if char_index >= chars.len() || chars[char_index].is_whitespace() {
411        return None;
412    }
413
414    let mut start = char_index;
415    while start > 0 && !chars[start - 1].is_whitespace() {
416        start -= 1;
417    }
418
419    let mut end = char_index + 1;
420    while end < chars.len() && !chars[end].is_whitespace() {
421        end += 1;
422    }
423
424    Some((
425        display_width_for_char_count(&chars, start),
426        display_width_for_char_count(&chars, end),
427    ))
428}
429
430fn display_width_for_char_count(chars: &[char], char_count: usize) -> u16 {
431    chars
432        .iter()
433        .take(char_count)
434        .map(|ch| UnicodeWidthChar::width(*ch).unwrap_or(0) as u16)
435        .fold(0_u16, u16::saturating_add)
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441    use ratatui::style::Color;
442    use std::time::{Duration, Instant};
443
444    #[test]
445    fn extract_text_clamps_area_to_buffer_bounds() {
446        let mut buf = Buffer::empty(Rect::new(0, 0, 2, 2));
447        buf[(0, 0)].set_symbol("A");
448        buf[(1, 0)].set_symbol("B");
449        buf[(0, 1)].set_symbol("C");
450        buf[(1, 1)].set_symbol("D");
451
452        let mut selection = MouseSelectionState::new();
453        selection.start_selection(0, 0);
454        selection.finish_selection(5, 5);
455
456        let text = selection.extract_text(&buf, Rect::new(0, 0, 10, 10));
457        assert_eq!(text, "AB\nCD");
458    }
459
460    #[test]
461    fn apply_highlight_clamps_area_to_buffer_bounds() {
462        let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
463        buf[(0, 0)].set_fg(Color::Red);
464        buf[(0, 0)].set_bg(Color::Blue);
465
466        let mut selection = MouseSelectionState::new();
467        selection.start_selection(0, 0);
468        selection.finish_selection(5, 5);
469
470        selection.apply_highlight(&mut buf, Rect::new(0, 0, 10, 10));
471
472        assert_eq!(buf[(0, 0)].fg, Color::Blue);
473        assert_eq!(buf[(0, 0)].bg, Color::Red);
474    }
475
476    #[test]
477    fn word_selection_range_selects_clicked_word() {
478        assert_eq!(word_selection_range("hello world", 1), Some((0, 5)));
479        assert_eq!(word_selection_range("hello world", 7), Some((6, 11)));
480    }
481
482    #[test]
483    fn word_selection_range_returns_none_for_whitespace() {
484        assert_eq!(word_selection_range("hello world", 5), None);
485    }
486
487    #[test]
488    fn adjust_for_scroll_shifts_rows() {
489        let mut sel = MouseSelectionState::new();
490        sel.set_selection((2, 5), (10, 8));
491
492        sel.adjust_for_scroll(3);
493        assert_eq!(sel.start, (2, 8));
494        assert_eq!(sel.end, (10, 11));
495        assert!(sel.has_selection);
496    }
497
498    #[test]
499    fn adjust_for_scroll_negative() {
500        let mut sel = MouseSelectionState::new();
501        sel.set_selection((0, 10), (5, 15));
502
503        sel.adjust_for_scroll(-4);
504        assert_eq!(sel.start, (0, 6));
505        assert_eq!(sel.end, (5, 11));
506    }
507
508    #[test]
509    fn adjust_for_scroll_clears_when_offscreen() {
510        let mut sel = MouseSelectionState::new();
511        sel.set_selection((0, 2), (5, 4));
512
513        sel.adjust_for_scroll(-10);
514        assert!(!sel.has_selection);
515        assert!(!sel.is_selecting);
516    }
517
518    #[test]
519    fn adjust_for_scroll_noop_without_selection() {
520        let mut sel = MouseSelectionState::new();
521        sel.adjust_for_scroll(5);
522        assert!(!sel.has_selection);
523    }
524
525    #[test]
526    fn register_click_detects_double_clicks_at_same_position() {
527        let mut selection = MouseSelectionState::new();
528        let now = Instant::now();
529
530        assert!(!selection.register_click(3, 7, now));
531        assert!(selection.register_click(3, 7, now + Duration::from_millis(250)));
532        assert!(!selection.register_click(4, 7, now + Duration::from_millis(250)));
533    }
534}