diff --git a/CHANGELOG.md b/CHANGELOG.md index b4e0c03..eaf58bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Added +- double-click selects and highlights the word under the cursor and copies it to the clipboard - activity indicator: tabs with output while not focused now show the activity dot in the tab bar; the marker clears as soon as the tab is focused - OSC 133 shell integration with prompt and exit-code status-bar indicators, gated by `general.shell_integration` - OSC 777 desktop notifications gated by `general.desktop_notifications` diff --git a/src/app_event.rs b/src/app_event.rs index 868c8f8..a0adf2a 100644 --- a/src/app_event.rs +++ b/src/app_event.rs @@ -1,3 +1,5 @@ +use std::time::{Duration, Instant}; + use arboard::Clipboard; use winit::event::{ElementState, MouseButton, MouseScrollDelta}; use winit::event_loop::ActiveEventLoop; @@ -471,7 +473,6 @@ impl App { event: winit::event::KeyEvent, event_loop: &ActiveEventLoop, ) { - use std::time::Instant; self.state.cursor_blink = true; self.state.blink_last = Instant::now(); @@ -703,11 +704,34 @@ impl App { } } + /// True when this left-press lands within ~400 ms and ~4 px of the previous + /// one (a double-click). Records the press position for the next comparison, + /// clearing it after a positive match so a third click is not treated as + /// another double-click. + fn is_double_click(&mut self, mx: f64, my: f64) -> bool { + let now = Instant::now(); + let is_double = self + .state + .last_click + .map(|(t, x, y)| { + now.duration_since(t) < Duration::from_millis(400) + && (x - mx).abs() < 4.0 + && (y - my).abs() < 4.0 + }) + .unwrap_or(false); + self.state.last_click = if is_double { None } else { Some((now, mx, my)) }; + is_double + } + fn handle_selection_click(&mut self, state: ElementState) { match state { ElementState::Pressed => { if let Some((mx, my)) = self.state.mouse_pos { - self.start_mouse_selection(mx, my); + if self.is_double_click(mx, my) { + self.select_word_at(mx, my); + } else { + self.start_mouse_selection(mx, my); + } } } ElementState::Released => { diff --git a/src/app_state.rs b/src/app_state.rs index 1e59810..0b21a5d 100644 --- a/src/app_state.rs +++ b/src/app_state.rs @@ -89,6 +89,9 @@ pub struct AppState { pub clipboard: Option, pub mouse_pos: Option<(f64, f64)>, pub mouse_selecting: bool, + /// Time and pixel position of the last left-button press, for double-click + /// detection (word selection). + pub last_click: Option<(Instant, f64, f64)>, pub search_matches: Vec<(usize, usize, usize)>, pub search_current: usize, pub search_history: Vec, @@ -118,6 +121,7 @@ impl AppState { clipboard: Clipboard::new().ok(), mouse_pos: None, mouse_selecting: false, + last_click: None, search_matches: Vec::new(), search_current: 0, search_history: Vec::new(), diff --git a/src/input/motion.rs b/src/input/motion.rs index d73bb01..a562136 100644 --- a/src/input/motion.rs +++ b/src/input/motion.rs @@ -149,6 +149,24 @@ pub fn word_end(grid: &Grid, scroll_offset: usize, col: usize, row: usize) -> (u (c, r) } +/// Inclusive column span `[start, end]` of the word under `(col, row)` in the +/// viewport, used for double-click word selection. If the character under the +/// cursor is not a word character, returns `(col, col)`. +pub fn word_bounds(grid: &Grid, scroll_offset: usize, col: usize, row: usize) -> (usize, usize) { + if !is_word(char_at(grid, scroll_offset, row, col)) { + return (col, col); + } + let mut start = col; + while start > 0 && is_word(char_at(grid, scroll_offset, row, start - 1)) { + start -= 1; + } + let mut end = col; + while end + 1 < grid.cols && is_word(char_at(grid, scroll_offset, row, end + 1)) { + end += 1; + } + (start, end) +} + #[cfg(test)] mod tests { use super::*; @@ -182,6 +200,31 @@ mod tests { assert_eq!(c, 6); // 'w' in "world" } + #[test] + fn word_bounds_spans_whole_word_from_middle() { + let g = make_grid("hello world"); + assert_eq!(word_bounds(&g, 0, 8, 0), (6, 10)); // inside "world" → whole word + } + + #[test] + fn word_bounds_spans_first_word_from_edge() { + let g = make_grid("hello world"); + assert_eq!(word_bounds(&g, 0, 0, 0), (0, 4)); // "hello" + assert_eq!(word_bounds(&g, 0, 4, 0), (0, 4)); // last char of "hello" + } + + #[test] + fn word_bounds_on_space_selects_single_cell() { + let g = make_grid("hello world"); + assert_eq!(word_bounds(&g, 0, 5, 0), (5, 5)); // the space + } + + #[test] + fn word_bounds_treats_underscore_as_word() { + let g = make_grid("foo_bar baz"); + assert_eq!(word_bounds(&g, 0, 3, 0), (0, 6)); // "foo_bar" + } + #[test] fn word_forward_from_space_skips_to_word() { let g = make_grid("hello world"); diff --git a/src/input/mouse_ops.rs b/src/input/mouse_ops.rs index b5f7a7a..d69a8f1 100644 --- a/src/input/mouse_ops.rs +++ b/src/input/mouse_ops.rs @@ -102,6 +102,50 @@ impl App { } } + /// Select the word under the pixel (double-click): leave it highlighted in + /// anchored Visual mode so the user sees what was picked, and copy it to the + /// clipboard. A blank cell selects nothing. `Esc`/click returns to Insert. + pub(crate) fn select_word_at(&mut self, px: f64, py: f64) { + let Some(pane_id) = self.pane_at_pixel(px, py) else { + return; + }; + self.tab_mut().active = pane_id; + let Some((col, row)) = self.pixel_to_cell(pane_id, px, py) else { + return; + }; + // Resolve the word span and its text in one grid read; a blank cell + // yields empty text and is treated as "nothing to select". + let selection = { + let tab = self.tab(); + tab.panes.get(&pane_id).and_then(|entry| { + entry.pane.grid_read().and_then(|grid| { + let (start, end) = crate::input::motion::word_bounds( + &grid, + entry.pane.scroll_offset, + col, + row, + ); + let text = grid.selected_text(start, row, end, row, entry.pane.scroll_offset); + (!text.is_empty()).then_some((start, end)) + }) + }) + }; + if let Some((start, end)) = selection { + self.state.mode = InputMode::Visual { + start_col: start, + start_row: row, + cur_col: end, + cur_row: row, + anchored: true, + }; + self.copy_selection_to_clipboard(start, row, end, row); + } + self.state.mouse_selecting = false; + if let Some(w) = &self.window { + w.request_redraw(); + } + } + pub(crate) fn update_mouse_selection(&mut self, px: f64, py: f64) { if let InputMode::Visual { start_col,