aboutsummaryrefslogtreecommitdiff
path: root/src/app/input
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/input')
-rw-r--r--src/app/input/mod.rs121
-rw-r--r--src/app/input/mouse.rs18
-rw-r--r--src/app/input/terminal.rs156
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");