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,20 +561,111 @@ 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.event_rx.try_recv().is_err()); - - double_click(&mut app, start_col, row); - - assert!(app.state.selection.is_none()); - assert!(app.last_pane_click.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()); } + #[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; + + 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()); 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 }