aboutsummaryrefslogtreecommitdiff
path: root/src/client
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-11 22:58:01 +0300
committerGitHub <noreply@github.com>2026-09-11 22:58:01 +0300
commita0474ae770ed185f6531a3c69ab64c60bed2c670 (patch)
treeae924822c99383aff5d40f11eb89fa21104194c8 /src/client
parentb11ac859c738b50ca569d9ac998d5baeeb30d33d (diff)
fix: preserve whole-word selection while dragging (#3969)
refs #1836
Diffstat (limited to 'src/client')
-rw-r--r--src/client/shell.rs2
-rw-r--r--src/client/shell/actions.rs99
-rw-r--r--src/client/shell/input.rs4
-rw-r--r--src/client/shell/mouse.rs86
-rw-r--r--src/client/shell/state.rs90
-rw-r--r--src/client/shell/tests/mouse_selection.rs440
-rw-r--r--src/client/shell/word_selection.rs216
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",
- &copy_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!(&copy[..], [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(&copy),
+ "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)
+ }
+}