diff options
Diffstat (limited to 'src/app/input')
| -rw-r--r-- | src/app/input/mod.rs | 121 | ||||
| -rw-r--r-- | src/app/input/mouse.rs | 18 | ||||
| -rw-r--r-- | src/app/input/terminal.rs | 156 |
3 files changed, 270 insertions, 25 deletions
diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index 0f2f575d..0f0b3568 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -2,6 +2,7 @@ use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; +use crate::app::PaneClickState; use crate::input::TerminalKey; use ratatui::layout::Direction; @@ -194,21 +195,27 @@ impl App { } } + let handled_pane_double_click = self.handle_pane_double_click(mouse); + let previous_agent_panel_scope = self.state.agent_panel_scope; let previous_settings_section = self.state.settings.section; - if let Some(action) = self.state.handle_mouse(&mut self.terminal_runtimes, mouse) { - match action { - SettingsAction::SaveTheme(name) => self.save_theme(&name), - SettingsAction::SaveSound(enabled) => self.save_sound(enabled), - SettingsAction::SaveToastDelivery(delivery) => self.save_toast_delivery(delivery), - SettingsAction::SaveAgentBorderLabels(enabled) => { - self.save_agent_border_labels(enabled) - } - SettingsAction::SavePaneHistory(enabled) => { - self.save_pane_history_persistence(enabled) - } - SettingsAction::InstallRecommendedIntegrations => { - self.install_recommended_integrations() + if !handled_pane_double_click { + if let Some(action) = self.state.handle_mouse(&mut self.terminal_runtimes, mouse) { + match action { + SettingsAction::SaveTheme(name) => self.save_theme(&name), + SettingsAction::SaveSound(enabled) => self.save_sound(enabled), + SettingsAction::SaveToastDelivery(delivery) => { + self.save_toast_delivery(delivery) + } + SettingsAction::SaveAgentBorderLabels(enabled) => { + self.save_agent_border_labels(enabled) + } + SettingsAction::SavePaneHistory(enabled) => { + self.save_pane_history_persistence(enabled) + } + SettingsAction::InstallRecommendedIntegrations => { + self.install_recommended_integrations() + } } } } @@ -240,6 +247,94 @@ impl App { Some(std::time::Instant::now() + super::SELECTION_AUTOSCROLL_INTERVAL); } } + + fn handle_pane_double_click(&mut self, mouse: MouseEvent) -> bool { + // A pane press stops being a double-click candidate once it becomes + // a drag or completes as a real text selection. + match mouse.kind { + MouseEventKind::Drag(MouseButton::Left) => { + self.last_pane_click = None; + return false; + } + MouseEventKind::Up(MouseButton::Left) + if self + .state + .selection + .as_ref() + .is_some_and(|selection| selection.is_visible()) => + { + self.last_pane_click = None; + return false; + } + _ => {} + } + + // Only terminal-pane left-clicks can start this gesture; other clicks + // should keep their existing mouse behavior and clear stale candidates. + let Some(click) = self.pane_click_candidate(mouse) else { + return false; + }; + + // 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. + 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) + } + + fn pane_click_candidate(&mut self, mouse: MouseEvent) -> Option<PaneClickState> { + if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { + return None; + } + + if self.state.mode != Mode::Terminal { + self.last_pane_click = None; + return None; + } + + let Some(info) = self.state.pane_at(mouse.column, mouse.row).cloned() else { + self.last_pane_click = None; + return None; + }; + + Some(PaneClickState { + pane_id: info.id, + viewport_row: mouse.row - info.inner_rect.y, + col: mouse.column - info.inner_rect.x, + at: std::time::Instant::now(), + }) + } + + fn take_pane_double_click(&mut self, click: PaneClickState) -> bool { + if !self + .last_pane_click + .is_some_and(|last| last.is_double_click_for(click)) + { + self.last_pane_click = Some(click); + return false; + } + + self.last_pane_click = None; + true + } + + fn copy_double_clicked_word(&mut self, click: PaneClickState) -> bool { + let copied = self.state.copy_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); + } + copied + } } // --------------------------------------------------------------------------- diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index 87c14756..8adfa55b 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -728,16 +728,19 @@ impl AppState { } MouseEventKind::Up(MouseButton::Left) => { - if self.selection.is_some() { + // Mouse-up either finishes a drag selection or releases after a + // double-click copy; the latter is already copied. + if let Some(selection) = self.selection.as_ref() { + let was_click = selection.was_just_click(); + let was_already_copied = selection.is_done(); + self.workspace_press = None; self.tab_press = None; self.drag = None; self.selection_autoscroll = None; - let was_click = self.selection.as_ref().is_some_and(|s| s.was_just_click()); if was_click { self.selection = None; - self.selection_autoscroll = None; - } else { + } else if !was_already_copied { self.copy_selection(terminal_runtimes); } return None; @@ -795,13 +798,6 @@ impl AppState { return None; } } - let was_click = self.selection.as_ref().is_some_and(|s| s.was_just_click()); - if was_click { - self.selection = None; - self.selection_autoscroll = None; - } else { - self.copy_selection(terminal_runtimes); - } } } } diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs index eee5b863..71728be4 100644 --- a/src/app/input/terminal.rs +++ b/src/app/input/terminal.rs @@ -201,7 +201,51 @@ mod tests { app_for_mouse_test, mouse, numbered_lines_bytes, unique_temp_path, wait_for_file, }; use super::*; - use crate::{config::Config, workspace::Workspace}; + use crate::{config::Config, events::AppEvent, workspace::Workspace}; + + fn app_with_screen_bytes(bytes: &[u8]) -> (App, crate::layout::PaneInfo) { + 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(); + ws.insert_test_runtime( + pane_id, + crate::terminal::TerminalRuntime::test_with_screen_bytes( + info.inner_rect.width, + info.inner_rect.height, + bytes, + ), + ); + + 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) + } + + fn double_click(app: &mut App, col: u16, row: u16) { + app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, row)); + app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), col, row)); + app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 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 dragging_selection_above_pane_autoscrolls_and_extends_into_scrollback() { @@ -310,6 +354,116 @@ mod tests { } #[tokio::test] + async fn drag_copy_then_click_does_not_reuse_double_click_candidate() { + let (mut app, info) = app_with_screen_bytes(b"alpha beta"); + let row = info.inner_rect.y; + let start_col = info.inner_rect.x; + let end_col = info.inner_rect.x + 4; + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + start_col, + row, + )); + assert!(app.last_pane_click.is_some()); + + app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), end_col, row)); + assert!(app.last_pane_click.is_none()); + + app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), end_col, row)); + assert!(app.last_pane_click.is_none()); + assert_eq!(clipboard_write_content(&mut app), b"alpha"); + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + start_col, + row, + )); + + assert!(app.last_pane_click.is_some()); + assert!(app.event_rx.try_recv().is_err()); + } + + #[tokio::test] + async fn double_click_selects_and_copies_word() { + let (mut app, info) = app_with_screen_bytes(b"alpha beta-gamma_delta@omega"); + let col = info.inner_rect.x + 13; + let row = info.inner_rect.y; + double_click(&mut app, col, row); + + assert_eq!(clipboard_write_content(&mut app), b"beta-gamma_delta@omega"); + assert_visible_selection(&app); + } + + #[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()); + let col = info.inner_rect.x + 8; + let row = info.inner_rect.y; + double_click(&mut app, col, row); + + assert_eq!(clipboard_write_content(&mut app), "你好-world".as_bytes()); + assert_visible_selection(&app); + } + + #[tokio::test] + async fn double_click_copies_quoted_path_without_quotes() { + let line = r#"cat "/tmp/build output/log.txt""#; + let (mut app, info) = app_with_screen_bytes(line.as_bytes()); + let col = info.inner_rect.x + line.find("output").expect("path segment") as u16; + let row = info.inner_rect.y; + double_click(&mut app, col, row); + + assert_eq!( + clipboard_write_content(&mut app), + b"/tmp/build output/log.txt" + ); + assert_visible_selection(&app); + } + + #[tokio::test] + async fn double_click_excludes_trailing_punctuation() { + let (mut app, info) = app_with_screen_bytes(b"done."); + 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"done"); + assert_visible_selection(&app); + } + + #[tokio::test] + async fn double_click_highlight_clears_after_short_delay() { + let (mut app, info) = app_with_screen_bytes(b"copied"); + 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"copied"); + + app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), col, row)); + + assert!(app.event_rx.try_recv().is_err()); + assert!(app.state.selection.is_some()); + let deadline = app + .selection_highlight_clear_deadline + .expect("highlight clear deadline"); + assert!(app.handle_scheduled_tasks(deadline + std::time::Duration::from_millis(1))); + assert!(app.state.selection.is_none()); + } + + #[tokio::test] + async fn double_click_is_forwarded_when_mouse_reporting_is_enabled() { + let (mut app, info) = app_with_screen_bytes(b"\x1b[?1002halpha beta"); + let col = info.inner_rect.x + 8; + let row = info.inner_rect.y; + double_click(&mut app, col, row); + + assert!(app.event_rx.try_recv().is_err()); + assert!(app.state.selection.is_none()); + assert!(app.selection_highlight_clear_deadline.is_none()); + } + + #[tokio::test] async fn wheel_scroll_keeps_in_progress_selection_and_extends_it() { let mut app = app_for_mouse_test(); let mut ws = Workspace::test_new("test"); |
