diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-11 22:58:01 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-11 22:58:01 +0300 |
| commit | a0474ae770ed185f6531a3c69ab64c60bed2c670 (patch) | |
| tree | ae924822c99383aff5d40f11eb89fa21104194c8 /src/client/shell/tests | |
| parent | b11ac859c738b50ca569d9ac998d5baeeb30d33d (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.rs | 440 |
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", - ©_request_id, + id, Ok(crate::api::schema::ResponseResult::PaneSelection { pane_id: "pane_1".into(), - text: "LIVE".into(), + text: text.into(), }), + ) + .1 +} + +fn start_word_drag(state: &mut ClientShellState) -> String { + word_drag_mouse(state, MouseEventKind::Down(MouseButton::Left), 0, 8); + word_drag_mouse(state, MouseEventKind::Up(MouseButton::Left), 0, 8); + assert!(state.selection.is_none(), "plain clicks must not select"); + let second = word_drag_mouse(state, MouseEventKind::Down(MouseButton::Left), 0, 8); + assert!(second.actions.iter().any(|action| matches!(action, ClientShellAction::Endpoint { request, .. } + if matches!(&request.method, crate::api::schema::Method::PaneSelectionRead(params) + if params.anchor.col == 0 && params.cursor.col == state.hits.panes[0].inner_rect.width - 1)))); + word_read_id(&second.actions) +} + +#[test] +fn double_click_drag_selects_whole_words_in_both_directions() { + let mut state = word_drag_state(false); + let initial = start_word_drag(&mut state); + word_row_reply(&mut state, &initial, "alpha bravo charlie"); + for (col, expected) in [ + (14, ((0, 6), (0, 18))), + (2, ((0, 0), (0, 10))), + (8, ((0, 6), (0, 10))), + (11, ((0, 6), (0, 11))), + (16, ((0, 6), (0, 18))), + ] { + let motion = word_drag_mouse(&mut state, MouseEventKind::Drag(MouseButton::Left), 0, col); + assert!( + motion.actions.is_empty(), + "reuse the row while dragging within it" + ); + assert_eq!(state.selection.as_ref().unwrap().ordered_cells(), expected); + } + assert!( + word_drag_mouse(&mut state, MouseEventKind::Up(MouseButton::Left), 0, 16) + .actions + .is_empty() + ); + assert!(state.selection.as_ref().unwrap().is_finalized()); +} + +#[test] +fn double_click_drag_waits_for_latest_row_before_copying() { + for release_before_anchor in [false, true] { + let mut state = word_drag_state(true); + let initial = start_word_drag(&mut state); + if !release_before_anchor { + assert!(word_row_reply(&mut state, &initial, "alpha bravo charlie").is_empty()); + } + let first_motion = + word_drag_mouse(&mut state, MouseEventKind::Drag(MouseButton::Left), 1, 8); + for col in [1, 3, 7] { + assert!( + word_drag_mouse(&mut state, MouseEventKind::Drag(MouseButton::Left), 2, col) + .actions + .is_empty() + ); + } + assert!( + word_drag_mouse(&mut state, MouseEventKind::Up(MouseButton::Left), 2, 7) + .actions + .is_empty() + ); + let final_read = if release_before_anchor { + word_row_reply(&mut state, &initial, "alpha bravo charlie") + } else { + word_row_reply( + &mut state, + &word_read_id(&first_motion.actions), + "delta echo foxtrot", + ) + }; + assert!( + matches!(&final_read[..], [ClientShellAction::Endpoint { request, .. }] + if matches!(&request.method, crate::api::schema::Method::PaneSelectionRead(params) + if params.anchor.row == 2 && params.cursor.row == 2)) + ); + let copy = word_row_reply(&mut state, &word_read_id(&final_read), "golf hotel india"); + assert!( + matches!(©[..], [ClientShellAction::Endpoint { request, .. }] + if matches!(&request.method, crate::api::schema::Method::PaneSelectionRead(params) + if params.anchor == crate::api::schema::PaneTextPoint { row: 0, col: 6 } + && params.cursor == crate::api::schema::PaneTextPoint { row: 2, col: 9 })) + ); + let copied = word_row_reply( + &mut state, + &word_read_id(©), + "bravo charlie\ndelta echo foxtrot\ngolf hotel", + ); + assert!( + matches!(&copied[..], [ClientShellAction::ClipboardWrite(bytes)] + if bytes == b"bravo charlie\ndelta echo foxtrot\ngolf hotel") + ); + } +} + +#[test] +fn double_click_drag_ignores_row_reply_after_typing_or_new_click() { + for typing in [false, true] { + let mut state = word_drag_state(false); + let initial = start_word_drag(&mut state); + word_row_reply(&mut state, &initial, "alpha bravo charlie"); + let drag = word_drag_mouse(&mut state, MouseEventKind::Drag(MouseButton::Left), 1, 8); + let row_id = word_read_id(&drag.actions); + if typing { + state.handle_input_bytes(b"x"); + } else { + word_drag_mouse(&mut state, MouseEventKind::Down(MouseButton::Left), 0, 0); + word_drag_mouse(&mut state, MouseEventKind::Up(MouseButton::Left), 0, 0); + } + assert!(word_row_reply(&mut state, &row_id, "delta echo foxtrot").is_empty()); + assert!(state.selection.is_none()); + } +} + +#[test] +fn double_click_drag_survives_focus_lag_after_anchor_reply() { + let mut state = word_drag_state(true); + let initial = start_word_drag(&mut state); + word_row_reply(&mut state, &initial, "alpha bravo charlie"); + let mut lagging = snapshot(); + lagging.focused_pane_id = None; + lagging.panes[0].focused = false; + state.set_snapshot(Box::new(lagging)); + assert!(state.selection.is_some()); + word_drag_mouse(&mut state, MouseEventKind::Drag(MouseButton::Left), 0, 14); + assert_eq!( + state.selection.as_ref().unwrap().ordered_cells(), + ((0, 6), (0, 18)) + ); + let released = word_drag_mouse(&mut state, MouseEventKind::Up(MouseButton::Left), 0, 14); + assert_eq!(released.actions.len(), 1); +} + +#[test] +fn double_click_drag_invalidates_cached_boundaries_outside_selected_cells() { + for copy_on_select in [false, true] { + let mut state = word_drag_state(copy_on_select); + let initial = start_word_drag(&mut state); + word_row_reply(&mut state, &initial, "alpha bravo charlie"); + let mut changed = state.pane_surface.as_ref().unwrap().clone(); + changed.surface_revision += 1; + changed.panes[0].content_revision += 2; + changed.frame.cells[14].symbol = " ".into(); + state.set_pane_surface(changed); + assert!( + state.selection.is_none(), + "unchanged selected cells do not validate cached boundaries outside the selection" + ); + assert!( + word_drag_mouse(&mut state, MouseEventKind::Drag(MouseButton::Left), 0, 14) + .actions + .is_empty() + ); + assert!( + word_drag_mouse(&mut state, MouseEventKind::Up(MouseButton::Left), 0, 14) + .actions + .is_empty() ); - assert!(matches!( - &actions[..], - [ClientShellAction::ClipboardWrite(bytes)] if bytes == b"LIVE" - )); - assert!(state.tick_copy_feedback(deadline.expect("auto-copy highlight deadline"))); assert!(state.selection.is_none()); } } #[test] +fn double_click_release_ignores_reply_after_focus_or_content_changes() { + for focus_changed in [false, true] { + let mut state = word_drag_state(true); + let initial = start_word_drag(&mut state); + word_drag_mouse(&mut state, MouseEventKind::Up(MouseButton::Left), 0, 8); + if focus_changed { + let mut lagging = snapshot(); + lagging.focused_pane_id = None; + lagging.panes[0].focused = false; + state.set_snapshot(Box::new(lagging)); + let mut unfocused = snapshot(); + unfocused.focused_pane_id = Some("pane_2".into()); + unfocused.panes[0].focused = false; + let mut other = unfocused.panes[0].clone(); + other.pane_id = "pane_2".into(); + other.focused = true; + unfocused.panes.push(other); + state.set_snapshot(Box::new(unfocused)); + } else { + let mut changed = state.pane_surface.as_ref().unwrap().clone(); + changed.surface_revision += 1; + changed.panes[0].content_revision += 2; + state.set_pane_surface(changed); + } + assert!( + word_row_reply(&mut state, &initial, "alpha bravo charlie").is_empty(), + "a stale released gesture must not copy" + ); + assert!(state.selection.is_none()); + } +} + +#[test] +fn double_click_drag_resize_cancels_pending_word_lookup() { + for anchor_ready in [false, true] { + let mut state = word_drag_state(true); + let initial = start_word_drag(&mut state); + let pending = if anchor_ready { + word_row_reply(&mut state, &initial, "alpha bravo charlie"); + let motion = word_drag_mouse(&mut state, MouseEventKind::Drag(MouseButton::Left), 1, 8); + word_read_id(&motion.actions) + } else { + initial + }; + word_drag_mouse(&mut state, MouseEventKind::Up(MouseButton::Left), 1, 8); + let mut resized = state.pane_surface.as_ref().unwrap().clone(); + resized.surface_revision += 1; + resized.panes[0].rect.width += 5; + resized.panes[0].inner_rect.width += 5; + state.set_pane_surface(resized); + assert!(word_row_reply(&mut state, &pending, "alpha bravo charlie extra").is_empty()); + assert!( + state.selection.is_none(), + "a late reply must not restore a resized selection" + ); + assert!(state.selection_autoscroll.is_none()); + } +} + +#[test] +fn double_click_drag_autoscroll_keeps_absolute_word_anchor() { + let mut state = word_drag_state(false); + state.hits.panes[0].scroll = Some(crate::pane::ScrollMetrics { + max_offset_from_bottom: 10, + offset_from_bottom: 5, + viewport_rows: 3, + }); + let initial = start_word_drag(&mut state); + word_row_reply(&mut state, &initial, "alpha bravo charlie"); + word_drag_mouse(&mut state, MouseEventKind::Drag(MouseButton::Left), 0, 14); + let tick = state.tick_selection_autoscroll(state.selection_autoscroll_deadline.unwrap()); + word_row_reply( + &mut state, + &word_read_id(&tick.actions), + "delta echo foxtrot", + ); + assert_eq!( + state.selection.as_ref().unwrap().ordered_cells(), + ((4, 11), (5, 10)) + ); + word_drag_mouse(&mut state, MouseEventKind::Up(MouseButton::Left), 0, 14); + assert!(state.selection.as_ref().unwrap().is_finalized()); + assert!(state.selection_autoscroll.is_none()); +} + +#[test] fn pane_content_updates_preserve_active_selection_only_when_selected_cells_stay_stable() { let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); state.set_snapshot(Box::new(snapshot())); |
