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    /// Clear any active selection.
82    #[allow(dead_code)]
83    pub fn clear(&mut self) {
84        self.is_selecting = false;
85        self.has_selection = false;
86        self.copied = false;
87        self.copy_requested = false;
88        self.last_click = None;
89    }
90
91    /// Clears only the mouse click history used for double-click detection.
92    pub fn clear_click_history(&mut self) {
93        self.last_click = None;
94    }
95
96    /// Records a click and returns `true` when it matches the previous click closely enough
97    /// to be treated as a double click.
98    pub fn register_click(&mut self, col: u16, row: u16, at: Instant) -> bool {
99        let is_double_click = self.last_click.is_some_and(|last| {
100            last.column == col
101                && last.row == row
102                && at.saturating_duration_since(last.at) <= DOUBLE_CLICK_INTERVAL
103        });
104
105        self.last_click = Some(ClickRecord {
106            column: col,
107            row,
108            at,
109        });
110        is_double_click
111    }
112
113    /// Returns the selection range normalized so that `from` is before `to`.
114    fn normalized(&self) -> ((u16, u16), (u16, u16)) {
115        let (s, e) = (self.start, self.end);
116        if s.1 < e.1 || (s.1 == e.1 && s.0 <= e.0) {
117            (s, e)
118        } else {
119            (e, s)
120        }
121    }
122
123    /// Extract selected text from a ratatui `Buffer`.
124    pub fn extract_text(&self, buf: &Buffer, area: Rect) -> String {
125        if !self.has_selection && !self.is_selecting {
126            return String::new();
127        }
128
129        // Clamp to the actual buffer area to avoid out-of-range buffer indexing panics.
130        let area = area.intersection(buf.area);
131        if area.width == 0 || area.height == 0 {
132            return String::new();
133        }
134
135        let ((start_col, start_row), (end_col, end_row)) = self.normalized();
136        let mut result = String::new();
137
138        for row in start_row..=end_row {
139            if row < area.y || row >= area.bottom() {
140                continue;
141            }
142            let line_start = if row == start_row {
143                start_col.max(area.x)
144            } else {
145                area.x
146            };
147            let line_end = if row == end_row {
148                end_col.min(area.right())
149            } else {
150                area.right()
151            };
152
153            for col in line_start..line_end {
154                if col < area.x || col >= area.right() {
155                    continue;
156                }
157                let cell = &buf[(col, row)];
158                let symbol = cell.symbol();
159                if !symbol.is_empty() {
160                    result.push_str(symbol);
161                }
162            }
163
164            // Add newline between rows (but not after the last)
165            if row < end_row {
166                // Trim trailing whitespace from each line
167                let trimmed = result.trim_end().len();
168                result.truncate(trimmed);
169                result.push('\n');
170            }
171        }
172
173        // Trim trailing whitespace from the final line
174        let trimmed = result.trim_end();
175        trimmed.to_string()
176    }
177
178    /// Apply selection highlight (inverted colors) to the frame buffer.
179    pub fn apply_highlight(&self, buf: &mut Buffer, area: Rect) {
180        if !self.has_selection && !self.is_selecting {
181            return;
182        }
183
184        // Clamp to the actual buffer area to avoid out-of-range buffer indexing panics.
185        let area = area.intersection(buf.area);
186        if area.width == 0 || area.height == 0 {
187            return;
188        }
189
190        let ((start_col, start_row), (end_col, end_row)) = self.normalized();
191
192        for row in start_row..=end_row {
193            if row < area.y || row >= area.bottom() {
194                continue;
195            }
196            let line_start = if row == start_row {
197                start_col.max(area.x)
198            } else {
199                area.x
200            };
201            let line_end = if row == end_row {
202                end_col.min(area.right())
203            } else {
204                area.right()
205            };
206
207            for col in line_start..line_end {
208                if col < area.x || col >= area.right() {
209                    continue;
210                }
211                let cell = &mut buf[(col, row)];
212                // Swap foreground and background to show selection
213                let fg = cell.fg;
214                let bg = cell.bg;
215                cell.set_fg(bg);
216                cell.set_bg(fg);
217            }
218        }
219    }
220
221    /// Returns `true` if the selection needs to be copied (finalized and not yet copied).
222    pub fn needs_copy(&self) -> bool {
223        self.has_selection && !self.is_selecting && !self.copied
224    }
225
226    /// Returns `true` if an explicit copy was requested via Ctrl+C.
227    pub fn has_copy_request(&self) -> bool {
228        self.copy_requested
229    }
230
231    /// Request an explicit copy of the current selection (triggered by Ctrl+C).
232    pub fn request_copy(&mut self) {
233        if self.has_selection {
234            self.copy_requested = true;
235        }
236    }
237
238    /// Mark the selection as already copied.
239    pub fn mark_copied(&mut self) {
240        self.copied = true;
241    }
242
243    /// Copy the selected text to the system clipboard.
244    ///
245    /// Tries native OS clipboard utilities first (`pbcopy` on macOS, `xclip`/`xsel`
246    /// on Linux, `clip.exe` on Windows/WSL) for maximum compatibility, then falls
247    /// back to the OSC 52 escape sequence.
248    pub fn copy_to_clipboard(text: &str) {
249        if text.is_empty() {
250            return;
251        }
252
253        if Self::copy_via_native(text) {
254            return;
255        }
256
257        // Fallback: OSC 52 escape sequence
258        let _ = execute!(
259            std::io::stderr(),
260            CopyToClipboard::to_clipboard_from(text.as_bytes())
261        );
262        let _ = std::io::stderr().flush();
263    }
264
265    /// Attempt to copy text using native OS clipboard utilities.
266    /// Returns `true` if successful.
267    fn copy_via_native(text: &str) -> bool {
268        use std::process::Command;
269
270        #[cfg(test)]
271        if let Some(program) = clipboard_command_override() {
272            return spawn_clipboard_command(Command::new(program), text);
273        }
274
275        let candidates: &[&str] = if cfg!(target_os = "macos") {
276            &["pbcopy"]
277        } else if cfg!(target_os = "linux") {
278            &["xclip", "xsel"]
279        } else if cfg!(target_os = "windows") {
280            &["clip.exe"]
281        } else {
282            &[]
283        };
284
285        for program in candidates {
286            let mut cmd = Command::new(program);
287            match *program {
288                "xclip" => {
289                    cmd.arg("-selection").arg("clipboard");
290                }
291                "xsel" => {
292                    cmd.arg("--clipboard").arg("--input");
293                }
294                _ => {}
295            }
296            if spawn_clipboard_command(cmd, text) {
297                return true;
298            }
299        }
300        false
301    }
302}
303
304fn spawn_clipboard_command(mut cmd: std::process::Command, text: &str) -> bool {
305    use std::process::Stdio;
306
307    let Ok(mut child) = cmd
308        .stdin(Stdio::piped())
309        .stdout(Stdio::null())
310        .stderr(Stdio::null())
311        .spawn()
312    else {
313        return false;
314    };
315    if let Some(stdin) = child.stdin.as_mut() {
316        let _ = stdin.write_all(text.as_bytes());
317    }
318    drop(child.stdin.take());
319    child.wait().is_ok()
320}
321
322#[cfg(test)]
323static CLIPBOARD_COMMAND_OVERRIDE: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
324
325#[cfg(test)]
326pub(crate) fn set_clipboard_command_override(path: Option<PathBuf>) {
327    let lock = CLIPBOARD_COMMAND_OVERRIDE.get_or_init(|| Mutex::new(None));
328    if let Ok(mut guard) = lock.lock() {
329        *guard = path;
330    }
331}
332
333#[cfg(test)]
334pub(crate) fn clipboard_command_override() -> Option<PathBuf> {
335    let lock = CLIPBOARD_COMMAND_OVERRIDE.get_or_init(|| Mutex::new(None));
336    match lock.lock() {
337        Ok(guard) => guard.clone(),
338        Err(_) => None,
339    }
340}
341
342/// Return the half-open display-column range for the word under `column`.
343pub(crate) fn word_selection_range(text: &str, column: u16) -> Option<(u16, u16)> {
344    if text.is_empty() {
345        return None;
346    }
347
348    let chars: Vec<char> = text.chars().collect();
349    if chars.is_empty() {
350        return None;
351    }
352
353    let line_width = UnicodeWidthStr::width(text);
354    if usize::from(column) >= line_width {
355        return None;
356    }
357
358    let mut consumed = 0usize;
359    let mut char_index = 0usize;
360    for ch in &chars {
361        let width = UnicodeWidthChar::width(*ch).unwrap_or(0);
362        if consumed.saturating_add(width) > usize::from(column) {
363            break;
364        }
365        consumed = consumed.saturating_add(width);
366        char_index += 1;
367    }
368
369    if char_index >= chars.len() || chars[char_index].is_whitespace() {
370        return None;
371    }
372
373    let mut start = char_index;
374    while start > 0 && !chars[start - 1].is_whitespace() {
375        start -= 1;
376    }
377
378    let mut end = char_index + 1;
379    while end < chars.len() && !chars[end].is_whitespace() {
380        end += 1;
381    }
382
383    Some((
384        display_width_for_char_count(&chars, start),
385        display_width_for_char_count(&chars, end),
386    ))
387}
388
389fn display_width_for_char_count(chars: &[char], char_count: usize) -> u16 {
390    chars
391        .iter()
392        .take(char_count)
393        .map(|ch| UnicodeWidthChar::width(*ch).unwrap_or(0) as u16)
394        .fold(0_u16, u16::saturating_add)
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400    use ratatui::style::Color;
401    use std::time::{Duration, Instant};
402
403    #[test]
404    fn extract_text_clamps_area_to_buffer_bounds() {
405        let mut buf = Buffer::empty(Rect::new(0, 0, 2, 2));
406        buf[(0, 0)].set_symbol("A");
407        buf[(1, 0)].set_symbol("B");
408        buf[(0, 1)].set_symbol("C");
409        buf[(1, 1)].set_symbol("D");
410
411        let mut selection = MouseSelectionState::new();
412        selection.start_selection(0, 0);
413        selection.finish_selection(5, 5);
414
415        let text = selection.extract_text(&buf, Rect::new(0, 0, 10, 10));
416        assert_eq!(text, "AB\nCD");
417    }
418
419    #[test]
420    fn apply_highlight_clamps_area_to_buffer_bounds() {
421        let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
422        buf[(0, 0)].set_fg(Color::Red);
423        buf[(0, 0)].set_bg(Color::Blue);
424
425        let mut selection = MouseSelectionState::new();
426        selection.start_selection(0, 0);
427        selection.finish_selection(5, 5);
428
429        selection.apply_highlight(&mut buf, Rect::new(0, 0, 10, 10));
430
431        assert_eq!(buf[(0, 0)].fg, Color::Blue);
432        assert_eq!(buf[(0, 0)].bg, Color::Red);
433    }
434
435    #[test]
436    fn word_selection_range_selects_clicked_word() {
437        assert_eq!(word_selection_range("hello world", 1), Some((0, 5)));
438        assert_eq!(word_selection_range("hello world", 7), Some((6, 11)));
439    }
440
441    #[test]
442    fn word_selection_range_returns_none_for_whitespace() {
443        assert_eq!(word_selection_range("hello world", 5), None);
444    }
445
446    #[test]
447    fn register_click_detects_double_clicks_at_same_position() {
448        let mut selection = MouseSelectionState::new();
449        let now = Instant::now();
450
451        assert!(!selection.register_click(3, 7, now));
452        assert!(selection.register_click(3, 7, now + Duration::from_millis(250)));
453        assert!(!selection.register_click(4, 7, now + Duration::from_millis(250)));
454    }
455}