aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app/actions.rs486
-rw-r--r--src/app/input/mod.rs121
-rw-r--r--src/app/input/mouse.rs18
-rw-r--r--src/app/input/terminal.rs156
-rw-r--r--src/app/mod.rs23
-rw-r--r--src/app/runtime.rs25
-rw-r--r--src/selection.rs24
-rw-r--r--src/server/headless.rs2
8 files changed, 827 insertions, 28 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs
index 9132d7bd..65142fd1 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -6,8 +6,10 @@ use tracing::{info, warn};
use crate::detect::{Agent, AgentState};
use crate::events::AppEvent;
use crate::layout::{find_in_direction, NavDirection, PaneId};
+use crate::selection::Selection;
use crate::terminal::{EffectiveStateChange, TerminalStateMutation};
use crate::workspace::WorkspaceGitStatus;
+use unicode_width::UnicodeWidthChar;
use super::state::{
AppState, Mode, NavigatorRow, NavigatorStateFilter, NavigatorTarget, ToastKind,
@@ -1256,6 +1258,76 @@ impl AppState {
self.selection_autoscroll = None;
}
+ pub(crate) fn copy_word_at_pane_cell(
+ &mut self,
+ terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
+ pane_id: crate::layout::PaneId,
+ viewport_row: u16,
+ col: u16,
+ ) -> bool {
+ // Resolve the active pane cell the double-click landed on.
+ let Some(ws_idx) = self
+ .active
+ .filter(|idx| self.workspaces.get(*idx).is_some())
+ else {
+ return false;
+ };
+
+ let Some(info) = self.pane_info_by_id(pane_id) else {
+ return false;
+ };
+ if viewport_row >= info.inner_rect.height || col >= info.inner_rect.width {
+ return false;
+ }
+
+ // Leave mouse input to terminal apps that requested it.
+ let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id)
+ else {
+ return false;
+ };
+ if rt
+ .input_state()
+ .is_some_and(crate::pane::InputState::mouse_reporting_enabled)
+ {
+ return false;
+ }
+
+ // Read the visible row and identify the clicked token bounds.
+ let metrics = self.pane_scroll_metrics(terminal_runtimes, pane_id);
+ let row_selection = Selection::range(
+ pane_id,
+ viewport_row,
+ 0,
+ info.inner_rect.width.saturating_sub(1),
+ metrics,
+ );
+ let Some(row_text) = rt.extract_selection(&row_selection) else {
+ return false;
+ };
+ let Some((start_col, end_col)) = word_bounds_at_column(&row_text, col) else {
+ return false;
+ };
+
+ // Copy the token and keep its selection visible as short-lived feedback.
+ let mut selection = Selection::range(pane_id, viewport_row, start_col, end_col, metrics);
+ if !selection.finish() {
+ return false;
+ }
+
+ let Some(text) = rt
+ .extract_selection(&selection)
+ .filter(|text| !text.is_empty())
+ else {
+ self.clear_selection();
+ return false;
+ };
+ self.request_clipboard_write = Some(text.into_bytes());
+ self.selection = Some(selection);
+ self.selection_autoscroll = None;
+ info!("copied double-clicked token to clipboard");
+ true
+ }
+
pub fn copy_selection(&mut self, terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry) {
let mut sel = match self.selection.take() {
Some(sel) => sel,
@@ -1273,7 +1345,6 @@ impl AppState {
let text = self
.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, sel.pane_id)
.and_then(|rt| rt.extract_selection(&sel));
-
if let Some(text) = text {
if !text.is_empty() {
self.request_clipboard_write = Some(text.into_bytes());
@@ -1281,9 +1352,245 @@ impl AppState {
}
}
- self.selection = None;
- self.selection_autoscroll = None;
+ self.clear_selection();
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+struct TextCell {
+ ch: char,
+ start_col: u16,
+ end_col: u16,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+struct CellSpan {
+ start: usize,
+ end: usize,
+}
+
+impl CellSpan {
+ fn contains(self, idx: usize) -> bool {
+ idx >= self.start && idx <= self.end
+ }
+
+ fn columns(self, cells: &[TextCell]) -> (u16, u16) {
+ (cells[self.start].start_col, cells[self.end].end_col)
+ }
+}
+
+/// Finds the terminal display-column bounds for the token under a double-click.
+///
+/// The algorithm first maps text to terminal cells so wide characters and
+/// zero-width marks use display columns, then prefers structured spans that
+/// users expect to copy whole (URLs and quoted paths), and finally falls back
+/// to a separator-delimited token.
+fn word_bounds_at_column(row: &str, col: u16) -> Option<(u16, u16)> {
+ // Map the row into display cells before doing any word-boundary work.
+ let cells = text_cells(row);
+ let clicked_idx = cell_index_at_column(&cells, col)?;
+
+ // Prefer spans that can legally include punctuation or spaces.
+ let span = url_span_at_column(&cells, clicked_idx)
+ .or_else(|| quoted_path_span_at_column(&cells, clicked_idx))
+ .or_else(|| token_span_at_column(&cells, clicked_idx))?;
+
+ // Convert the internal cell span back to inclusive terminal columns.
+ Some(span.columns(&cells))
+}
+
+fn token_span_at_column(cells: &[TextCell], clicked_idx: usize) -> Option<CellSpan> {
+ if is_word_separator(cells[clicked_idx].ch) {
+ return None;
+ }
+
+ let mut start = clicked_idx;
+ while start > 0 && !is_word_separator(cells[start - 1].ch) {
+ start -= 1;
+ }
+
+ let mut end = clicked_idx;
+ while end + 1 < cells.len() && !is_word_separator(cells[end + 1].ch) {
+ end += 1;
}
+
+ trim_token_edges(cells, CellSpan { start, end }).filter(|span| span.contains(clicked_idx))
+}
+
+fn text_cells(row: &str) -> Vec<TextCell> {
+ let mut next_col = 0u16;
+ row.chars()
+ .map(|ch| {
+ let width = UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
+ let start_col = if width == 0 {
+ next_col.saturating_sub(1)
+ } else {
+ next_col
+ };
+ if width > 0 {
+ next_col = next_col.saturating_add(width);
+ }
+ TextCell {
+ ch,
+ start_col,
+ end_col: next_col.saturating_sub(1),
+ }
+ })
+ .collect()
+}
+
+fn cell_index_at_column(cells: &[TextCell], col: u16) -> Option<usize> {
+ cells
+ .iter()
+ .position(|cell| cell.start_col <= col && col <= cell.end_col)
+}
+
+fn url_span_at_column(cells: &[TextCell], clicked_idx: usize) -> Option<CellSpan> {
+ let mut start = 0;
+ while start < cells.len() {
+ if starts_with_chars(&cells[start..], "http://")
+ || starts_with_chars(&cells[start..], "https://")
+ {
+ let mut end = start;
+ while end + 1 < cells.len() && !cells[end + 1].ch.is_whitespace() {
+ end += 1;
+ }
+ if clicked_idx >= start && clicked_idx <= end {
+ let span = trim_url_edges(cells, CellSpan { start, end })?;
+ return span.contains(clicked_idx).then_some(span);
+ }
+ start = end + 1;
+ } else {
+ start += 1;
+ }
+ }
+ None
+}
+
+fn trim_url_edges(cells: &[TextCell], span: CellSpan) -> Option<CellSpan> {
+ let start = span.start;
+ let mut end = span.end;
+ while start <= end && should_trim_trailing_url_cell(cells, start, end) {
+ if end == 0 {
+ return None;
+ }
+ end -= 1;
+ }
+ (start <= end).then_some(CellSpan { start, end })
+}
+
+fn should_trim_trailing_url_cell(cells: &[TextCell], start: usize, end: usize) -> bool {
+ match cells[end].ch {
+ '"' | '\'' | '`' | '.' | ',' | ';' | ':' | '!' | '?' => true,
+ ')' => !trailing_url_closer_is_balanced(cells, start, end, '(', ')'),
+ ']' => !trailing_url_closer_is_balanced(cells, start, end, '[', ']'),
+ '}' => !trailing_url_closer_is_balanced(cells, start, end, '{', '}'),
+ _ => false,
+ }
+}
+
+fn trailing_url_closer_is_balanced(
+ cells: &[TextCell],
+ start: usize,
+ end: usize,
+ open: char,
+ close: char,
+) -> bool {
+ let mut balance = 0i32;
+ for cell in &cells[start..end] {
+ if cell.ch == open {
+ balance += 1;
+ } else if cell.ch == close {
+ balance -= 1;
+ }
+ }
+ balance > 0
+}
+
+fn quoted_path_span_at_column(cells: &[TextCell], clicked_idx: usize) -> Option<CellSpan> {
+ let clicked = cells.get(clicked_idx)?.ch;
+ if clicked == '"' || clicked == '\'' || clicked == '`' {
+ return None;
+ }
+
+ for quote in ['"', '\'', '`'] {
+ let mut start = None;
+ for (idx, cell) in cells.iter().copied().enumerate() {
+ let ch = cell.ch;
+ if ch != quote || is_escaped(cells, idx) {
+ continue;
+ }
+ if let Some(open) = start {
+ if clicked_idx > open
+ && clicked_idx < idx
+ && cells[open + 1..idx].iter().any(|cell| cell.ch == '/')
+ {
+ return Some(CellSpan {
+ start: open + 1,
+ end: idx - 1,
+ });
+ }
+ start = None;
+ } else {
+ start = Some(idx);
+ }
+ }
+ }
+ None
+}
+
+fn is_escaped(cells: &[TextCell], idx: usize) -> bool {
+ let mut slashes = 0;
+ let mut cursor = idx;
+ while cursor > 0 && cells[cursor - 1].ch == '\\' {
+ slashes += 1;
+ cursor -= 1;
+ }
+ slashes % 2 == 1
+}
+
+fn starts_with_chars(cells: &[TextCell], prefix: &str) -> bool {
+ prefix
+ .chars()
+ .enumerate()
+ .all(|(idx, expected)| cells.get(idx).is_some_and(|cell| cell.ch == expected))
+}
+
+fn is_word_separator(ch: char) -> bool {
+ ch.is_whitespace()
+ || matches!(
+ ch,
+ '|' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';' | '!'
+ )
+}
+
+fn trim_token_edges(cells: &[TextCell], span: CellSpan) -> Option<CellSpan> {
+ let mut start = span.start;
+ let mut end = span.end;
+ while start <= end && is_leading_token_wrapper(cells[start].ch) {
+ start += 1;
+ }
+ if start < end && cells[end].ch == '$' && is_trailing_token_wrapper(cells[end - 1].ch) {
+ end -= 1;
+ }
+ while start <= end && is_trailing_token_wrapper(cells[end].ch) {
+ if end == 0 {
+ return None;
+ }
+ end -= 1;
+ }
+ (start <= end).then_some(CellSpan { start, end })
+}
+
+fn is_leading_token_wrapper(ch: char) -> bool {
+ matches!(ch, '(' | '[' | '{' | '<' | '"' | '\'' | '`')
+}
+
+fn is_trailing_token_wrapper(ch: char) -> bool {
+ matches!(
+ ch,
+ ')' | ']' | '}' | '>' | '"' | '\'' | '`' | '.' | ',' | ';' | ':' | '!' | '?'
+ )
}
// ---------------------------------------------------------------------------
@@ -1625,6 +1932,179 @@ mod tests {
});
}
+ fn selected_word(row: &str, col: u16) -> Option<String> {
+ let (start, end) = word_bounds_at_column(row, col)?;
+ Some(text_in_cell_range(row, start, end))
+ }
+
+ fn text_in_cell_range(row: &str, start_col: u16, end_col: u16) -> String {
+ text_cells(row)
+ .into_iter()
+ .filter(|cell| cell.start_col >= start_col && cell.end_col <= end_col)
+ .map(|cell| cell.ch)
+ .collect()
+ }
+
+ fn col_of(row: &str, needle: &str) -> u16 {
+ let byte_idx = row
+ .find(needle)
+ .unwrap_or_else(|| panic!("{needle:?} not found in {row:?}"));
+ let prefix = &row[..byte_idx];
+ prefix
+ .chars()
+ .map(|ch| UnicodeWidthChar::width(ch).unwrap_or(0) as u16)
+ .sum()
+ }
+
+ fn assert_selects(row: &str, click: &str, expected: &str) {
+ assert_eq!(
+ selected_word(row, col_of(row, click)).as_deref(),
+ Some(expected),
+ "row={row:?}, click={click:?}"
+ );
+ }
+
+ fn assert_selects_nothing(row: &str, click: &str) {
+ assert_eq!(
+ selected_word(row, col_of(row, click)),
+ None,
+ "row={row:?}, click={click:?}"
+ );
+ }
+
+ #[test]
+ fn double_click_word_bounds_cover_terminal_text() {
+ let cases = [
+ (
+ "see https://example.com/a-b_c?q=x@y.",
+ "example.com",
+ "https://example.com/a-b_c?q=x@y",
+ ),
+ (
+ "open \"https://example.com/a,b;c?q=x\";",
+ "example.com",
+ "https://example.com/a,b;c?q=x",
+ ),
+ (
+ "see https://en.wikipedia.org/wiki/Foo_(bar_(baz)),",
+ "wikipedia",
+ "https://en.wikipedia.org/wiki/Foo_(bar_(baz))",
+ ),
+ (
+ "see https://example.com/a(b[c{d}e]f),",
+ "example.com",
+ "https://example.com/a(b[c{d}e]f)",
+ ),
+ (
+ "see (https://example.com/a(b(c)d)))",
+ "example.com",
+ "https://example.com/a(b(c)d)",
+ ),
+ (
+ "open /tmp/foo-bar/baz_qux/",
+ "foo-bar",
+ "/tmp/foo-bar/baz_qux/",
+ ),
+ (
+ "open ./src/app/actions.rs:795",
+ "actions",
+ "./src/app/actions.rs:795",
+ ),
+ (
+ "open ../herdr-worktrees/issue-1",
+ "herdr",
+ "../herdr-worktrees/issue-1",
+ ),
+ (
+ "edit src/app/actions.rs,then",
+ "actions",
+ "src/app/actions.rs",
+ ),
+ (
+ "cat \"/tmp/build output/log.txt\"",
+ "output",
+ "/tmp/build output/log.txt",
+ ),
+ (
+ "cat '/Users/me/Library/Application Support/app/config.json'",
+ "Support",
+ "/Users/me/Library/Application Support/app/config.json",
+ ),
+ ("echo 你好-world done", "好", "你好-world"),
+ ("先跑 cargo test", "cargo", "cargo"),
+ (
+ "export PATH=$HOME/.cargo/bin:$PATH",
+ "$HOME",
+ "PATH=$HOME/.cargo/bin:$PATH",
+ ),
+ (
+ "git checkout feature/foo-bar_baz",
+ "foo",
+ "feature/foo-bar_baz",
+ ),
+ ("refs #123 and @owner/name", "#123", "#123"),
+ ("refs #123 and @owner/name", "owner", "@owner/name"),
+ ("cargo test --package=herdr", "--package", "--package=herdr"),
+ (
+ "cargo test app::actions::tests",
+ "app::",
+ "app::actions::tests",
+ ),
+ (
+ "image ghcr.io/org/app:latest",
+ "ghcr",
+ "ghcr.io/org/app:latest",
+ ),
+ ("ERROR [worker-1] request_id=abc-123", "worker", "worker-1"),
+ (
+ "tmux|newhoo|fixhoo|newmoo|notification|window_bell|herdr",
+ "newhoo",
+ "newhoo",
+ ),
+ (
+ "render_status_line(app, area)",
+ "render",
+ "render_status_line",
+ ),
+ ("render_status_line(app, area)", "app", "app"),
+ ("render_status_line(app, area)", "area", "area"),
+ ("if !enabled {", "enabled", "enabled"),
+ ("println!(\"hi\")", "println", "println"),
+ ("( master)$", "master", "master"),
+ ("regex foo$", "foo", "foo$"),
+ ];
+
+ for (row, click, expected) in cases {
+ assert_selects(row, click, expected);
+ }
+
+ let row = "echo 你好-world done";
+ assert_eq!(
+ selected_word(row, col_of(row, "好") + 1).as_deref(),
+ Some("你好-world")
+ );
+ }
+
+ #[test]
+ fn double_click_word_bounds_ignore_delimiters() {
+ for (row, click) in [
+ (
+ "tmux|newhoo|fixhoo|newmoo|notification|window_bell|herdr",
+ "|",
+ ),
+ ("alpha,beta;gamma", ","),
+ ("alpha,beta;gamma", ";"),
+ ("render_status_line(app, area)", "("),
+ ("render_status_line(app, area)", ")"),
+ ("if !enabled {", "!"),
+ ("if !enabled {", "{"),
+ ("(done).", "("),
+ ("(done).", "."),
+ ] {
+ assert_selects_nothing(row, click);
+ }
+ }
+
#[test]
fn navigator_rows_show_tab_nodes_only_for_multi_tab_workspaces() {
let mut state = app_with_workspaces(&["single", "multi"]);
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");
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 0b4c792c..e4ec2751 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -36,6 +36,8 @@ const GIT_REMOTE_STATUS_REFRESH_INTERVAL: Duration = Duration::from_millis(1500)
const AUTO_UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(30 * 60);
const SESSION_SAVE_DEBOUNCE: Duration = Duration::from_secs(5);
const SIDEBAR_DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(350);
+const PANE_DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(350);
+const PANE_COPY_HIGHLIGHT_DURATION: Duration = Duration::from_millis(500);
use crossterm::{
event::{DisableMouseCapture, EnableMouseCapture},
@@ -61,6 +63,23 @@ pub(crate) struct OverlayPaneState {
temp_files: Vec<std::path::PathBuf>,
}
+#[derive(Debug, Clone, Copy)]
+pub(crate) struct PaneClickState {
+ pane_id: crate::layout::PaneId,
+ viewport_row: u16,
+ col: u16,
+ at: Instant,
+}
+
+impl PaneClickState {
+ fn is_double_click_for(self, next: Self) -> bool {
+ self.pane_id == next.pane_id
+ && next.at.duration_since(self.at) <= PANE_DOUBLE_CLICK_WINDOW
+ && self.viewport_row.abs_diff(next.viewport_row) <= 1
+ && self.col.abs_diff(next.col) <= 1
+ }
+}
+
pub struct App {
pub state: AppState,
pub(crate) terminal_runtimes: crate::terminal::TerminalRuntimeRegistry,
@@ -77,10 +96,12 @@ pub struct App {
pub(crate) last_git_remote_status_refresh: Instant,
pub(crate) git_refresh_in_flight: bool,
pub(crate) last_sidebar_divider_click: Option<Instant>,
+ pub(crate) last_pane_click: Option<PaneClickState>,
pub(crate) next_resize_poll: Instant,
pub(crate) next_animation_tick: Option<Instant>,
pub(crate) next_auto_update_check: Option<Instant>,
pub(crate) selection_autoscroll_deadline: Option<Instant>,
+ pub(crate) selection_highlight_clear_deadline: Option<Instant>,
pub(crate) session_save_deadline: Option<Instant>,
pub(crate) persist_pane_history: bool,
pub(crate) last_render_at: Option<Instant>,
@@ -532,12 +553,14 @@ impl App {
last_git_remote_status_refresh: Instant::now() - GIT_REMOTE_STATUS_REFRESH_INTERVAL,
git_refresh_in_flight: false,
last_sidebar_divider_click: None,
+ last_pane_click: None,
next_resize_poll: Instant::now() + RESIZE_POLL_INTERVAL,
next_animation_tick: None,
next_auto_update_check: auto_updates_enabled(no_session)
.then_some(Instant::now() + AUTO_UPDATE_CHECK_INTERVAL),
session_save_deadline: None,
selection_autoscroll_deadline: None,
+ selection_highlight_clear_deadline: None,
persist_pane_history: config.experimental.pane_history,
last_render_at: None,
suppressed_repeat_keys: HashSet::new(),
diff --git a/src/app/runtime.rs b/src/app/runtime.rs
index 85bdb8dd..bbef7444 100644
--- a/src/app/runtime.rs
+++ b/src/app/runtime.rs
@@ -179,6 +179,8 @@ impl App {
changed = true;
}
+ changed |= self.clear_due_selection_highlight(now);
+
self.start_git_status_refresh_if_due(now);
if self
@@ -199,6 +201,28 @@ impl App {
changed
}
+ /// Clears temporary copied-token highlights, such as after double-click copy.
+ pub(crate) fn clear_due_selection_highlight(&mut self, now: Instant) -> bool {
+ if self
+ .selection_highlight_clear_deadline
+ .is_none_or(|deadline| now < deadline)
+ {
+ return false;
+ }
+
+ self.selection_highlight_clear_deadline = None;
+ if self
+ .state
+ .selection
+ .as_ref()
+ .is_some_and(|selection| !selection.is_in_progress())
+ {
+ self.state.clear_selection();
+ return true;
+ }
+ false
+ }
+
pub(crate) fn sync_animation_timer(&mut self, now: Instant) {
self.sync_animation_timer_with_interval(now, ANIMATION_INTERVAL);
}
@@ -412,6 +436,7 @@ impl App {
self.next_auto_update_check,
self.session_save_deadline,
self.selection_autoscroll_deadline,
+ self.selection_highlight_clear_deadline,
render_deadline,
]
.into_iter()
diff --git a/src/selection.rs b/src/selection.rs
index 016cf649..35e44c1d 100644
--- a/src/selection.rs
+++ b/src/selection.rs
@@ -7,6 +7,8 @@
//! MouseUp → Text extracted, copied via OSC 52, highlight stays
//! Next click / key → Selection cleared
//!
+//! Double-click copy also briefly highlights the selected word.
+//!
//! Rows are stored in screen-buffer coordinates instead of viewport-relative
//! coordinates. That keeps selection stable while the pane scrolls.
@@ -59,6 +61,23 @@ impl Selection {
}
}
+ /// Create an active selection from an explicit viewport-row range.
+ pub(crate) fn range(
+ pane_id: PaneId,
+ viewport_row: u16,
+ start_col: u16,
+ end_col: u16,
+ metrics: Option<ScrollMetrics>,
+ ) -> Self {
+ let row = absolute_row_for_viewport_row(viewport_row, metrics);
+ Self {
+ pane_id,
+ anchor: (row, start_col),
+ cursor: (row, end_col),
+ phase: Phase::Dragging,
+ }
+ }
+
/// Convert the anchor's absolute row and pane-relative column back to
/// screen coordinates. Adds the pane origin before clamping so the
/// returned (screen_row, screen_col) can be compared directly against
@@ -114,6 +133,11 @@ impl Selection {
self.phase == Phase::Dragging || self.phase == Phase::Done
}
+ /// Whether this selection was already finalized and copied.
+ pub fn is_done(&self) -> bool {
+ self.phase == Phase::Done
+ }
+
/// Whether the user just clicked without dragging (not a selection).
pub fn was_just_click(&self) -> bool {
self.phase == Phase::Anchored
diff --git a/src/server/headless.rs b/src/server/headless.rs
index 2b8550b4..4d9a2cc5 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -2449,6 +2449,8 @@ impl HeadlessServer {
changed = true;
}
+ changed |= self.app.clear_due_selection_highlight(now);
+
self.app.start_git_status_refresh_if_due(now);
if self