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    }
59
60    /// Returns the selection range normalized so that `from` is before `to`.
61    fn normalized(&self) -> ((u16, u16), (u16, u16)) {
62        let (s, e) = (self.start, self.end);
63        if s.1 < e.1 || (s.1 == e.1 && s.0 <= e.0) {
64            (s, e)
65        } else {
66            (e, s)
67        }
68    }
69
70    /// Extract selected text from a ratatui `Buffer`.
71    pub fn extract_text(&self, buf: &Buffer, area: Rect) -> String {
72        if !self.has_selection && !self.is_selecting {
73            return String::new();
74        }
75
76        // Clamp to the actual buffer area to avoid out-of-range buffer indexing panics.
77        let area = area.intersection(buf.area);
78        if area.width == 0 || area.height == 0 {
79            return String::new();
80        }
81
82        let ((start_col, start_row), (end_col, end_row)) = self.normalized();
83        let mut result = String::new();
84
85        for row in start_row..=end_row {
86            if row < area.y || row >= area.bottom() {
87                continue;
88            }
89            let line_start = if row == start_row {
90                start_col.max(area.x)
91            } else {
92                area.x
93            };
94            let line_end = if row == end_row {
95                end_col.min(area.right())
96            } else {
97                area.right()
98            };
99
100            for col in line_start..line_end {
101                if col < area.x || col >= area.right() {
102                    continue;
103                }
104                let cell = &buf[(col, row)];
105                let symbol = cell.symbol();
106                if !symbol.is_empty() {
107                    result.push_str(symbol);
108                }
109            }
110
111            // Add newline between rows (but not after the last)
112            if row < end_row {
113                // Trim trailing whitespace from each line
114                let trimmed = result.trim_end().len();
115                result.truncate(trimmed);
116                result.push('\n');
117            }
118        }
119
120        // Trim trailing whitespace from the final line
121        let trimmed = result.trim_end();
122        trimmed.to_string()
123    }
124
125    /// Apply selection highlight (inverted colors) to the frame buffer.
126    pub fn apply_highlight(&self, buf: &mut Buffer, area: Rect) {
127        if !self.has_selection && !self.is_selecting {
128            return;
129        }
130
131        // Clamp to the actual buffer area to avoid out-of-range buffer indexing panics.
132        let area = area.intersection(buf.area);
133        if area.width == 0 || area.height == 0 {
134            return;
135        }
136
137        let ((start_col, start_row), (end_col, end_row)) = self.normalized();
138
139        for row in start_row..=end_row {
140            if row < area.y || row >= area.bottom() {
141                continue;
142            }
143            let line_start = if row == start_row {
144                start_col.max(area.x)
145            } else {
146                area.x
147            };
148            let line_end = if row == end_row {
149                end_col.min(area.right())
150            } else {
151                area.right()
152            };
153
154            for col in line_start..line_end {
155                if col < area.x || col >= area.right() {
156                    continue;
157                }
158                let cell = &mut buf[(col, row)];
159                // Swap foreground and background to show selection
160                let fg = cell.fg;
161                let bg = cell.bg;
162                cell.set_fg(bg);
163                cell.set_bg(fg);
164            }
165        }
166    }
167
168    /// Returns `true` if the selection needs to be copied (finalized and not yet copied).
169    pub fn needs_copy(&self) -> bool {
170        self.has_selection && !self.is_selecting && !self.copied
171    }
172
173    /// Mark the selection as already copied.
174    pub fn mark_copied(&mut self) {
175        self.copied = true;
176    }
177
178    /// Copy the selected text to the system clipboard using crossterm clipboard commands.
179    pub fn copy_to_clipboard(text: &str) {
180        if text.is_empty() {
181            return;
182        }
183        let _ = execute!(
184            std::io::stderr(),
185            CopyToClipboard::to_clipboard_from(text.as_bytes())
186        );
187        let _ = std::io::stderr().flush();
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use ratatui::style::Color;
195
196    #[test]
197    fn extract_text_clamps_area_to_buffer_bounds() {
198        let mut buf = Buffer::empty(Rect::new(0, 0, 2, 2));
199        buf[(0, 0)].set_symbol("A");
200        buf[(1, 0)].set_symbol("B");
201        buf[(0, 1)].set_symbol("C");
202        buf[(1, 1)].set_symbol("D");
203
204        let mut selection = MouseSelectionState::new();
205        selection.start_selection(0, 0);
206        selection.finish_selection(5, 5);
207
208        let text = selection.extract_text(&buf, Rect::new(0, 0, 10, 10));
209        assert_eq!(text, "AB\nCD");
210    }
211
212    #[test]
213    fn apply_highlight_clamps_area_to_buffer_bounds() {
214        let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
215        buf[(0, 0)].set_fg(Color::Red);
216        buf[(0, 0)].set_bg(Color::Blue);
217
218        let mut selection = MouseSelectionState::new();
219        selection.start_selection(0, 0);
220        selection.finish_selection(5, 5);
221
222        selection.apply_highlight(&mut buf, Rect::new(0, 0, 10, 10));
223
224        assert_eq!(buf[(0, 0)].fg, Color::Blue);
225        assert_eq!(buf[(0, 0)].bg, Color::Red);
226    }
227}