aboutsummaryrefslogtreecommitdiff
path: root/src/client/shell/tests
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/shell/tests
parentb11ac859c738b50ca569d9ac998d5baeeb30d33d (diff)
fix: preserve whole-word selection while dragging (#3969)
refs #1836
Diffstat (limited to 'src/client/shell/tests')
-rw-r--r--src/client/shell/tests/mouse_selection.rs440
1 files changed, 341 insertions, 99 deletions
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()));