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 pub fn adjust_for_scroll(&mut self, row_delta: i32) {
88 if !self.has_selection && !self.is_selecting {
89 return;
90 }
91 if row_delta == 0 {
92 return;
93 }
94
95 let new_start_row = self.start.1 as i32 + row_delta;
96 let new_end_row = self.end.1 as i32 + row_delta;
97
98 let clamped_start = new_start_row.clamp(0, i32::from(u16::MAX));
104 let clamped_end = new_end_row.clamp(0, i32::from(u16::MAX));
105
106 if (new_start_row < 0 && new_end_row < 0)
109 || (new_start_row > i32::from(u16::MAX) && new_end_row > i32::from(u16::MAX))
110 {
111 self.is_selecting = false;
112 self.has_selection = false;
113 self.copied = false;
114 self.copy_requested = false;
115 return;
116 }
117
118 self.start.1 = clamped_start as u16;
119 self.end.1 = clamped_end as u16;
120 }
121
122 #[expect(dead_code)]
124 pub fn clear(&mut self) {
125 self.is_selecting = false;
126 self.has_selection = false;
127 self.copied = false;
128 self.copy_requested = false;
129 self.last_click = None;
130 }
131
132 pub fn clear_click_history(&mut self) {
134 self.last_click = None;
135 }
136
137 pub fn register_click(&mut self, col: u16, row: u16, at: Instant) -> bool {
140 let is_double_click = self.last_click.is_some_and(|last| {
141 last.column == col
142 && last.row == row
143 && at.saturating_duration_since(last.at) <= DOUBLE_CLICK_INTERVAL
144 });
145
146 self.last_click = Some(ClickRecord {
147 column: col,
148 row,
149 at,
150 });
151 is_double_click
152 }
153
154 fn normalized(&self) -> ((u16, u16), (u16, u16)) {
156 let (s, e) = (self.start, self.end);
157 if s.1 < e.1 || (s.1 == e.1 && s.0 <= e.0) {
158 (s, e)
159 } else {
160 (e, s)
161 }
162 }
163
164 pub fn extract_text(&self, buf: &Buffer, area: Rect) -> String {
166 if !self.has_selection && !self.is_selecting {
167 return String::new();
168 }
169
170 let area = area.intersection(buf.area);
172 if area.width == 0 || area.height == 0 {
173 return String::new();
174 }
175
176 let ((start_col, start_row), (end_col, end_row)) = self.normalized();
177 let mut result = String::new();
178
179 for row in start_row..=end_row {
180 if row < area.y || row >= area.bottom() {
181 continue;
182 }
183 let line_start = if row == start_row {
184 start_col.max(area.x)
185 } else {
186 area.x
187 };
188 let line_end = if row == end_row {
189 end_col.min(area.right())
190 } else {
191 area.right()
192 };
193
194 for col in line_start..line_end {
195 if col < area.x || col >= area.right() {
196 continue;
197 }
198 let cell = &buf[(col, row)];
199 let symbol = cell.symbol();
200 if !symbol.is_empty() {
201 result.push_str(symbol);
202 }
203 }
204
205 if row < end_row {
207 let trimmed = result.trim_end().len();
209 result.truncate(trimmed);
210 result.push('\n');
211 }
212 }
213
214 let trimmed = result.trim_end();
216 trimmed.to_string()
217 }
218
219 pub fn apply_highlight(&self, buf: &mut Buffer, area: Rect) {
221 if !self.has_selection && !self.is_selecting {
222 return;
223 }
224
225 let area = area.intersection(buf.area);
227 if area.width == 0 || area.height == 0 {
228 return;
229 }
230
231 let ((start_col, start_row), (end_col, end_row)) = self.normalized();
232
233 for row in start_row..=end_row {
234 if row < area.y || row >= area.bottom() {
235 continue;
236 }
237 let line_start = if row == start_row {
238 start_col.max(area.x)
239 } else {
240 area.x
241 };
242 let line_end = if row == end_row {
243 end_col.min(area.right())
244 } else {
245 area.right()
246 };
247
248 for col in line_start..line_end {
249 if col < area.x || col >= area.right() {
250 continue;
251 }
252 let cell = &mut buf[(col, row)];
253 let fg = cell.fg;
255 let bg = cell.bg;
256 cell.set_fg(bg);
257 cell.set_bg(fg);
258 }
259 }
260 }
261
262 pub fn needs_copy(&self) -> bool {
264 self.has_selection && !self.is_selecting && !self.copied
265 }
266
267 pub fn has_copy_request(&self) -> bool {
269 self.copy_requested
270 }
271
272 pub fn request_copy(&mut self) {
274 if self.has_selection {
275 self.copy_requested = true;
276 }
277 }
278
279 pub fn mark_copied(&mut self) {
281 self.copied = true;
282 }
283
284 pub fn copy_to_clipboard(text: &str) {
290 if text.is_empty() {
291 return;
292 }
293
294 if Self::copy_via_native(text) {
295 return;
296 }
297
298 let _ = execute!(
300 std::io::stderr(),
301 CopyToClipboard::to_clipboard_from(text.as_bytes())
302 );
303 let _ = std::io::stderr().flush();
304 }
305
306 fn copy_via_native(text: &str) -> bool {
309 use std::process::Command;
310
311 #[cfg(test)]
312 if let Some(program) = clipboard_command_override() {
313 return spawn_clipboard_command(Command::new(program), text);
314 }
315
316 let candidates: &[&str] = if cfg!(target_os = "macos") {
317 &["pbcopy"]
318 } else if cfg!(target_os = "linux") {
319 &["xclip", "xsel"]
320 } else if cfg!(target_os = "windows") {
321 &["clip.exe"]
322 } else {
323 &[]
324 };
325
326 for program in candidates {
327 let mut cmd = Command::new(program);
328 match *program {
329 "xclip" => {
330 cmd.arg("-selection").arg("clipboard");
331 }
332 "xsel" => {
333 cmd.arg("--clipboard").arg("--input");
334 }
335 _ => {}
336 }
337 if spawn_clipboard_command(cmd, text) {
338 return true;
339 }
340 }
341 false
342 }
343}
344
345fn spawn_clipboard_command(mut cmd: std::process::Command, text: &str) -> bool {
346 use std::process::Stdio;
347
348 let Ok(mut child) = cmd
349 .stdin(Stdio::piped())
350 .stdout(Stdio::null())
351 .stderr(Stdio::null())
352 .spawn()
353 else {
354 return false;
355 };
356 if let Some(stdin) = child.stdin.as_mut() {
357 let _ = stdin.write_all(text.as_bytes());
358 }
359 drop(child.stdin.take());
360 child.wait().is_ok()
361}
362
363#[cfg(test)]
364static CLIPBOARD_COMMAND_OVERRIDE: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
365
366#[cfg(test)]
367pub(crate) fn set_clipboard_command_override(path: Option<PathBuf>) {
368 let lock = CLIPBOARD_COMMAND_OVERRIDE.get_or_init(|| Mutex::new(None));
369 if let Ok(mut guard) = lock.lock() {
370 *guard = path;
371 }
372}
373
374#[cfg(test)]
375pub(crate) fn clipboard_command_override() -> Option<PathBuf> {
376 let lock = CLIPBOARD_COMMAND_OVERRIDE.get_or_init(|| Mutex::new(None));
377 match lock.lock() {
378 Ok(guard) => guard.clone(),
379 Err(_) => None,
380 }
381}
382
383pub(crate) fn word_selection_range(text: &str, column: u16) -> Option<(u16, u16)> {
385 if text.is_empty() {
386 return None;
387 }
388
389 let chars: Vec<char> = text.chars().collect();
390 if chars.is_empty() {
391 return None;
392 }
393
394 let line_width = UnicodeWidthStr::width(text);
395 if usize::from(column) >= line_width {
396 return None;
397 }
398
399 let mut consumed = 0usize;
400 let mut char_index = 0usize;
401 for ch in &chars {
402 let width = UnicodeWidthChar::width(*ch).unwrap_or(0);
403 if consumed.saturating_add(width) > usize::from(column) {
404 break;
405 }
406 consumed = consumed.saturating_add(width);
407 char_index += 1;
408 }
409
410 if char_index >= chars.len() || chars[char_index].is_whitespace() {
411 return None;
412 }
413
414 let mut start = char_index;
415 while start > 0 && !chars[start - 1].is_whitespace() {
416 start -= 1;
417 }
418
419 let mut end = char_index + 1;
420 while end < chars.len() && !chars[end].is_whitespace() {
421 end += 1;
422 }
423
424 Some((
425 display_width_for_char_count(&chars, start),
426 display_width_for_char_count(&chars, end),
427 ))
428}
429
430fn display_width_for_char_count(chars: &[char], char_count: usize) -> u16 {
431 chars
432 .iter()
433 .take(char_count)
434 .map(|ch| UnicodeWidthChar::width(*ch).unwrap_or(0) as u16)
435 .fold(0_u16, u16::saturating_add)
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441 use ratatui::style::Color;
442 use std::time::{Duration, Instant};
443
444 #[test]
445 fn extract_text_clamps_area_to_buffer_bounds() {
446 let mut buf = Buffer::empty(Rect::new(0, 0, 2, 2));
447 buf[(0, 0)].set_symbol("A");
448 buf[(1, 0)].set_symbol("B");
449 buf[(0, 1)].set_symbol("C");
450 buf[(1, 1)].set_symbol("D");
451
452 let mut selection = MouseSelectionState::new();
453 selection.start_selection(0, 0);
454 selection.finish_selection(5, 5);
455
456 let text = selection.extract_text(&buf, Rect::new(0, 0, 10, 10));
457 assert_eq!(text, "AB\nCD");
458 }
459
460 #[test]
461 fn apply_highlight_clamps_area_to_buffer_bounds() {
462 let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
463 buf[(0, 0)].set_fg(Color::Red);
464 buf[(0, 0)].set_bg(Color::Blue);
465
466 let mut selection = MouseSelectionState::new();
467 selection.start_selection(0, 0);
468 selection.finish_selection(5, 5);
469
470 selection.apply_highlight(&mut buf, Rect::new(0, 0, 10, 10));
471
472 assert_eq!(buf[(0, 0)].fg, Color::Blue);
473 assert_eq!(buf[(0, 0)].bg, Color::Red);
474 }
475
476 #[test]
477 fn word_selection_range_selects_clicked_word() {
478 assert_eq!(word_selection_range("hello world", 1), Some((0, 5)));
479 assert_eq!(word_selection_range("hello world", 7), Some((6, 11)));
480 }
481
482 #[test]
483 fn word_selection_range_returns_none_for_whitespace() {
484 assert_eq!(word_selection_range("hello world", 5), None);
485 }
486
487 #[test]
488 fn adjust_for_scroll_shifts_rows() {
489 let mut sel = MouseSelectionState::new();
490 sel.set_selection((2, 5), (10, 8));
491
492 sel.adjust_for_scroll(3);
493 assert_eq!(sel.start, (2, 8));
494 assert_eq!(sel.end, (10, 11));
495 assert!(sel.has_selection);
496 }
497
498 #[test]
499 fn adjust_for_scroll_negative() {
500 let mut sel = MouseSelectionState::new();
501 sel.set_selection((0, 10), (5, 15));
502
503 sel.adjust_for_scroll(-4);
504 assert_eq!(sel.start, (0, 6));
505 assert_eq!(sel.end, (5, 11));
506 }
507
508 #[test]
509 fn adjust_for_scroll_clears_when_offscreen() {
510 let mut sel = MouseSelectionState::new();
511 sel.set_selection((0, 2), (5, 4));
512
513 sel.adjust_for_scroll(-10);
514 assert!(!sel.has_selection);
515 assert!(!sel.is_selecting);
516 }
517
518 #[test]
519 fn adjust_for_scroll_noop_without_selection() {
520 let mut sel = MouseSelectionState::new();
521 sel.adjust_for_scroll(5);
522 assert!(!sel.has_selection);
523 }
524
525 #[test]
526 fn register_click_detects_double_clicks_at_same_position() {
527 let mut selection = MouseSelectionState::new();
528 let now = Instant::now();
529
530 assert!(!selection.register_click(3, 7, now));
531 assert!(selection.register_click(3, 7, now + Duration::from_millis(250)));
532 assert!(!selection.register_click(4, 7, now + Duration::from_millis(250)));
533 }
534}