diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index ee05a843..b719e9d4 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -6,6 +6,7 @@ - Relicensed Herdr from AGPL-3.0-or-later to Apache-2.0. ### Fixed +- `ui.copy_on_select = false` now retains drag and double-click word selections without copying; `Ctrl+C`, or `Cmd+C` when the host terminal forwards it, copies and clears the selection. - Pane and agent read responses now report `truncated: true` when older terminal rows were omitted. (#1717) - Pane applications that query OSC 4 palette colors now inherit the host terminal palette. (#1752) - Ctrl-clicking a pane URL no longer forwards an unmatched mouse release to alternate-screen applications, preventing duplicate browser tabs. (#1761) diff --git a/docs/next/website/src/data/config-reference.json b/docs/next/website/src/data/config-reference.json index b91cdf46..b3841de2 100644 --- a/docs/next/website/src/data/config-reference.json +++ b/docs/next/website/src/data/config-reference.json @@ -597,7 +597,7 @@ "key": "ui.copy_on_select", "type": "boolean", "default": "true", - "description": "Copy text selected with the mouse." + "description": "Automatically copy text selected by mouse drag or double-click. When disabled, Ctrl+C or a host-forwarded Cmd+C copies and clears the retained selection." }, { "key": "ui.host_cursor", diff --git a/src/app/actions.rs b/src/app/actions.rs index ff786846..d3b85c07 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -2063,7 +2063,7 @@ impl AppState { self.selection_autoscroll = None; } - pub(crate) fn copy_word_at_pane_cell( + pub(crate) fn select_word_at_pane_cell( &mut self, terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, pane_id: crate::layout::PaneId, @@ -2113,23 +2113,30 @@ impl AppState { return false; }; - // Copy the token and keep its selection visible as short-lived feedback. let mut selection = Selection::range(pane_id, viewport_row, start_col, end_col, metrics); if !selection.finish() { return false; } - let Some(text) = rt - .extract_selection(&selection) - .filter(|text| !text.is_empty()) - else { - self.clear_selection(); - return false; + let text = if self.copy_on_select { + let Some(text) = rt + .extract_selection(&selection) + .filter(|text| !text.is_empty()) + else { + self.clear_selection(); + return false; + }; + Some(text) + } else { + None }; - self.request_clipboard_write = Some(text.into_bytes()); + self.selection = Some(selection); self.selection_autoscroll = None; - info!("copied double-clicked token to clipboard"); + if let Some(text) = text { + self.request_clipboard_write = Some(text.into_bytes()); + info!("copied double-clicked token to clipboard"); + } true } @@ -2184,7 +2191,7 @@ impl AppState { Some(sel) => sel, None => return, }; - if !sel.finish() { + if !sel.is_finalized() && !sel.finish() { return; } diff --git a/src/app/input/clipboard.rs b/src/app/input/clipboard.rs new file mode 100644 index 00000000..db3fa9f2 --- /dev/null +++ b/src/app/input/clipboard.rs @@ -0,0 +1,269 @@ +use crossterm::event::{KeyCode, KeyModifiers}; + +use crate::{ + app::{App, InputSourceId}, + input::TerminalKey, +}; + +fn is_retained_selection_copy_key(key: TerminalKey) -> bool { + matches!(key.code, KeyCode::Char('c' | 'C')) + && matches!(key.modifiers, KeyModifiers::CONTROL | KeyModifiers::SUPER) +} + +impl App { + pub(super) fn dispatch_pending_clipboard_write(&mut self) -> bool { + let Some(content) = self.state.request_clipboard_write.take() else { + return false; + }; + if self + .event_tx + .try_send(crate::events::AppEvent::ClipboardWrite { content }) + .is_err() + { + tracing::warn!("failed to queue clipboard write event"); + } + true + } + + pub(super) fn try_copy_retained_selection( + &mut self, + source_id: InputSourceId, + key: TerminalKey, + ) -> bool { + if self.state.copy_on_select + || !is_retained_selection_copy_key(key) + || !self + .state + .selection + .as_ref() + .is_some_and(crate::selection::Selection::is_finalized) + { + return false; + } + + self.state.copy_selection(&self.terminal_runtimes); + if !self.dispatch_pending_clipboard_write() { + return false; + } + + self.suppressed_repeat_keys.insert((source_id, key.code)); + true + } +} + +#[cfg(test)] +mod tests { + use bytes::Bytes; + use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind}; + use ratatui::layout::Rect; + + use super::super::{app_for_mouse_test, mouse}; + use super::*; + use crate::{app::Mode, events::AppEvent, workspace::Workspace}; + + fn app_with_screen_bytes_and_input( + bytes: &[u8], + ) -> ( + App, + crate::layout::PaneInfo, + tokio::sync::mpsc::Receiver, + ) { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("test"); + let pane_id = ws.tabs[0].root_pane; + let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); + let info = pane_infos[0].clone(); + let (runtime, input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + info.inner_rect.width, + info.inner_rect.height, + 0, + bytes, + 4, + ); + ws.insert_test_runtime(pane_id, runtime); + + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + app.state.view.pane_infos = pane_infos; + (app, info, input_rx) + } + + fn drag_select_range( + app: &mut App, + info: &crate::layout::PaneInfo, + start_col: u16, + end_col: u16, + ) { + let row = info.inner_rect.y; + let start_col = info.inner_rect.x + start_col; + let end_col = info.inner_rect.x + end_col; + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + start_col, + row, + )); + app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), end_col, row)); + app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), end_col, row)); + } + + fn clipboard_write_content(app: &mut App) -> Vec { + match app.event_rx.try_recv().expect("clipboard write event") { + AppEvent::ClipboardWrite { content } => content, + event => panic!("unexpected event: {event:?}"), + } + } + + fn assert_visible_selection(app: &App) { + assert!(app + .state + .selection + .as_ref() + .is_some_and(crate::selection::Selection::is_visible)); + } + + #[tokio::test] + async fn copy_on_select_disabled_ctrl_c_copies_and_clears_retained_selection() { + let (mut app, info, mut input_rx) = app_with_screen_bytes_and_input(b"alpha beta"); + app.state.copy_on_select = false; + drag_select_range(&mut app, &info, 0, 4); + assert_visible_selection(&app); + assert!(app.event_rx.try_recv().is_err()); + + let ctrl_c = TerminalKey::new(KeyCode::Char('c'), KeyModifiers::CONTROL); + let source_id = 41; + app.route_client_events_from( + source_id, + vec![crate::raw_input::RawInputEvent::Key(ctrl_c)], + false, + ); + + let content = clipboard_write_content(&mut app); + assert_eq!(content, b"alpha"); + assert!(app.state.selection.is_none()); + assert!(input_rx.try_recv().is_err()); + + app.handle_internal_event(AppEvent::ClipboardWrite { content }); + assert_eq!( + app.state + .copy_feedback + .as_ref() + .map(|feedback| feedback.message.as_str()), + Some("copied to clipboard") + ); + + app.route_client_events_from( + source_id, + vec![crate::raw_input::RawInputEvent::Key( + ctrl_c.with_kind(KeyEventKind::Repeat), + )], + false, + ); + assert!(app.event_rx.try_recv().is_err()); + assert!(input_rx.try_recv().is_err()); + + app.route_client_events_from( + source_id, + vec![crate::raw_input::RawInputEvent::Key( + ctrl_c.with_kind(KeyEventKind::Release), + )], + false, + ); + app.route_client_events_from( + source_id, + vec![crate::raw_input::RawInputEvent::Key(ctrl_c)], + false, + ); + assert_eq!( + input_rx.try_recv().expect("forwarded Ctrl-C").as_ref(), + b"\x03" + ); + assert!(app.event_rx.try_recv().is_err()); + } + + #[tokio::test] + async fn copy_on_select_disabled_cmd_c_copies_retained_selection() { + let (mut app, info, mut input_rx) = app_with_screen_bytes_and_input(b"alpha beta"); + app.state.copy_on_select = false; + drag_select_range(&mut app, &info, 0, 4); + + app.handle_terminal_key_headless(TerminalKey::new(KeyCode::Char('c'), KeyModifiers::SUPER)); + + assert_eq!(clipboard_write_content(&mut app), b"alpha"); + assert!(app.state.selection.is_none()); + assert!(input_rx.try_recv().is_err()); + } + + #[tokio::test] + async fn retained_selection_copy_shortcut_is_disabled_with_copy_on_select() { + let (mut app, _info, mut input_rx) = app_with_screen_bytes_and_input(b"alpha beta"); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + let mut selection = crate::selection::Selection::range( + pane_id, + 0, + 0, + 4, + app.state + .pane_scroll_metrics(&app.terminal_runtimes, pane_id), + ); + assert!(selection.finish()); + app.state.selection = Some(selection); + app.state.copy_on_select = true; + + app.handle_terminal_key_headless(TerminalKey::new( + KeyCode::Char('c'), + KeyModifiers::CONTROL, + )); + + assert!(app.state.selection.is_none()); + assert!(app.event_rx.try_recv().is_err()); + assert_eq!( + input_rx.try_recv().expect("forwarded Ctrl-C").as_ref(), + b"\x03" + ); + } + + #[tokio::test] + async fn retained_selection_copy_shortcut_forwards_when_selection_text_is_empty() { + let (mut app, info, mut input_rx) = app_with_screen_bytes_and_input(b""); + app.state.copy_on_select = false; + drag_select_range(&mut app, &info, 0, 4); + assert_visible_selection(&app); + + app.handle_terminal_key_headless(TerminalKey::new( + KeyCode::Char('c'), + KeyModifiers::CONTROL, + )); + + assert!(app.state.selection.is_none()); + assert!(app.event_rx.try_recv().is_err()); + assert_eq!( + input_rx.try_recv().expect("forwarded Ctrl-C").as_ref(), + b"\x03" + ); + } + + #[tokio::test] + async fn retained_selection_copy_shortcut_requires_exact_modifiers() { + let (mut app, info, mut input_rx) = app_with_screen_bytes_and_input(b"alpha beta"); + app.state.copy_on_select = false; + drag_select_range(&mut app, &info, 0, 4); + + app.handle_terminal_key_headless(TerminalKey::new( + KeyCode::Char('C'), + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + )); + + assert!(app.state.selection.is_none()); + assert!(app.event_rx.try_recv().is_err()); + assert_eq!( + input_rx + .try_recv() + .expect("forwarded Ctrl-Shift-C") + .as_ref(), + b"\x03" + ); + } +} diff --git a/src/app/input/copy_mode.rs b/src/app/input/copy_mode.rs index e6c0d074..6c967133 100644 --- a/src/app/input/copy_mode.rs +++ b/src/app/input/copy_mode.rs @@ -22,15 +22,7 @@ impl App { } self.state .handle_copy_mode_key(&self.terminal_runtimes, key); - if let Some(content) = self.state.request_clipboard_write.take() { - if self - .event_tx - .try_send(crate::events::AppEvent::ClipboardWrite { content }) - .is_err() - { - tracing::warn!("failed to queue clipboard write event"); - } - } + self.dispatch_pending_clipboard_write(); } } diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index f304cde6..a2e6597b 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -36,6 +36,7 @@ fn modified_url_click_modifier_matches_terminal_mouse_reporting() { assert_eq!(modified_url_click_modifier(), KeyModifiers::CONTROL); } +mod clipboard; mod copy_mode; mod modal; mod mouse; @@ -401,15 +402,7 @@ impl App { self.save_agent_panel_sort(self.state.agent_panel_sort); } - if let Some(content) = self.state.request_clipboard_write.take() { - if self - .event_tx - .try_send(crate::events::AppEvent::ClipboardWrite { content }) - .is_err() - { - tracing::warn!("failed to queue clipboard write event"); - } - } + self.dispatch_pending_clipboard_write(); // Sync autoscroll deadline with state (mouse handler may have // set or cleared selection_autoscroll during handle_mouse). @@ -569,14 +562,12 @@ impl App { }; // Require the second click to land near the first click in the same pane - // and within the double-click window so adjacent interactions do not copy. + // and within the double-click window so adjacent interactions do not select a word. if !self.take_pane_double_click(click) { return false; } - // Preserve a short highlight after copying so the user gets visible - // confirmation without leaving a persistent selection behind. - self.copy_double_clicked_word(click) + self.select_double_clicked_word(click) } fn pane_click_candidate(&mut self, mouse: MouseEvent) -> Option { @@ -620,18 +611,20 @@ impl App { true } - fn copy_double_clicked_word(&mut self, click: PaneClickState) -> bool { - let copied = self.state.copy_word_at_pane_cell( + fn select_double_clicked_word(&mut self, click: PaneClickState) -> bool { + let selected = self.state.select_word_at_pane_cell( &self.terminal_runtimes, click.pane_id, click.viewport_row, click.col, ); - if copied { - self.selection_highlight_clear_deadline = - Some(std::time::Instant::now() + super::PANE_COPY_HIGHLIGHT_DURATION); + if selected { + self.selection_highlight_clear_deadline = self + .state + .copy_on_select + .then(|| std::time::Instant::now() + super::PANE_COPY_HIGHLIGHT_DURATION); } - copied + selected } } diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index 2283b754..e0f89387 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -796,7 +796,7 @@ impl AppState { MouseEventKind::Up(MouseButton::Left) => { // Mouse-up either finishes a drag selection or releases after a - // double-click copy; the latter is already finalized. + // double-click word selection; the latter is already finalized. if let Some(selection) = self.selection.as_ref() { let was_click = selection.was_just_click(); let was_finalized = selection.is_finalized(); @@ -808,7 +808,7 @@ impl AppState { if was_click { self.selection = None; } else if was_finalized { - // Double-click copy already finalized this selection. + // Double-click already finalized this word selection. } else if self.copy_on_select { self.copy_selection(terminal_runtimes); } else if let Some(selection) = self.selection.as_mut() { diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs index 371803da..d862702f 100644 --- a/src/app/input/terminal.rs +++ b/src/app/input/terminal.rs @@ -3,7 +3,7 @@ use crossterm::event::KeyCode; use tracing::{debug, warn}; use crate::{ - app::{App, Mode, TerminalInputTarget}, + app::{App, InputSourceId, Mode, TerminalInputTarget}, input::TerminalKey, }; @@ -28,9 +28,18 @@ fn is_modifier_only_key(code: &KeyCode) -> bool { } impl App { + #[cfg(test)] pub(crate) fn handle_terminal_key_headless( &mut self, key: TerminalKey, + ) -> Option { + self.handle_terminal_key_headless_from(crate::app::LOCAL_INPUT_SOURCE, key) + } + + pub(crate) fn handle_terminal_key_headless_from( + &mut self, + source_id: InputSourceId, + key: TerminalKey, ) -> Option { match self.prepare_popup_key_forward(key) { PreparedPopupInput::NotOpen => {} @@ -44,20 +53,27 @@ impl App { } } - let input = self.prepare_terminal_key_forward(key)?; + let input = self.prepare_terminal_key_forward(source_id, key)?; let sent = self .lookup_runtime_sender(input.ws_idx, input.pane_id) .is_some_and(|runtime| runtime.try_send_bytes(input.bytes).is_ok()); sent.then_some(input.target) } - fn prepare_terminal_key_forward(&mut self, key: TerminalKey) -> Option { + fn prepare_terminal_key_forward( + &mut self, + source_id: InputSourceId, + key: TerminalKey, + ) -> Option { + let key_event = key.as_key_event(); + if self.try_copy_retained_selection(source_id, key) { + return None; + } + self.state.clear_selection(); self.selection_autoscroll_deadline = None; self.state.update_dismissed = true; - let key_event = key.as_key_event(); - if let Some(action) = super::terminal_direct_non_indexed_navigation_action(&self.state, key) { debug!( @@ -365,7 +381,7 @@ impl App { } } - let input = self.prepare_terminal_key_forward(key)?; + let input = self.prepare_terminal_key_forward(crate::app::LOCAL_INPUT_SOURCE, key)?; let sent = if let Some(runtime) = self.lookup_runtime_sender(input.ws_idx, input.pane_id) { runtime.send_bytes(input.bytes).await.is_ok() } else { @@ -714,7 +730,7 @@ mod tests { } #[tokio::test] - async fn copy_on_select_disabled_keeps_explicit_double_click_copy() { + async fn copy_on_select_disabled_retains_double_clicked_word_until_shortcut() { let (mut app, info) = app_with_screen_bytes(b"alpha beta"); app.state.copy_on_select = false; let col = info.inner_rect.x + 2; @@ -722,16 +738,22 @@ mod tests { 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.selection_highlight_clear_deadline.is_none()); assert!(app.event_rx.try_recv().is_err()); + + app.handle_terminal_key_headless(TerminalKey::new( + KeyCode::Char('c'), + KeyModifiers::CONTROL, + )); + + assert_eq!(clipboard_write_content(&mut app), b"alpha"); + assert!(app.state.selection.is_none()); } #[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; @@ -740,6 +762,7 @@ mod tests { let stale_deadline = app .selection_highlight_clear_deadline .expect("double-click highlight deadline"); + app.state.copy_on_select = false; let start_col = info.inner_rect.x + 6; let end_col = info.inner_rect.x + 9; diff --git a/src/app/mod.rs b/src/app/mod.rs index 6ca1d940..84f6d917 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1621,7 +1621,9 @@ impl App { if self.state.popup_pane.is_some() || self.state.mode == Mode::Terminal { self.suppressed_repeat_keys.remove(&pressed_key_id); - if let Some(target) = self.handle_terminal_key_headless(key) { + if let Some(target) = + self.handle_terminal_key_headless_from(source_id, key) + { if !key.is_text_commit { self.pressed_terminal_keys.insert( pressed_key_id, @@ -1650,7 +1652,7 @@ impl App { || self.state.mode == Mode::Terminal) && !self.suppressed_repeat_keys.contains(&pressed_key_id) { - let _ = self.handle_terminal_key_headless(key); + let _ = self.handle_terminal_key_headless_from(source_id, key); } } crossterm::event::KeyEventKind::Release => { diff --git a/src/main.rs b/src/main.rs index a73423bf..4e9ce815 100644 --- a/src/main.rs +++ b/src/main.rs @@ -269,8 +269,9 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # Pane apps like lazygit and btop can still receive mouse when they request it. # mouse_capture = true -# Automatically copy text selected by mouse drag. -# Set false to keep drag selection visible without copying; double-click still copies a word. +# Automatically copy text selected with the mouse. +# Set false to retain drag or double-click word selection until Ctrl+C, +# or Cmd+C when the host forwards it, copies and clears it. # copy_on_select = true # Host cursor policy: "auto", "native", or "drawn". diff --git a/src/selection.rs b/src/selection.rs index c725be2e..d9378571 100644 --- a/src/selection.rs +++ b/src/selection.rs @@ -7,7 +7,7 @@ //! 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. +//! Double-click selects a word; the caller decides whether to copy it immediately. //! //! Rows are stored in screen-buffer coordinates instead of viewport-relative //! coordinates. That keeps selection stable while the pane scrolls.