diff options
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/actions.rs | 486 | ||||
| -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 | ||||
| -rw-r--r-- | src/app/mod.rs | 23 | ||||
| -rw-r--r-- | src/app/runtime.rs | 25 |
6 files changed, 801 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() |
