aboutsummaryrefslogtreecommitdiff
path: root/src/app/input/clipboard.rs
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/clipboard.rs
parent0065a0ef344237c4e52bf391646e7495258ee180 (diff)
fix: copy retained mouse selections with keyboard shortcuts
refs #1782
Diffstat (limited to 'src/app/input/clipboard.rs')
-rw-r--r--src/app/input/clipboard.rs269
1 files changed, 269 insertions, 0 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"
+ );
+ }
+}