use crossterm::event::{KeyCode, KeyModifiers}; use crate::{ app::{App, InputSourceId}, input::TerminalKey, }; use super::{ConsumedInputLease, InputLeaseKey}; 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.input_leases.insert_consumed( InputLeaseKey::new(source_id, &key), ConsumedInputLease::SuppressRepeats, ); 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) .with_windows_record(crate::input::WindowsKeyRecord { key_down: true, repeat_count: 1, virtual_key_code: 0x43, virtual_scan_code: 0x2e, unicode: 'c' as u16, control_key_state: 0x0008, }); let source_id = 41; app.route_client_events_from( source_id, vec![crate::raw_input::RawInputEvent::Key(ctrl_c.clone())], 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.clone()), crate::raw_input::RawInputEvent::Key( ctrl_c.clone().with_kind(KeyEventKind::Repeat), ), ], false, ); assert_eq!(app.input_leases.len(), 1); 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.clone().with_kind(KeyEventKind::Release), )], false, ); assert!(app.input_leases.is_empty()); app.route_client_events_from( source_id, vec![crate::raw_input::RawInputEvent::Key(ctrl_c.clone())], false, ); let expected = if cfg!(windows) { b"\x1b[67;46;99;1;8;1_".as_slice() } else { b"\x03".as_slice() }; assert_eq!( input_rx.try_recv().expect("forwarded Ctrl-C").as_ref(), expected ); 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" ); } }