aboutsummaryrefslogtreecommitdiff
path: root/src/app/actions.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/actions.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/actions.rs')
-rw-r--r--src/app/actions.rs486
1 files changed, 483 insertions, 3 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"]);