aboutsummaryrefslogtreecommitdiff
path: root/src/app/input/mod.rs
diff options
context:
space:
mode:
authorZack Drach <zackdrach@gmail.com>2026-05-26 03:40:29 -0700
committerGitHub <noreply@github.com>2026-05-26 13:40:29 +0300
commit6c4a947244b7be05496f9d3bd5ad2d00a69cca03 (patch)
tree421b4a009e2a201c9be65806fd0e13fe9899c73a /src/app/input/mod.rs
parent33e71ed2f50e5ca8950029f89f41e0ec135b87c0 (diff)
Add tmux-style double-click word copy (#296)
* feat: add tmux-style double-click copy Double-clicking terminal output now selects and copies the token under the cursor, similar to tmux. The copied token briefly stays highlighted as feedback, and token detection handles common shell output like paths, URLs, variables, CJK text, and surrounding punctuation. Mouse events are still forwarded unchanged when the child terminal has mouse reporting enabled. refs #142 * fix: tighten double-click copy refs #142 --------- Co-authored-by: Can Celik <ogulcancelik@gmail.com>
Diffstat (limited to 'src/app/input/mod.rs')
-rw-r--r--src/app/input/mod.rs121
1 files changed, 108 insertions, 13 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
+ }
}
// ---------------------------------------------------------------------------