From c8e57e1612c2b5df70cd8ae3ca3c69665dd996a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Ram=C3=ADrez=20Norambuena?= Date: Sat, 4 Jul 2026 17:07:03 -0400 Subject: [PATCH 1/2] feat(input): select the word under the cursor on double-click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A left double-click (within ~400 ms and ~4 px of the previous press) selects the whole word under the pointer via a new motion::word_bounds helper, copies it to the clipboard, and returns to Insert mode — consistent with drag-selection. Word boundaries reuse the existing is_word logic (alphanumerics and underscore). --- CHANGELOG.md | 1 + src/app_event.rs | 28 +++++++++++++++++++++++++-- src/app_state.rs | 4 ++++ src/input/motion.rs | 43 ++++++++++++++++++++++++++++++++++++++++++ src/input/mouse_ops.rs | 29 ++++++++++++++++++++++++++++ 5 files changed, 103 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4e0c03..8e0f8f9 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 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..cf96638 100644 --- a/src/input/mouse_ops.rs +++ b/src/input/mouse_ops.rs @@ -102,6 +102,35 @@ impl App { } } + /// Select the word under the pixel (double-click): highlight it briefly, + /// copy it to the clipboard, and return to Insert mode (no persistent + /// selection), consistent with drag-selection. + 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; + }; + let bounds = { + let tab = self.tab(); + tab.panes.get(&pane_id).and_then(|entry| { + entry.pane.grid_read().map(|grid| { + crate::input::motion::word_bounds(&grid, entry.pane.scroll_offset, col, row) + }) + }) + }; + if let Some((start, end)) = bounds { + self.copy_selection_to_clipboard(start, row, end, row); + } + self.state.mode = InputMode::Insert; + 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, From e4d2e77c4c859de57ab2bb2ac64f631b69882a69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Ram=C3=ADrez=20Norambuena?= Date: Sun, 5 Jul 2026 13:21:47 -0400 Subject: [PATCH 2/2] feat(input): keep the double-clicked word highlighted Double-click now leaves the selected word highlighted in anchored Visual mode (in addition to copying it) so the user can see what was picked, instead of silently returning to Insert with no visible feedback. A blank cell selects nothing; Esc or a click returns to Insert. --- CHANGELOG.md | 2 +- src/input/mouse_ops.rs | 31 +++++++++++++++++++++++-------- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e0f8f9..eaf58bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Added -- double-click selects the word under the cursor and copies it to the clipboard +- 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/input/mouse_ops.rs b/src/input/mouse_ops.rs index cf96638..d69a8f1 100644 --- a/src/input/mouse_ops.rs +++ b/src/input/mouse_ops.rs @@ -102,9 +102,9 @@ impl App { } } - /// Select the word under the pixel (double-click): highlight it briefly, - /// copy it to the clipboard, and return to Insert mode (no persistent - /// selection), consistent with drag-selection. + /// 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; @@ -113,18 +113,33 @@ impl App { let Some((col, row)) = self.pixel_to_cell(pane_id, px, py) else { return; }; - let bounds = { + // 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().map(|grid| { - crate::input::motion::word_bounds(&grid, entry.pane.scroll_offset, col, row) + 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)) = bounds { + 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.mode = InputMode::Insert; self.state.mouse_selecting = false; if let Some(w) = &self.window { w.request_redraw();