aboutsummaryrefslogtreecommitdiff
path: root/src/app/input
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-25 02:40:27 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-25 02:54:20 +0300
commitb33e5f973fc89381aa4ac72b3a31fa087ec66e23 (patch)
tree454d9b504987052619d38157fbf7c8eb8eaff5d6 /src/app/input
parent0065a0ef344237c4e52bf391646e7495258ee180 (diff)
fix: copy retained mouse selections with keyboard shortcuts
refs #1782
Diffstat (limited to 'src/app/input')
-rw-r--r--src/app/input/clipboard.rs269
-rw-r--r--src/app/input/copy_mode.rs10
-rw-r--r--src/app/input/mod.rs31
-rw-r--r--src/app/input/mouse.rs4
-rw-r--r--src/app/input/terminal.rs43
5 files changed, 317 insertions, 40 deletions
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<Bytes>,
+ ) {
+ 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<u8> {
+ 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<PaneClickState> {
@@ -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,10 +28,19 @@ fn is_modifier_only_key(code: &KeyCode) -> bool {
}
impl App {
+ #[cfg(test)]
pub(crate) fn handle_terminal_key_headless(
&mut self,
key: TerminalKey,
) -> Option<TerminalInputTarget> {
+ 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<TerminalInputTarget> {
match self.prepare_popup_key_forward(key) {
PreparedPopupInput::NotOpen => {}
PreparedPopupInput::Consumed => return None,
@@ -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<PreparedPaneInput> {
+ fn prepare_terminal_key_forward(
+ &mut self,
+ source_id: InputSourceId,
+ key: TerminalKey,
+ ) -> Option<PreparedPaneInput> {
+ 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;