aboutsummaryrefslogtreecommitdiff
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
parentb11ac859c738b50ca569d9ac998d5baeeb30d33d (diff)
fix: preserve whole-word selection while dragging (#3969)
refs #1836
-rw-r--r--docs/next/website/src/content/docs/quick-start.mdx2
-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
8 files changed, 680 insertions, 259 deletions
diff --git a/docs/next/website/src/content/docs/quick-start.mdx b/docs/next/website/src/content/docs/quick-start.mdx
index 5ac2d56d..8b34de91 100644
--- a/docs/next/website/src/content/docs/quick-start.mdx
+++ b/docs/next/website/src/content/docs/quick-start.mdx
@@ -17,7 +17,7 @@ When a session has no workspaces, Herdr opens one automatically. A workspace is
## Use the mouse
-Herdr is mouse-native, so start by clicking panes, tabs, workspaces, and agents to focus them. Drag split borders to resize. Right-click for context menus, including splitting panes and creating tabs. Drag-select text to copy it to your clipboard; double-click a token to copy it directly. Copying does not require Ctrl+C.
+Herdr is mouse-native, so start by clicking panes, tabs, workspaces, and agents to focus them. Drag split borders to resize. Right-click for context menus, including splitting panes and creating tabs. Drag-select text to copy it to your clipboard. Double-click a token to select it, or hold the second press and drag to extend the selection by whole words. Both gestures copy when you release the mouse. Copying does not require Ctrl+C.
Ctrl-click opens pane links when your terminal sends the modified click to Herdr. This works for OSC 8 hyperlinks and visible `http://` or `https://` URLs. On macOS, use Ctrl-click for Herdr-handled pane links while mouse capture is enabled; Cmd-click is only available through the terminal-native bypass path, such as Shift-Cmd-click or `ui.mouse_capture = false`.
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)
+ }
+}