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#[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#[derive(Debug, Default)]
16pub struct MouseSelectionState {
17 pub is_selecting: bool,
19 pub start: (u16, u16),
21 pub end: (u16, u16),
23 pub has_selection: bool,
25 copied: bool,
27 copy_requested: bool,
29 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 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 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 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 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 self.has_selection = self.start != self.end;
78 }
79 }
80
81 #[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 pub fn clear_click_history(&mut self) {
93 self.last_click = None;
94 }
95
96 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 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 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 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 if row < end_row {
166 let trimmed = result.trim_end().len();
168 result.truncate(trimmed);
169 result.push('\n');
170 }
171 }
172
173 let trimmed = result.trim_end();
175 trimmed.to_string()
176 }
177
178 pub fn apply_highlight(&self, buf: &mut Buffer, area: Rect) {
180 if !self.has_selection && !self.is_selecting {
181 return;
182 }
183
184 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 let fg = cell.fg;
214 let bg = cell.bg;
215 cell.set_fg(bg);
216 cell.set_bg(fg);
217 }
218 }
219 }
220
221 pub fn needs_copy(&self) -> bool {
223 self.has_selection && !self.is_selecting && !self.copied
224 }
225
226 pub fn has_copy_request(&self) -> bool {
228 self.copy_requested
229 }
230
231 pub fn request_copy(&mut self) {
233 if self.has_selection {
234 self.copy_requested = true;
235 }
236 }
237
238 pub fn mark_copied(&mut self) {
240 self.copied = true;
241 }
242
243 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 let _ = execute!(
259 std::io::stderr(),
260 CopyToClipboard::to_clipboard_from(text.as_bytes())
261 );
262 let _ = std::io::stderr().flush();
263 }
264
265 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
342pub(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}