vtcode_tui/core_tui/session/
mouse_selection.rs1use ratatui::buffer::Buffer;
2use ratatui::crossterm::{clipboard::CopyToClipboard, execute};
3use ratatui::layout::Rect;
4use std::io::Write;
5
6#[derive(Debug, Default)]
8pub struct MouseSelectionState {
9 pub is_selecting: bool,
11 pub start: (u16, u16),
13 pub end: (u16, u16),
15 pub has_selection: bool,
17 copied: bool,
19}
20
21impl MouseSelectionState {
22 pub fn new() -> Self {
23 Self::default()
24 }
25
26 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 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 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 self.has_selection = self.start != self.end;
50 }
51 }
52
53 #[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 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 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 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 if row < end_row {
114 let trimmed = result.trim_end().len();
116 result.truncate(trimmed);
117 result.push('\n');
118 }
119 }
120
121 let trimmed = result.trim_end();
123 trimmed.to_string()
124 }
125
126 pub fn apply_highlight(&self, buf: &mut Buffer, area: Rect) {
128 if !self.has_selection && !self.is_selecting {
129 return;
130 }
131
132 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 let fg = cell.fg;
162 let bg = cell.bg;
163 cell.set_fg(bg);
164 cell.set_bg(fg);
165 }
166 }
167 }
168
169 pub fn needs_copy(&self) -> bool {
171 self.has_selection && !self.is_selecting && !self.copied
172 }
173
174 pub fn mark_copied(&mut self) {
176 self.copied = true;
177 }
178
179 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 let _ = execute!(
195 std::io::stderr(),
196 CopyToClipboard::to_clipboard_from(text.as_bytes())
197 );
198 let _ = std::io::stderr().flush();
199 }
200
201 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}