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
6/// Tracks mouse-driven text selection state for the TUI transcript.
7#[derive(Debug, Default)]
8pub struct MouseSelectionState {
9    /// Whether the user is currently dragging to select text.
10    pub is_selecting: bool,
11    /// Screen coordinates where the selection started (column, row).
12    pub start: (u16, u16),
13    /// Screen coordinates where the selection currently ends (column, row).
14    pub end: (u16, u16),
15    /// Whether a completed selection exists (ready for highlight rendering).
16    pub has_selection: bool,
17    /// Whether the current selection has already been copied to clipboard.
18    copied: bool,
19}
20
21impl MouseSelectionState {
22    pub fn new() -> Self {
23        Self::default()
24    }
25
26    /// Begin a new selection at the given screen position.
27    pub fn start_selection(&mut self, col: u16, row: u16) {
28        self.is_selecting = true;
29        self.has_selection = false;
30        self.copied = false;
31        self.start = (col, row);
32        self.end = (col, row);
33    }
34
35    /// Update the end position while dragging.
36    pub fn update_selection(&mut self, col: u16, row: u16) {
37        if self.is_selecting {
38            self.end = (col, row);
39            self.has_selection = true;
40        }
41    }
42
43    /// Finalize the selection on mouse-up.
44    pub fn finish_selection(&mut self, col: u16, row: u16) {
45        if self.is_selecting {
46            self.end = (col, row);
47            self.is_selecting = false;
48            // Only mark as having a selection if start != end
49            self.has_selection = self.start != self.end;
50        }
51    }
52
53    /// Clear any active selection.
54    #[allow(dead_code)]
55    pub fn clear(&mut self) {
56        self.is_selecting = false;
57        self.has_selection = false;
58        self.copied = false;
59    }
60
61    /// Returns the selection range normalized so that `from` is before `to`.
62    fn normalized(&self) -> ((u16, u16), (u16, u16)) {
63        let (s, e) = (self.start, self.end);
64        if s.1 < e.1 || (s.1 == e.1 && s.0 <= e.0) {
65            (s, e)
66        } else {
67            (e, s)
68        }
69    }
70
71    /// Extract selected text from a ratatui `Buffer`.
72    pub fn extract_text(&self, buf: &Buffer, area: Rect) -> String {
73        if !self.has_selection && !self.is_selecting {
74            return String::new();
75        }
76
77        // Clamp to the actual buffer area to avoid out-of-range buffer indexing panics.
78        let area = area.intersection(buf.area);
79        if area.width == 0 || area.height == 0 {
80            return String::new();
81        }
82
83        let ((start_col, start_row), (end_col, end_row)) = self.normalized();
84        let mut result = String::new();
85
86        for row in start_row..=end_row {
87            if row < area.y || row >= area.bottom() {
88                continue;
89            }
90            let line_start = if row == start_row {
91                start_col.max(area.x)
92            } else {
93                area.x
94            };
95            let line_end = if row == end_row {
96                end_col.min(area.right())
97            } else {
98                area.right()
99            };
100
101            for col in line_start..line_end {
102                if col < area.x || col >= area.right() {
103                    continue;
104                }
105                let cell = &buf[(col, row)];
106                let symbol = cell.symbol();
107                if !symbol.is_empty() {
108                    result.push_str(symbol);
109                }
110            }
111
112            // Add newline between rows (but not after the last)
113            if row < end_row {
114                // Trim trailing whitespace from each line
115                let trimmed = result.trim_end().len();
116                result.truncate(trimmed);
117                result.push('\n');
118            }
119        }
120
121        // Trim trailing whitespace from the final line
122        let trimmed = result.trim_end();
123        trimmed.to_string()
124    }
125
126    /// Apply selection highlight (inverted colors) to the frame buffer.
127    pub fn apply_highlight(&self, buf: &mut Buffer, area: Rect) {
128        if !self.has_selection && !self.is_selecting {
129            return;
130        }
131
132        // Clamp to the actual buffer area to avoid out-of-range buffer indexing panics.
133        let area = area.intersection(buf.area);
134        if area.width == 0 || area.height == 0 {
135            return;
136        }
137
138        let ((start_col, start_row), (end_col, end_row)) = self.normalized();
139
140        for row in start_row..=end_row {
141            if row < area.y || row >= area.bottom() {
142                continue;
143            }
144            let line_start = if row == start_row {
145                start_col.max(area.x)
146            } else {
147                area.x
148            };
149            let line_end = if row == end_row {
150                end_col.min(area.right())
151            } else {
152                area.right()
153            };
154
155            for col in line_start..line_end {
156                if col < area.x || col >= area.right() {
157                    continue;
158                }
159                let cell = &mut buf[(col, row)];
160                // Swap foreground and background to show selection
161                let fg = cell.fg;
162                let bg = cell.bg;
163                cell.set_fg(bg);
164                cell.set_bg(fg);
165            }
166        }
167    }
168
169    /// Returns `true` if the selection needs to be copied (finalized and not yet copied).
170    pub fn needs_copy(&self) -> bool {
171        self.has_selection && !self.is_selecting && !self.copied
172    }
173
174    /// Mark the selection as already copied.
175    pub fn mark_copied(&mut self) {
176        self.copied = true;
177    }
178
179    /// Copy the selected text to the system clipboard.
180    ///
181    /// Tries native OS clipboard utilities first (`pbcopy` on macOS, `xclip`/`xsel`
182    /// on Linux, `clip.exe` on Windows/WSL) for maximum compatibility, then falls
183    /// back to the OSC 52 escape sequence.
184    pub fn copy_to_clipboard(text: &str) {
185        if text.is_empty() {
186            return;
187        }
188
189        if Self::copy_via_native(text) {
190            return;
191        }
192
193        // Fallback: OSC 52 escape sequence
194        let _ = execute!(
195            std::io::stderr(),
196            CopyToClipboard::to_clipboard_from(text.as_bytes())
197        );
198        let _ = std::io::stderr().flush();
199    }
200
201    /// Attempt to copy text using native OS clipboard utilities.
202    /// Returns `true` if successful.
203    fn copy_via_native(text: &str) -> bool {
204        use std::process::{Command, Stdio};
205
206        let candidates: &[&str] = if cfg!(target_os = "macos") {
207            &["pbcopy"]
208        } else if cfg!(target_os = "linux") {
209            &["xclip", "xsel"]
210        } else if cfg!(target_os = "windows") {
211            &["clip.exe"]
212        } else {
213            &[]
214        };
215
216        for program in candidates {
217            let mut cmd = Command::new(program);
218            match *program {
219                "xclip" => {
220                    cmd.arg("-selection").arg("clipboard");
221                }
222                "xsel" => {
223                    cmd.arg("--clipboard").arg("--input");
224                }
225                _ => {}
226            }
227            let Ok(mut child) = cmd
228                .stdin(Stdio::piped())
229                .stdout(Stdio::null())
230                .stderr(Stdio::null())
231                .spawn()
232            else {
233                continue;
234            };
235            if let Some(stdin) = child.stdin.as_mut() {
236                let _ = stdin.write_all(text.as_bytes());
237            }
238            drop(child.stdin.take());
239            if child.wait().is_ok() {
240                return true;
241            }
242        }
243        false
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use ratatui::style::Color;
251
252    #[test]
253    fn extract_text_clamps_area_to_buffer_bounds() {
254        let mut buf = Buffer::empty(Rect::new(0, 0, 2, 2));
255        buf[(0, 0)].set_symbol("A");
256        buf[(1, 0)].set_symbol("B");
257        buf[(0, 1)].set_symbol("C");
258        buf[(1, 1)].set_symbol("D");
259
260        let mut selection = MouseSelectionState::new();
261        selection.start_selection(0, 0);
262        selection.finish_selection(5, 5);
263
264        let text = selection.extract_text(&buf, Rect::new(0, 0, 10, 10));
265        assert_eq!(text, "AB\nCD");
266    }
267
268    #[test]
269    fn apply_highlight_clamps_area_to_buffer_bounds() {
270        let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
271        buf[(0, 0)].set_fg(Color::Red);
272        buf[(0, 0)].set_bg(Color::Blue);
273
274        let mut selection = MouseSelectionState::new();
275        selection.start_selection(0, 0);
276        selection.finish_selection(5, 5);
277
278        selection.apply_highlight(&mut buf, Rect::new(0, 0, 10, 10));
279
280        assert_eq!(buf[(0, 0)].fg, Color::Blue);
281        assert_eq!(buf[(0, 0)].bg, Color::Red);
282    }
283}