From b85a6667e4af50ccdcb5fd92e17d63a17afdbd99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Ram=C3=ADrez=20Norambuena?= Date: Thu, 16 Jul 2026 17:50:09 -0400 Subject: [PATCH] feat(input): add Shift+Click to extend the current selection A plain left-press starts a fresh selection. When Shift is held and an existing Visual selection is present, keep its anchor (start_*) and move the cursor (cur_*) to the clicked cell so the selection grows from the original anchor to the click point; a following drag extends further and release copies. With no prior selection it falls back to a fresh one. --- CHANGELOG.md | 1 + src/app_event.rs | 5 +++- src/input/mouse_ops.rs | 49 +++++++++++++++++++++++++++++++ src/input/mouse_ops_test.rs | 58 +++++++++++++++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 src/input/mouse_ops_test.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index a35216a..d3c75d3 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 +- add Shift+Click to extend the current selection from its anchor - add `--maximized` and `--fullscreen` flags to start the window in that mode - persist and restore window size, maximized, and fullscreen state per session - REP (`CSI Ps b`): repeat the last printed character `Ps` times diff --git a/src/app_event.rs b/src/app_event.rs index cf483e7..d2b1425 100644 --- a/src/app_event.rs +++ b/src/app_event.rs @@ -727,7 +727,10 @@ impl App { match state { ElementState::Pressed => { if let Some((mx, my)) = self.state.mouse_pos { - if self.is_double_click(mx, my) { + let shift = self.modifiers.state().shift_key(); + if shift { + self.extend_mouse_selection(mx, my); + } else if self.is_double_click(mx, my) { self.select_word_at(mx, my); } else { self.start_mouse_selection(mx, my); diff --git a/src/input/mouse_ops.rs b/src/input/mouse_ops.rs index 05ea1fb..ac04a13 100644 --- a/src/input/mouse_ops.rs +++ b/src/input/mouse_ops.rs @@ -6,6 +6,30 @@ use crate::input::InputMode; use crate::App; +/// Given the current input mode and a clicked cell, produce the Visual mode that +/// extends an existing anchored selection to the click point: the anchor +/// (`start_*`) is preserved and the cursor (`cur_*`) jumps to the clicked cell. +/// Returns `None` when there is no Visual selection to extend, in which case the +/// caller should start a fresh selection instead. +pub(crate) fn extend_visual_mode(mode: &InputMode, col: usize, row: usize) -> Option { + if let InputMode::Visual { + start_col, + start_row, + .. + } = *mode + { + Some(InputMode::Visual { + start_col, + start_row, + cur_col: col, + cur_row: row, + anchored: true, + }) + } else { + None + } +} + pub(super) fn open_url(url: &str) { #[cfg(target_os = "linux")] { @@ -102,6 +126,27 @@ impl App { } } + /// Shift+left-press: if there is an existing anchored Visual selection, keep + /// its anchor (`start_*`) and move the cursor (`cur_*`) to the clicked cell so + /// the selection grows from the original anchor to the click point. A following + /// drag extends further and release copies. With no prior Visual selection this + /// behaves like a normal fresh selection from the click. + pub(crate) fn extend_mouse_selection(&mut self, px: f64, py: f64) { + let Some(new_mode) = self + .pane_at_pixel(px, py) + .and_then(|pane_id| self.pixel_to_cell(pane_id, px, py)) + .and_then(|(col, row)| extend_visual_mode(self.state.mode(), col, row)) + else { + self.start_mouse_selection(px, py); + return; + }; + self.state.tab_mut().mode = new_mode; + self.state.mouse_selecting = true; + if let Some(w) = &self.window { + w.request_redraw(); + } + } + /// 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. @@ -224,3 +269,7 @@ impl App { } } } + +#[cfg(test)] +#[path = "mouse_ops_test.rs"] +mod tests; diff --git a/src/input/mouse_ops_test.rs b/src/input/mouse_ops_test.rs new file mode 100644 index 0000000..1791a9e --- /dev/null +++ b/src/input/mouse_ops_test.rs @@ -0,0 +1,58 @@ +use super::extend_visual_mode; +use crate::input::InputMode; + +// ── Shift+Click extend-selection decision ──────────────────────────────────── + +#[test] +fn extend_from_anchored_visual_keeps_anchor_moves_cursor() { + let current = InputMode::Visual { + start_col: 3, + start_row: 1, + cur_col: 5, + cur_row: 2, + anchored: true, + }; + let extended = extend_visual_mode(¤t, 10, 7).expect("extends an existing selection"); + assert_eq!( + extended, + InputMode::Visual { + start_col: 3, + start_row: 1, + cur_col: 10, + cur_row: 7, + anchored: true, + }, + "anchor (start_*) is preserved and cursor (cur_*) jumps to the clicked cell" + ); +} + +#[test] +fn extend_from_unanchored_visual_still_grows_from_anchor() { + // Even a not-yet-anchored Visual selection carries a start; shift-click + // anchors it and extends the cursor to the click point. + let current = InputMode::Visual { + start_col: 0, + start_row: 0, + cur_col: 4, + cur_row: 0, + anchored: false, + }; + let extended = extend_visual_mode(¤t, 8, 3).expect("extends the visual selection"); + assert_eq!( + extended, + InputMode::Visual { + start_col: 0, + start_row: 0, + cur_col: 8, + cur_row: 3, + anchored: true, + } + ); +} + +#[test] +fn extend_with_no_selection_returns_none_for_fresh_start() { + // No prior Visual selection → caller should fall back to a fresh selection. + assert!(extend_visual_mode(&InputMode::Insert, 2, 2).is_none()); + assert!(extend_visual_mode(&InputMode::Normal, 2, 2).is_none()); +}