diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-11 22:58:01 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-11 22:58:01 +0300 |
| commit | a0474ae770ed185f6531a3c69ab64c60bed2c670 (patch) | |
| tree | ae924822c99383aff5d40f11eb89fa21104194c8 /src | |
| parent | b11ac859c738b50ca569d9ac998d5baeeb30d33d (diff) | |
fix: preserve whole-word selection while dragging (#3969)
refs #1836
Diffstat (limited to 'src')
| -rw-r--r-- | src/client/shell.rs | 2 | ||||
| -rw-r--r-- | src/client/shell/actions.rs | 99 | ||||
| -rw-r--r-- | src/client/shell/input.rs | 4 | ||||
| -rw-r--r-- | src/client/shell/mouse.rs | 86 | ||||
| -rw-r--r-- | src/client/shell/state.rs | 90 | ||||
| -rw-r--r-- | src/client/shell/tests/mouse_selection.rs | 440 | ||||
| -rw-r--r-- | src/client/shell/word_selection.rs | 216 |
7 files changed, 679 insertions, 258 deletions
diff --git a/src/client/shell.rs b/src/client/shell.rs index 98b77d25..4c2fc6cd 100644 --- a/src/client/shell.rs +++ b/src/client/shell.rs @@ -31,7 +31,9 @@ mod scroll; mod settings; mod state; mod surface_patch; +mod word_selection; mod worktrees; +use word_selection::ClientWordSelection; pub(in crate::client::shell) use render::sidebar; pub(crate) use state::*; diff --git a/src/client/shell/actions.rs b/src/client/shell/actions.rs index 119988f6..7fb2b6ce 100644 --- a/src/client/shell/actions.rs +++ b/src/client/shell/actions.rs @@ -294,54 +294,6 @@ impl ClientShellState { ); } - pub(super) fn request_word_selection( - &mut self, - hit: &PaneHit, - viewport_row: u16, - col: u16, - outcome: &mut ClientShellInput, - ) { - let absolute_row = crate::selection::absolute_row_for_viewport(viewport_row, hit.scroll); - let content_revision = self - .pane_surface - .as_ref() - .and_then(|surface| { - surface - .panes - .iter() - .find(|pane| pane.pane_id == hit.pane_id) - }) - .map(|pane| pane.content_revision); - self.word_selection_generation = self.word_selection_generation.saturating_add(1); - let generation = self.word_selection_generation; - self.pending_word_selection = Some(generation); - if !self.push_endpoint_method_with_kind( - crate::api::schema::Method::PaneSelectionRead( - crate::api::schema::PaneSelectionReadParams { - pane_id: hit.pane_id.clone(), - anchor: crate::api::schema::PaneTextPoint { - row: absolute_row, - col: 0, - }, - cursor: crate::api::schema::PaneTextPoint { - row: absolute_row, - col: hit.inner_rect.width.saturating_sub(1), - }, - content_revision, - }, - ), - PendingEndpointKind::WordSelection { - pane_id: hit.pane_id.clone(), - absolute_row, - col, - generation, - }, - outcome, - ) { - self.pending_word_selection = None; - } - } - pub(super) fn push_endpoint_method( &mut self, method: crate::api::schema::Method, @@ -666,58 +618,9 @@ impl ClientShellState { PendingEndpointKind::WordSelection { pane_id, absolute_row, - col, generation, } => { - if self.pending_word_selection != Some(generation) - || self.snapshot.as_deref().is_none_or(|snapshot| { - !snapshot.panes.iter().any(|pane| pane.pane_id == pane_id) - }) - { - return (false, Vec::new()); - } - self.pending_word_selection = None; - let row_text = match result { - Ok(crate::api::schema::ResponseResult::PaneSelection { - pane_id: returned_pane_id, - text, - }) if returned_pane_id == pane_id => text, - Ok(crate::api::schema::ResponseResult::PaneSelection { .. }) => { - return (false, Vec::new()) - } - Ok(_) => { - self.endpoint_error = Some( - "endpoint returned an unexpected word-selection result".to_owned(), - ); - return (true, Vec::new()); - } - Err(_) => return (true, Vec::new()), - }; - let Some((start_col, end_col)) = - crate::app::actions::word_bounds_at_column(&row_text, col) - else { - self.selection = None; - return (true, Vec::new()); - }; - let mut selection = crate::selection::Selection::absolute_range( - pane_id, - (absolute_row, start_col), - (absolute_row, end_col), - ); - if !selection.finish() { - return (false, Vec::new()); - } - self.selection = Some(selection); - self.selection_autoscroll = None; - self.selection_autoscroll_deadline = None; - if !self.config.copy_on_select { - return (true, Vec::new()); - } - self.selection_highlight_clear_deadline = - Some(std::time::Instant::now() + std::time::Duration::from_millis(500)); - let mut outcome = ClientShellInput::default(); - self.request_selection_copy(&mut outcome, false); - return (true, outcome.actions); + return self.complete_word_selection_row(pane_id, absolute_row, generation, result); } PendingEndpointKind::PaneLinkActivate { pane_id, diff --git a/src/client/shell/input.rs b/src/client/shell/input.rs index 41a9f64c..8a3684a3 100644 --- a/src/client/shell/input.rs +++ b/src/client/shell/input.rs @@ -134,7 +134,7 @@ impl ClientShellState { outcome.repaint = true; return true; } - self.pending_word_selection = None; + self.word_selection_gesture = None; if self.copy_or_terminal_mode() != ClientShellMode::Copy && self.selection.take().is_some() { self.stop_selection_autoscroll(); @@ -526,7 +526,7 @@ impl ClientShellState { if matches!(key.code, KeyCode::Modifier(_)) { return None; } - self.pending_word_selection = None; + self.word_selection_gesture = None; if self.mode != ClientShellMode::Copy && self.copy_or_terminal_mode() != ClientShellMode::Copy && !self.config.copy_on_select diff --git a/src/client/shell/mouse.rs b/src/client/shell/mouse.rs index af324174..912e81ed 100644 --- a/src/client/shell/mouse.rs +++ b/src/client/shell/mouse.rs @@ -162,14 +162,44 @@ impl ClientShellState { ) } + fn active_selection_pane(&self) -> Option<PaneHit> { + let pane_id = if let Some(gesture) = self.word_selection_gesture.as_ref() { + if gesture.released { + return None; + } + &gesture.pane_id + } else { + &self + .selection + .as_ref() + .filter(|selection| selection.is_in_progress())? + .pane_id + }; + self.hits + .panes + .iter() + .find(|hit| &hit.pane_id == pane_id) + .cloned() + } + fn update_selection_cursor_with_metrics( &mut self, hit: &PaneHit, column: u16, row: u16, metrics: Option<crate::pane::ScrollMetrics>, + outcome: &mut ClientShellInput, ) { - if let Some(selection) = self.selection.as_mut() { + if self.word_selection_gesture.is_some() { + let viewport_row = row + .saturating_sub(hit.inner_rect.y) + .min(hit.inner_rect.height.saturating_sub(1)); + let col = column + .saturating_sub(hit.inner_rect.x) + .min(hit.inner_rect.width.saturating_sub(1)); + let absolute_row = crate::selection::absolute_row_for_viewport(viewport_row, metrics); + self.drag_word_selection((absolute_row, col), outcome); + } else if let Some(selection) = self.selection.as_mut() { selection.drag(column, row, hit.inner_rect, metrics); } } @@ -190,8 +220,11 @@ impl ClientShellState { let (anchor_row, anchor_col) = selection.anchor_screen_pos(hit.inner_rect, metrics); anchor_row != row || anchor_col != column }); - let is_dragging = was_dragging || moved_from_anchor; - self.update_selection_cursor_with_metrics(hit, column, row, metrics); + self.update_selection_cursor_with_metrics(hit, column, row, metrics, outcome); + let is_dragging = self + .word_selection_gesture + .as_ref() + .map_or(was_dragging || moved_from_anchor, |gesture| gesture.dragged); if is_dragging { if let Some(selection) = self.selection.as_mut() { if selection.is_just_click() { @@ -244,7 +277,7 @@ impl ClientShellState { offset_from_bottom, ..metrics }; - self.update_selection_cursor_with_metrics(hit, column, row, Some(projected)); + self.update_selection_cursor_with_metrics(hit, column, row, Some(projected), outcome); self.push_pane_scroll_offset(hit.pane_id.clone(), offset_from_bottom, outcome); } self.selection_autoscroll = Some(ClientSelectionAutoscroll { @@ -268,20 +301,10 @@ impl ClientShellState { if !matches!( mouse.kind, MouseEventKind::ScrollUp | MouseEventKind::ScrollDown - ) || !self - .selection - .as_ref() - .is_some_and(crate::selection::Selection::is_in_progress) - { + ) { return false; } - let Some(hit) = self.selection.as_ref().and_then(|selection| { - self.hits - .panes - .iter() - .find(|hit| hit.pane_id == selection.pane_id) - .cloned() - }) else { + let Some(hit) = self.active_selection_pane() else { return false; }; let Some(metrics) = self.selection_scroll_metrics(&hit) else { @@ -307,6 +330,7 @@ impl ClientShellState { mouse.column, mouse.row, Some(projected), + outcome, ); self.push_pane_scroll_offset(hit.pane_id, offset_from_bottom, outcome); outcome.repaint = true; @@ -344,9 +368,15 @@ impl ClientShellState { self.selection_autoscroll_deadline = None; return outcome; }; - if !self.selection.as_ref().is_some_and(|selection| { - selection.pane_id == autoscroll.pane_id && selection.is_dragging() - }) { + let dragging = self.word_selection_gesture.as_ref().map_or_else( + || { + self.selection.as_ref().is_some_and(|selection| { + selection.pane_id == autoscroll.pane_id && selection.is_dragging() + }) + }, + |gesture| gesture.pane_id == autoscroll.pane_id && gesture.dragged && !gesture.released, + ); + if !dragging { self.stop_selection_autoscroll(); return outcome; } @@ -388,6 +418,7 @@ impl ClientShellState { autoscroll.last_mouse_column, autoscroll.last_mouse_row, Some(metrics), + &mut outcome, ); self.push_pane_scroll_offset(autoscroll.pane_id.clone(), next_offset, &mut outcome); self.selection_autoscroll = Some(autoscroll); @@ -1659,13 +1690,7 @@ impl ClientShellState { } if mouse.kind == MouseEventKind::Drag(MouseButton::Left) { - let selection_hit = self.selection.as_ref().and_then(|selection| { - self.hits - .panes - .iter() - .find(|hit| hit.pane_id == selection.pane_id) - .cloned() - }); + let selection_hit = self.active_selection_pane(); if let Some(hit) = selection_hit { self.update_selection_drag(&hit, mouse.column, mouse.row, outcome); // Consume every motion, but do not rebuild a frame for every intermediate position. @@ -1674,6 +1699,13 @@ impl ClientShellState { return; } } + if mouse.kind == MouseEventKind::Up(MouseButton::Left) + && self.word_selection_gesture.is_some() + { + self.finish_word_selection(outcome); + outcome.repaint = true; + return; + } if mouse.kind == MouseEventKind::Up(MouseButton::Left) && self.selection.is_some() { self.stop_selection_autoscroll(); let copied = self @@ -1854,7 +1886,7 @@ impl ClientShellState { } self.stop_selection_autoscroll(); self.selection_highlight_clear_deadline = None; - self.pending_word_selection = None; + self.word_selection_gesture = None; let previous_pane_click = self.last_pane_click.take(); self.workspace_press = None; self.tab_press = None; diff --git a/src/client/shell/state.rs b/src/client/shell/state.rs index 9507797d..e2e406a2 100644 --- a/src/client/shell/state.rs +++ b/src/client/shell/state.rs @@ -696,7 +696,6 @@ pub(super) enum PendingEndpointKind { WordSelection { pane_id: String, absolute_row: u32, - col: u16, generation: u64, }, PaneLinkActivate { @@ -941,7 +940,7 @@ pub(crate) struct ClientShellState { pub(super) selection_autoscroll: Option<ClientSelectionAutoscroll>, pub(super) selection_autoscroll_deadline: Option<std::time::Instant>, pub(super) selection_highlight_clear_deadline: Option<std::time::Instant>, - pub(super) pending_word_selection: Option<u64>, + pub(super) word_selection_gesture: Option<ClientWordSelection>, pub(super) word_selection_generation: u64, pub(super) copy_mode: Option<ClientCopyModeState>, pub(super) copy_session_generation: u64, @@ -1099,7 +1098,7 @@ impl ClientShellState { selection_autoscroll: None, selection_autoscroll_deadline: None, selection_highlight_clear_deadline: None, - pending_word_selection: None, + word_selection_gesture: None, word_selection_generation: 0, copy_mode: None, copy_session_generation: 0, @@ -1288,7 +1287,7 @@ impl ClientShellState { self.selection_autoscroll = None; self.selection_autoscroll_deadline = None; self.selection_highlight_clear_deadline = None; - self.pending_word_selection = None; + self.word_selection_gesture = None; self.copy_mode = None; if self.mode == ClientShellMode::Copy { self.mode = ClientShellMode::Terminal; @@ -1431,18 +1430,32 @@ impl ClientShellState { { self.reveal_focused_tab = true; } - if self.selection.as_ref().is_some_and(|selection| { - snapshot.focused_pane_id.as_deref() != Some(selection.pane_id.as_str()) - || !snapshot - .panes - .iter() - .any(|pane| pane.pane_id == selection.pane_id) - }) { + let selection_focus_lost = if let Some(gesture) = self.word_selection_gesture.as_mut() { + let focused_pane = snapshot.focused_pane_id.as_deref(); + // Remember confirmed focus across intermediate snapshots with no + // focused pane, without rejecting the gesture's in-flight focus request. + gesture.focus_confirmed |= focused_pane == Some(gesture.pane_id.as_str()); + !snapshot + .panes + .iter() + .any(|pane| pane.pane_id == gesture.pane_id) + || (gesture.focus_confirmed + && focused_pane.is_some_and(|pane_id| pane_id != gesture.pane_id)) + } else { + self.selection.as_ref().is_some_and(|selection| { + snapshot.focused_pane_id.as_deref() != Some(selection.pane_id.as_str()) + || !snapshot + .panes + .iter() + .any(|pane| pane.pane_id == selection.pane_id) + }) + }; + if selection_focus_lost { self.selection = None; self.selection_autoscroll = None; self.selection_autoscroll_deadline = None; self.selection_highlight_clear_deadline = None; - self.pending_word_selection = None; + self.word_selection_gesture = None; self.last_pane_click = None; } if let Some(copy_pane_id) = self @@ -1667,7 +1680,7 @@ impl ClientShellState { self.selection_autoscroll = None; self.selection_autoscroll_deadline = None; self.selection_highlight_clear_deadline = None; - self.pending_word_selection = None; + self.word_selection_gesture = None; self.copy_mode = None; self.reset_copy_pipeline(); self.chrome_drag = None; @@ -1685,38 +1698,51 @@ impl ClientShellState { self.popup_pending = false; self.popup_pending_deadline = None; } - let selection_content_changed = self.selection.as_ref().is_some_and(|selection| { + let selection_pane = match &self.word_selection_gesture { + Some(gesture) => Some(&gesture.pane_id), + None => self.selection.as_ref().map(|selection| &selection.pane_id), + }; + let selection_content_changed = selection_pane.is_some_and(|pane_id| { let Some(previous_surface) = self.pane_surface.as_ref() else { return false; }; let previous = previous_surface .panes .iter() - .find(|pane| pane.pane_id == selection.pane_id); - let next = surface - .panes - .iter() - .find(|pane| pane.pane_id == selection.pane_id); + .find(|pane| &pane.pane_id == pane_id); + let next = surface.panes.iter().find(|pane| &pane.pane_id == pane_id); let (Some(previous), Some(next)) = (previous, next) else { return false; }; - previous.inner_rect.width != next.inner_rect.width + if previous.inner_rect.width != next.inner_rect.width || previous.inner_rect.height != next.inner_rect.height || previous.alternate_screen_active != next.alternate_screen_active - // Manual mouse selections track a live buffer range, not a content revision. - || (self.config.copy_on_select - && previous.content_revision != next.content_revision - && (!previous.content_revision.is_multiple_of(2) - || !next.content_revision.is_multiple_of(2) - || !selection_cells_unchanged( - selection, - previous_surface, - previous, - &surface, - next, - ))) + { + return true; + } + if previous.content_revision == next.content_revision { + return false; + } + match (&self.word_selection_gesture, &self.selection) { + // Word gestures cache boundaries outside the selected cells too. + (Some(_), _) => true, + (None, Some(selection)) => { + self.config.copy_on_select + && (!previous.content_revision.is_multiple_of(2) + || !next.content_revision.is_multiple_of(2) + || !selection_cells_unchanged( + selection, + previous_surface, + previous, + &surface, + next, + )) + } + (None, None) => false, + } }); if selection_content_changed { + self.word_selection_gesture = None; self.selection = None; self.stop_selection_autoscroll(); self.selection_highlight_clear_deadline = None; diff --git a/src/client/shell/tests/mouse_selection.rs b/src/client/shell/tests/mouse_selection.rs index ea78705a..0a54203e 100644 --- a/src/client/shell/tests/mouse_selection.rs +++ b/src/client/shell/tests/mouse_selection.rs @@ -300,129 +300,371 @@ fn disabled_mouse_chrome_keeps_tab_wheel_but_removes_split_drag_hits() { } #[test] -fn client_double_click_selects_and_copies_endpoint_row_word() { - // The row response may arrive on either side of the second mouse release. - for (copy_on_select, release_before_response) in - [(false, false), (false, true), (true, false), (true, true)] - { - let mut config = Config::default(); - config.ui.copy_on_select = copy_on_select; - let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); - state.set_snapshot(Box::new(snapshot())); - state.set_pane_surface(surface()); - state.compose(106, 20).expect("composed frame"); - let pane = state.hits.panes[0].clone(); - let click = || { - RawInputEvent::Mouse(crossterm::event::MouseEvent { - kind: MouseEventKind::Down(MouseButton::Left), - column: pane.inner_rect.x + 1, - row: pane.inner_rect.y, - modifiers: KeyModifiers::empty(), - }) - }; - let release = || { - RawInputEvent::Mouse(crossterm::event::MouseEvent { - kind: MouseEventKind::Up(MouseButton::Left), - column: pane.inner_rect.x + 1, - row: pane.inner_rect.y, - modifiers: KeyModifiers::empty(), - }) - }; - - state.handle_raw_events(vec![click()]); - state.handle_raw_events(vec![release()]); - assert!(state.selection.is_none(), "plain clicks must not select"); - let second = state.handle_raw_events(vec![click()]); - let ClientShellAction::Endpoint { request, .. } = second - .actions - .iter() - .find(|action| { - matches!( - action, - ClientShellAction::Endpoint { request, .. } - if matches!(request.method, crate::api::schema::Method::PaneSelectionRead(_)) - ) - }) - .expect("word-row read") - else { - unreachable!() - }; - let word_request_id = request.id.clone(); - assert!(matches!( - &request.method, - crate::api::schema::Method::PaneSelectionRead(params) - if params.anchor == crate::api::schema::PaneTextPoint { row: 0, col: 0 } - && params.cursor == crate::api::schema::PaneTextPoint { row: 0, col: 3 } - )); - +fn client_double_click_selects_word_and_copies_only_after_release() { + for (copy_on_select, release_before_response) in [(false, true), (true, false)] { + let mut state = word_drag_state(copy_on_select); + let initial = start_word_drag(&mut state); + let release = MouseEventKind::Up(MouseButton::Left); if release_before_response { - let released = state.handle_raw_events(vec![release()]); - assert!(released.actions.is_empty()); + assert!(word_drag_mouse(&mut state, release, 0, 8) + .actions + .is_empty()); } - let (repaint, actions) = state.handle_endpoint_result( - "boot-1", - &word_request_id, - Ok(crate::api::schema::ResponseResult::PaneSelection { - pane_id: "pane_1".into(), - text: "LIVE".into(), - }), - ); - assert!(repaint); - assert!(state - .selection - .as_ref() - .is_some_and(crate::selection::Selection::is_finalized)); - let deadline = state.selection_highlight_clear_deadline; + let mut actions = word_row_reply(&mut state, &initial, "alpha bravo charlie"); if !release_before_response { - let released = state.handle_raw_events(vec![release()]); - assert!(released.actions.is_empty(), "release must not copy twice"); + assert!(actions.is_empty(), "holding the second press must not copy"); + assert!(state.selection.as_ref().unwrap().is_in_progress()); + state.tick_copy_feedback(std::time::Instant::now() + std::time::Duration::from_secs(1)); + assert!(state.selection.as_ref().unwrap().is_visible()); + assert!(state.copy_feedback.is_none()); + actions = word_drag_mouse(&mut state, release, 0, 8).actions; } - assert!( - state.selection.as_ref().is_some_and(crate::selection::Selection::is_finalized), - "mouse release must retain the finalized word selection (copy_on_select={copy_on_select})" - ); + assert!(state.selection.as_ref().unwrap().is_finalized()); assert_eq!( state.selection.as_ref().unwrap().ordered_cells(), - ((0, 0), (0, 3)) + ((0, 6), (0, 10)) ); - assert_eq!(state.selection_highlight_clear_deadline, deadline); - if !copy_on_select { + assert!( + word_drag_mouse(&mut state, release, 0, 8) + .actions + .is_empty(), + "copy only once" + ); + if copy_on_select { + assert!( + matches!(&actions[..], [ClientShellAction::Endpoint { request, .. }] + if matches!(&request.method, crate::api::schema::Method::PaneSelectionRead(params) + if params.anchor.col == 6 && params.cursor.col == 10)) + ); + let copied = word_row_reply(&mut state, &word_read_id(&actions), "bravo"); + assert!( + matches!(&copied[..], [ClientShellAction::ClipboardWrite(bytes)] if bytes == b"bravo") + ); + assert!(state.tick_copy_feedback(state.selection_highlight_clear_deadline.unwrap())); + assert!(state.selection.is_none()); + } else { assert!(actions.is_empty(), "manual selection must not auto-copy"); - assert!(state.selection_highlight_clear_deadline.is_none()); state.tick_copy_feedback(std::time::Instant::now() + std::time::Duration::from_secs(1)); assert!( state.selection.is_some(), "manual selection must not expire" ); - continue; } - let [ClientShellAction::Endpoint { request, .. }] = &actions[..] else { - panic!("auto-copy should read the selected word"); - }; - let copy_request_id = request.id.clone(); - assert!(matches!( - &request.method, - crate::api::schema::Method::PaneSelectionRead(params) - if params.anchor.col == 0 && params.cursor.col == 3 - )); - let (_, actions) = state.handle_endpoint_result( + } +} + +fn word_drag_state(copy_on_select: bool) -> ClientShellState { + let mut config = Config::default(); + config.ui.copy_on_select = copy_on_select; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + let buffer = Buffer::with_lines([ + "alpha bravo charlie", + "delta echo foxtrot ", + "golf hotel india ", + ]); + pane_surface.frame = FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, None, &[]); + pane_surface.panes[0].rect.width = 19; + pane_surface.panes[0].rect.height = 3; + pane_surface.panes[0].inner_rect = pane_surface.panes[0].rect; + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + state +} + +fn word_drag_mouse( + state: &mut ClientShellState, + kind: MouseEventKind, + row: u16, + col: u16, +) -> ClientShellInput { + let pane = state.hits.panes[0].clone(); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind, + column: pane.inner_rect.x + col, + row: pane.inner_rect.y + row, + modifiers: KeyModifiers::empty(), + })]) +} + +fn word_read_id(actions: &[ClientShellAction]) -> String { + actions + .iter() + .find_map(|action| match action { + ClientShellAction::Endpoint { request, .. } + if matches!( + request.method, + crate::api::schema::Method::PaneSelectionRead(_) + ) => + { + Some(request.id.clone()) + } + _ => None, + }) + .expect("selection read") +} + +fn word_row_reply(state: &mut ClientShellState, id: &str, text: &str) -> Vec<ClientShellAction> { + state + .handle_endpoint_result( "boot-1", - ©_request_id, + id, Ok(crate::api::schema::ResponseResult::PaneSelection { pane_id: "pane_1".into(), - text: "LIVE".into(), + text: text.into(), }), + ) + .1 +} + +fn start_word_drag(state: &mut ClientShellState) -> String { + word_drag_mouse(state, MouseEventKind::Down(MouseButton::Left), 0, 8); + word_drag_mouse(state, MouseEventKind::Up(MouseButton::Left), 0, 8); + assert!(state.selection.is_none(), "plain clicks must not select"); + let second = word_drag_mouse(state, MouseEventKind::Down(MouseButton::Left), 0, 8); + assert!(second.actions.iter().any(|action| matches!(action, ClientShellAction::Endpoint { request, .. } + if matches!(&request.method, crate::api::schema::Method::PaneSelectionRead(params) + if params.anchor.col == 0 && params.cursor.col == state.hits.panes[0].inner_rect.width - 1)))); + word_read_id(&second.actions) +} + +#[test] +fn double_click_drag_selects_whole_words_in_both_directions() { + let mut state = word_drag_state(false); + let initial = start_word_drag(&mut state); + word_row_reply(&mut state, &initial, "alpha bravo charlie"); + for (col, expected) in [ + (14, ((0, 6), (0, 18))), + (2, ((0, 0), (0, 10))), + (8, ((0, 6), (0, 10))), + (11, ((0, 6), (0, 11))), + (16, ((0, 6), (0, 18))), + ] { + let motion = word_drag_mouse(&mut state, MouseEventKind::Drag(MouseButton::Left), 0, col); + assert!( + motion.actions.is_empty(), + "reuse the row while dragging within it" + ); + assert_eq!(state.selection.as_ref().unwrap().ordered_cells(), expected); + } + assert!( + word_drag_mouse(&mut state, MouseEventKind::Up(MouseButton::Left), 0, 16) + .actions + .is_empty() + ); + assert!(state.selection.as_ref().unwrap().is_finalized()); +} + +#[test] +fn double_click_drag_waits_for_latest_row_before_copying() { + for release_before_anchor in [false, true] { + let mut state = word_drag_state(true); + let initial = start_word_drag(&mut state); + if !release_before_anchor { + assert!(word_row_reply(&mut state, &initial, "alpha bravo charlie").is_empty()); + } + let first_motion = + word_drag_mouse(&mut state, MouseEventKind::Drag(MouseButton::Left), 1, 8); + for col in [1, 3, 7] { + assert!( + word_drag_mouse(&mut state, MouseEventKind::Drag(MouseButton::Left), 2, col) + .actions + .is_empty() + ); + } + assert!( + word_drag_mouse(&mut state, MouseEventKind::Up(MouseButton::Left), 2, 7) + .actions + .is_empty() + ); + let final_read = if release_before_anchor { + word_row_reply(&mut state, &initial, "alpha bravo charlie") + } else { + word_row_reply( + &mut state, + &word_read_id(&first_motion.actions), + "delta echo foxtrot", + ) + }; + assert!( + matches!(&final_read[..], [ClientShellAction::Endpoint { request, .. }] + if matches!(&request.method, crate::api::schema::Method::PaneSelectionRead(params) + if params.anchor.row == 2 && params.cursor.row == 2)) + ); + let copy = word_row_reply(&mut state, &word_read_id(&final_read), "golf hotel india"); + assert!( + matches!(©[..], [ClientShellAction::Endpoint { request, .. }] + if matches!(&request.method, crate::api::schema::Method::PaneSelectionRead(params) + if params.anchor == crate::api::schema::PaneTextPoint { row: 0, col: 6 } + && params.cursor == crate::api::schema::PaneTextPoint { row: 2, col: 9 })) + ); + let copied = word_row_reply( + &mut state, + &word_read_id(©), + "bravo charlie\ndelta echo foxtrot\ngolf hotel", + ); + assert!( + matches!(&copied[..], [ClientShellAction::ClipboardWrite(bytes)] + if bytes == b"bravo charlie\ndelta echo foxtrot\ngolf hotel") + ); + } +} + +#[test] +fn double_click_drag_ignores_row_reply_after_typing_or_new_click() { + for typing in [false, true] { + let mut state = word_drag_state(false); + let initial = start_word_drag(&mut state); + word_row_reply(&mut state, &initial, "alpha bravo charlie"); + let drag = word_drag_mouse(&mut state, MouseEventKind::Drag(MouseButton::Left), 1, 8); + let row_id = word_read_id(&drag.actions); + if typing { + state.handle_input_bytes(b"x"); + } else { + word_drag_mouse(&mut state, MouseEventKind::Down(MouseButton::Left), 0, 0); + word_drag_mouse(&mut state, MouseEventKind::Up(MouseButton::Left), 0, 0); + } + assert!(word_row_reply(&mut state, &row_id, "delta echo foxtrot").is_empty()); + assert!(state.selection.is_none()); + } +} + +#[test] +fn double_click_drag_survives_focus_lag_after_anchor_reply() { + let mut state = word_drag_state(true); + let initial = start_word_drag(&mut state); + word_row_reply(&mut state, &initial, "alpha bravo charlie"); + let mut lagging = snapshot(); + lagging.focused_pane_id = None; + lagging.panes[0].focused = false; + state.set_snapshot(Box::new(lagging)); + assert!(state.selection.is_some()); + word_drag_mouse(&mut state, MouseEventKind::Drag(MouseButton::Left), 0, 14); + assert_eq!( + state.selection.as_ref().unwrap().ordered_cells(), + ((0, 6), (0, 18)) + ); + let released = word_drag_mouse(&mut state, MouseEventKind::Up(MouseButton::Left), 0, 14); + assert_eq!(released.actions.len(), 1); +} + +#[test] +fn double_click_drag_invalidates_cached_boundaries_outside_selected_cells() { + for copy_on_select in [false, true] { + let mut state = word_drag_state(copy_on_select); + let initial = start_word_drag(&mut state); + word_row_reply(&mut state, &initial, "alpha bravo charlie"); + let mut changed = state.pane_surface.as_ref().unwrap().clone(); + changed.surface_revision += 1; + changed.panes[0].content_revision += 2; + changed.frame.cells[14].symbol = " ".into(); + state.set_pane_surface(changed); + assert!( + state.selection.is_none(), + "unchanged selected cells do not validate cached boundaries outside the selection" + ); + assert!( + word_drag_mouse(&mut state, MouseEventKind::Drag(MouseButton::Left), 0, 14) + .actions + .is_empty() + ); + assert!( + word_drag_mouse(&mut state, MouseEventKind::Up(MouseButton::Left), 0, 14) + .actions + .is_empty() ); - assert!(matches!( - &actions[..], - [ClientShellAction::ClipboardWrite(bytes)] if bytes == b"LIVE" - )); - assert!(state.tick_copy_feedback(deadline.expect("auto-copy highlight deadline"))); assert!(state.selection.is_none()); } } #[test] +fn double_click_release_ignores_reply_after_focus_or_content_changes() { + for focus_changed in [false, true] { + let mut state = word_drag_state(true); + let initial = start_word_drag(&mut state); + word_drag_mouse(&mut state, MouseEventKind::Up(MouseButton::Left), 0, 8); + if focus_changed { + let mut lagging = snapshot(); + lagging.focused_pane_id = None; + lagging.panes[0].focused = false; + state.set_snapshot(Box::new(lagging)); + let mut unfocused = snapshot(); + unfocused.focused_pane_id = Some("pane_2".into()); + unfocused.panes[0].focused = false; + let mut other = unfocused.panes[0].clone(); + other.pane_id = "pane_2".into(); + other.focused = true; + unfocused.panes.push(other); + state.set_snapshot(Box::new(unfocused)); + } else { + let mut changed = state.pane_surface.as_ref().unwrap().clone(); + changed.surface_revision += 1; + changed.panes[0].content_revision += 2; + state.set_pane_surface(changed); + } + assert!( + word_row_reply(&mut state, &initial, "alpha bravo charlie").is_empty(), + "a stale released gesture must not copy" + ); + assert!(state.selection.is_none()); + } +} + +#[test] +fn double_click_drag_resize_cancels_pending_word_lookup() { + for anchor_ready in [false, true] { + let mut state = word_drag_state(true); + let initial = start_word_drag(&mut state); + let pending = if anchor_ready { + word_row_reply(&mut state, &initial, "alpha bravo charlie"); + let motion = word_drag_mouse(&mut state, MouseEventKind::Drag(MouseButton::Left), 1, 8); + word_read_id(&motion.actions) + } else { + initial + }; + word_drag_mouse(&mut state, MouseEventKind::Up(MouseButton::Left), 1, 8); + let mut resized = state.pane_surface.as_ref().unwrap().clone(); + resized.surface_revision += 1; + resized.panes[0].rect.width += 5; + resized.panes[0].inner_rect.width += 5; + state.set_pane_surface(resized); + assert!(word_row_reply(&mut state, &pending, "alpha bravo charlie extra").is_empty()); + assert!( + state.selection.is_none(), + "a late reply must not restore a resized selection" + ); + assert!(state.selection_autoscroll.is_none()); + } +} + +#[test] +fn double_click_drag_autoscroll_keeps_absolute_word_anchor() { + let mut state = word_drag_state(false); + state.hits.panes[0].scroll = Some(crate::pane::ScrollMetrics { + max_offset_from_bottom: 10, + offset_from_bottom: 5, + viewport_rows: 3, + }); + let initial = start_word_drag(&mut state); + word_row_reply(&mut state, &initial, "alpha bravo charlie"); + word_drag_mouse(&mut state, MouseEventKind::Drag(MouseButton::Left), 0, 14); + let tick = state.tick_selection_autoscroll(state.selection_autoscroll_deadline.unwrap()); + word_row_reply( + &mut state, + &word_read_id(&tick.actions), + "delta echo foxtrot", + ); + assert_eq!( + state.selection.as_ref().unwrap().ordered_cells(), + ((4, 11), (5, 10)) + ); + word_drag_mouse(&mut state, MouseEventKind::Up(MouseButton::Left), 0, 14); + assert!(state.selection.as_ref().unwrap().is_finalized()); + assert!(state.selection_autoscroll.is_none()); +} + +#[test] fn pane_content_updates_preserve_active_selection_only_when_selected_cells_stay_stable() { let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); state.set_snapshot(Box::new(snapshot())); diff --git a/src/client/shell/word_selection.rs b/src/client/shell/word_selection.rs new file mode 100644 index 00000000..42a03fc2 --- /dev/null +++ b/src/client/shell/word_selection.rs @@ -0,0 +1,216 @@ +use super::*; + +/// Held second press. Keep only one row read in flight and use the latest +/// pointer position when it returns, so remote latency cannot queue up motion. +#[derive(Debug)] +pub(super) struct ClientWordSelection { + pub(super) pane_id: String, + pub(super) focus_confirmed: bool, + anchor: (u32, u16), + anchor_bounds: Option<(u16, u16)>, + cursor: (u32, u16), + end_col: u16, + content_revision: Option<u64>, + cached_row: Option<(u32, String)>, + pending_row: Option<u32>, + pub(super) dragged: bool, + pub(super) released: bool, +} + +impl ClientShellState { + pub(super) fn request_word_selection( + &mut self, + hit: &PaneHit, + viewport_row: u16, + col: u16, + outcome: &mut ClientShellInput, + ) { + let row = crate::selection::absolute_row_for_viewport(viewport_row, hit.scroll); + self.word_selection_generation = self.word_selection_generation.saturating_add(1); + self.word_selection_gesture = Some(ClientWordSelection { + pane_id: hit.pane_id.clone(), + focus_confirmed: self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.focused_pane_id.as_deref()) + == Some(hit.pane_id.as_str()), + anchor: (row, col), + anchor_bounds: None, + cursor: (row, col), + end_col: hit.inner_rect.width.saturating_sub(1), + content_revision: self.pane_surface.as_ref().and_then(|surface| { + surface + .panes + .iter() + .find(|pane| pane.pane_id == hit.pane_id) + .map(|pane| pane.content_revision) + }), + cached_row: None, + pending_row: None, + dragged: false, + released: false, + }); + self.request_word_selection_row(row, outcome); + } + + fn cancel_word_selection(&mut self) { + self.word_selection_gesture = None; + self.selection = None; + self.stop_selection_autoscroll(); + } + + fn request_word_selection_row(&mut self, row: u32, outcome: &mut ClientShellInput) { + let Some(gesture) = self.word_selection_gesture.as_mut() else { + return; + }; + if gesture.pending_row.is_some() { + return; + } + gesture.pending_row = Some(row); + let pane_id = gesture.pane_id.clone(); + let params = crate::api::schema::PaneSelectionReadParams { + pane_id: pane_id.clone(), + anchor: crate::api::schema::PaneTextPoint { row, col: 0 }, + cursor: crate::api::schema::PaneTextPoint { + row, + col: gesture.end_col, + }, + content_revision: gesture.content_revision, + }; + if !self.push_endpoint_method_with_kind( + crate::api::schema::Method::PaneSelectionRead(params), + PendingEndpointKind::WordSelection { + pane_id, + absolute_row: row, + generation: self.word_selection_generation, + }, + outcome, + ) { + self.cancel_word_selection(); + } + } + + pub(super) fn drag_word_selection( + &mut self, + cursor: (u32, u16), + outcome: &mut ClientShellInput, + ) { + let Some(gesture) = self.word_selection_gesture.as_mut() else { + return; + }; + if gesture.released || gesture.cursor == cursor { + return; + } + gesture.cursor = cursor; + gesture.dragged = true; + self.update_word_selection(outcome); + } + + pub(super) fn finish_word_selection(&mut self, outcome: &mut ClientShellInput) { + self.stop_selection_autoscroll(); + if let Some(gesture) = self.word_selection_gesture.as_mut() { + gesture.released = true; + } + // A pending row reply will finish the selection if its bounds are not ready yet. + self.update_word_selection(outcome); + } + + fn update_word_selection(&mut self, outcome: &mut ClientShellInput) { + let Some(gesture) = self.word_selection_gesture.as_ref() else { + return; + }; + let Some((anchor_start, anchor_end)) = gesture.anchor_bounds else { + return; + }; + let Some((_, text)) = gesture + .cached_row + .as_ref() + .filter(|(row, _)| *row == gesture.cursor.0) + else { + self.request_word_selection_row(gesture.cursor.0, outcome); + return; + }; + let (start_col, end_col) = + crate::app::actions::word_bounds_at_column(text, gesture.cursor.1) + .unwrap_or((gesture.cursor.1, gesture.cursor.1)); + let start = (gesture.anchor.0, anchor_start).min((gesture.cursor.0, start_col)); + let end = (gesture.anchor.0, anchor_end).max((gesture.cursor.0, end_col)); + self.selection = Some(crate::selection::Selection::absolute_range( + gesture.pane_id.clone(), + start, + end, + )); + if gesture.released { + let dragged = gesture.dragged; + if let Some(selection) = self.selection.as_mut() { + selection.finish(); + } + self.word_selection_gesture = None; + if self.config.copy_on_select { + self.request_selection_copy(outcome, false); + if dragged { + self.selection = None; + } else { + self.selection_highlight_clear_deadline = + Some(std::time::Instant::now() + std::time::Duration::from_millis(500)); + } + } + } + outcome.repaint = true; + } + + pub(super) fn complete_word_selection_row( + &mut self, + pane_id: String, + absolute_row: u32, + generation: u64, + result: Result<crate::api::schema::ResponseResult, ClientShellEndpointError>, + ) -> (bool, Vec<ClientShellAction>) { + if self.word_selection_generation != generation + || self.word_selection_gesture.as_ref().is_none_or(|gesture| { + gesture.pane_id != pane_id || gesture.pending_row != Some(absolute_row) + }) + { + return (false, Vec::new()); + } + if self + .snapshot + .as_deref() + .is_none_or(|snapshot| !snapshot.panes.iter().any(|pane| pane.pane_id == pane_id)) + { + self.cancel_word_selection(); + return (true, Vec::new()); + } + let text = match result { + Ok(crate::api::schema::ResponseResult::PaneSelection { + pane_id: returned_pane_id, + text, + }) if returned_pane_id == pane_id => text, + other => { + if matches!(other, Ok(value) if !matches!(value, crate::api::schema::ResponseResult::PaneSelection { .. })) + { + self.endpoint_error = + Some("endpoint returned an unexpected word-selection result".to_owned()); + } + self.cancel_word_selection(); + return (true, Vec::new()); + } + }; + let Some(gesture) = self.word_selection_gesture.as_mut() else { + return (false, Vec::new()); + }; + gesture.pending_row = None; + if gesture.anchor_bounds.is_none() { + gesture.anchor_bounds = + crate::app::actions::word_bounds_at_column(&text, gesture.anchor.1); + if gesture.anchor_bounds.is_none() { + self.cancel_word_selection(); + return (true, Vec::new()); + } + } + gesture.cached_row = Some((absolute_row, text)); + let mut outcome = ClientShellInput::default(); + self.update_word_selection(&mut outcome); + (outcome.repaint, outcome.actions) + } +} |
