aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorakbash <akbash@herdr.dev>2026-07-16 21:57:20 +0300
committerGitHub <noreply@github.com>2026-07-16 21:57:20 +0300
commitf43a6d4a5591a0286e01de737be7b3f42416898b (patch)
tree9245146f3cc399bc6e8477d43c5de5cf3d3f1a42 /src
parent616d1dd8125ece4a9102b231864cb4fc4b761497 (diff)
fix: preserve selection when auto-copy is disabled (#1496)
refs #1471 Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com>
Diffstat (limited to 'src')
-rw-r--r--src/app/input/mod.rs14
-rw-r--r--src/app/input/mouse.rs24
-rw-r--r--src/app/input/terminal.rs101
-rw-r--r--src/app/mod.rs23
-rw-r--r--src/main.rs4
-rw-r--r--src/selection.rs12
6 files changed, 134 insertions, 44 deletions
diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs
index 21e9a49b..73ddbefd 100644
--- a/src/app/input/mod.rs
+++ b/src/app/input/mod.rs
@@ -361,6 +361,15 @@ impl App {
}
}
}
+ if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))
+ && self
+ .state
+ .selection
+ .as_ref()
+ .is_none_or(crate::selection::Selection::is_in_progress)
+ {
+ self.selection_highlight_clear_deadline = None;
+ }
}
if previous_settings_section != crate::app::state::SettingsSection::Integrations
&& self.state.settings.section == crate::app::state::SettingsSection::Integrations
@@ -482,11 +491,6 @@ impl App {
}
fn handle_pane_double_click(&mut self, mouse: MouseEvent) -> bool {
- if !self.state.copy_on_select {
- self.last_pane_click = None;
- return false;
- }
-
// A pane press stops being a double-click candidate once it becomes
// a drag or completes as a real text selection.
match mouse.kind {
diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs
index 80a7be6d..6cbce83f 100644
--- a/src/app/input/mouse.rs
+++ b/src/app/input/mouse.rs
@@ -640,14 +640,12 @@ impl AppState {
mouse.row - info.inner_rect.y,
mouse.column - info.inner_rect.x,
);
- if self.copy_on_select {
- self.selection = Some(Selection::anchor(
- info.id,
- row,
- col,
- self.pane_scroll_metrics(terminal_runtimes, info.id),
- ));
- }
+ self.selection = Some(Selection::anchor(
+ info.id,
+ row,
+ col,
+ self.pane_scroll_metrics(terminal_runtimes, info.id),
+ ));
if let Some(ws_idx) = self.active {
return Some(MouseAction::FocusPane {
ws_idx,
@@ -814,10 +812,10 @@ impl AppState {
MouseEventKind::Up(MouseButton::Left) => {
// Mouse-up either finishes a drag selection or releases after a
- // double-click copy; the latter is already copied.
+ // double-click copy; the latter is already finalized.
if let Some(selection) = self.selection.as_ref() {
let was_click = selection.was_just_click();
- let was_already_copied = selection.is_done();
+ let was_finalized = selection.is_finalized();
self.workspace_press = None;
self.tab_press = None;
@@ -825,8 +823,12 @@ impl AppState {
self.selection_autoscroll = None;
if was_click {
self.selection = None;
- } else if !was_already_copied {
+ } else if was_finalized {
+ // Double-click copy already finalized this selection.
+ } else if self.copy_on_select {
self.copy_selection(terminal_runtimes);
+ } else if let Some(selection) = self.selection.as_mut() {
+ selection.finish();
}
return None;
}
diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs
index e8e6495e..587c0e22 100644
--- a/src/app/input/terminal.rs
+++ b/src/app/input/terminal.rs
@@ -548,7 +548,7 @@ mod tests {
}
#[tokio::test]
- async fn copy_on_select_disabled_ignores_drag_and_double_click_selection() {
+ async fn copy_on_select_disabled_keeps_drag_selection_without_copying() {
let (mut app, info) = app_with_screen_bytes(b"alpha beta");
app.state.copy_on_select = false;
let row = info.inner_rect.y;
@@ -561,21 +561,112 @@ mod tests {
row,
));
app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), end_col, row));
+ assert_visible_selection(&app);
+ assert!(!app
+ .state
+ .selection
+ .as_ref()
+ .is_some_and(crate::selection::Selection::is_finalized));
+
app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), end_col, row));
- assert!(app.state.selection.is_none());
+ assert_visible_selection(&app);
+ assert_eq!(
+ app.state
+ .selection
+ .as_ref()
+ .map(crate::selection::Selection::ordered_cells),
+ Some(((0, 0), (0, 4)))
+ );
+ assert!(app
+ .state
+ .selection
+ .as_ref()
+ .is_some_and(crate::selection::Selection::is_finalized));
assert!(app.state.selection_autoscroll.is_none());
+ assert!(app.selection_autoscroll_deadline.is_none());
+ assert!(app.selection_highlight_clear_deadline.is_none());
assert!(app.event_rx.try_recv().is_err());
+ }
- double_click(&mut app, start_col, row);
+ #[tokio::test]
+ async fn copy_on_select_disabled_keeps_explicit_double_click_copy() {
+ let (mut app, info) = app_with_screen_bytes(b"alpha beta");
+ app.state.copy_on_select = false;
+ let col = info.inner_rect.x + 2;
+ let row = info.inner_rect.y;
- assert!(app.state.selection.is_none());
- assert!(app.last_pane_click.is_none());
+ double_click(&mut app, col, row);
+
+ assert_eq!(clipboard_write_content(&mut app), b"alpha");
+ assert_visible_selection(&app);
+ assert!(app.selection_highlight_clear_deadline.is_some());
+ assert!(app.event_rx.try_recv().is_err());
+ }
+
+ #[tokio::test]
+ async fn new_drag_cancels_stale_double_click_highlight_deadline() {
+ let (mut app, info) = app_with_screen_bytes(b"alpha beta");
+ app.state.copy_on_select = false;
+ let row = info.inner_rect.y;
+ let word_col = info.inner_rect.x + 2;
+
+ double_click(&mut app, word_col, row);
+ assert_eq!(clipboard_write_content(&mut app), b"alpha");
+ let stale_deadline = app
+ .selection_highlight_clear_deadline
+ .expect("double-click highlight deadline");
+
+ let start_col = info.inner_rect.x + 6;
+ let end_col = info.inner_rect.x + 9;
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ start_col,
+ row,
+ ));
assert!(app.selection_highlight_clear_deadline.is_none());
+ app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), end_col, row));
+ app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), end_col, row));
+
+ assert_visible_selection(&app);
+ assert!(!app
+ .clear_due_selection_highlight(stale_deadline + std::time::Duration::from_millis(1)));
+ assert_visible_selection(&app);
assert!(app.event_rx.try_recv().is_err());
}
#[tokio::test]
+ async fn ignored_left_down_keeps_double_click_highlight_deadline() {
+ let (mut app, info) = app_with_screen_bytes(b"alpha beta");
+ let col = info.inner_rect.x + 2;
+ let row = info.inner_rect.y;
+
+ double_click(&mut app, col, row);
+ assert_eq!(clipboard_write_content(&mut app), b"alpha");
+ let deadline = app
+ .selection_highlight_clear_deadline
+ .expect("double-click highlight deadline");
+ app.state.toast = Some(crate::app::state::ToastNotification {
+ kind: crate::app::state::ToastKind::Finished,
+ title: "finished".into(),
+ context: "missing".into(),
+ position: None,
+ target: Some(crate::app::state::ToastTarget {
+ workspace_id: "missing".into(),
+ pane_id: info.id,
+ }),
+ });
+ app.state.view.toast_hit_area = Rect::new(0, 0, 1, 1);
+
+ app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 0, 0));
+
+ assert_visible_selection(&app);
+ assert_eq!(app.selection_highlight_clear_deadline, Some(deadline));
+ assert!(app.clear_due_selection_highlight(deadline + std::time::Duration::from_millis(1)));
+ assert!(app.state.selection.is_none());
+ }
+
+ #[tokio::test]
async fn double_click_uses_display_columns_for_wide_chars() {
let (mut app, info) = app_with_screen_bytes("echo 你好-world done".as_bytes());
let col = info.inner_rect.x + 8;
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 8909fb1f..578ea0cd 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -1392,16 +1392,6 @@ impl App {
.clamp(self.state.sidebar_min_width, self.state.sidebar_max_width);
self.state.mouse_capture = config.ui.mouse_capture;
self.state.copy_on_select = config.ui.copy_on_select;
- if !self.state.copy_on_select {
- if self.state.mode == Mode::Copy {
- self.state.stop_selection_autoscroll_state();
- } else {
- self.state.clear_selection();
- }
- self.last_pane_click = None;
- self.selection_autoscroll_deadline = None;
- self.selection_highlight_clear_deadline = None;
- }
if self.state.redraw_on_focus_gained != config.ui.redraw_on_focus_gained {
self.state.request_client_config_reload = true;
}
@@ -2629,11 +2619,14 @@ mod tests {
assert_eq!(app.state.agent_panel_sort, state::AgentPanelSort::Priority);
assert!(!app.state.redraw_on_focus_gained);
assert!(!app.state.copy_on_select);
- assert!(app.state.selection.is_none());
- assert!(app.state.selection_autoscroll.is_none());
- assert!(app.selection_autoscroll_deadline.is_none());
- assert!(app.selection_highlight_clear_deadline.is_none());
- assert!(app.last_pane_click.is_none());
+ assert!(app.state.selection.is_some());
+ assert!(app.state.selection_autoscroll.is_some());
+ assert_eq!(app.selection_autoscroll_deadline, Some(selection_deadline));
+ assert_eq!(
+ app.selection_highlight_clear_deadline,
+ Some(selection_deadline)
+ );
+ assert!(app.last_pane_click.is_some());
app.state.mode = Mode::Copy;
app.state.selection = Some(crate::selection::Selection::range(
diff --git a/src/main.rs b/src/main.rs
index d7746f57..05309f98 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -269,8 +269,8 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# Pane apps like lazygit and btop can still receive mouse when they request it.
# mouse_capture = true
-# Copy text selected by mouse drag or double-click.
-# Set false to disable mouse text selection and copying.
+# Automatically copy text selected by mouse drag.
+# Set false to keep drag selection visible without copying; double-click still copies a word.
# copy_on_select = true
# Host cursor policy: "auto", "native", or "drawn".
diff --git a/src/selection.rs b/src/selection.rs
index 4fd65b6d..c725be2e 100644
--- a/src/selection.rs
+++ b/src/selection.rs
@@ -4,8 +4,8 @@
//!
//! MouseDown in pane → Anchor recorded (no visual yet)
//! MouseDrag → Selection becomes active, cells highlighted
-//! MouseUp → Text extracted, copied via OSC 52, highlight stays
-//! Next click / key → Selection cleared
+//! MouseUp → Selection finalized; optionally copied by the caller
+//! Next click / key → A retained selection is cleared
//!
//! Double-click copy also briefly highlights the selected word.
//!
@@ -25,8 +25,8 @@ enum Phase {
Anchored,
/// Mouse has moved from the anchor point. Cells are being highlighted.
Dragging,
- /// Mouse released after dragging. Selection is visible and text
- /// has been copied to clipboard. Cleared on next interaction.
+ /// Mouse released after dragging. Selection is visible and the gesture is
+ /// complete. Clipboard policy is owned by the caller.
Done,
}
@@ -159,8 +159,8 @@ impl Selection {
self.phase == Phase::Dragging || self.phase == Phase::Done
}
- /// Whether this selection was already finalized and copied.
- pub fn is_done(&self) -> bool {
+ /// Whether this selection was already finalized.
+ pub fn is_finalized(&self) -> bool {
self.phase == Phase::Done
}