From 8d95e9bd45338c43091e04ef0c976086bb09163a Mon Sep 17 00:00:00 2001 From: Marco Mayer Date: Thu, 24 Sep 2026 22:12:04 +0200 Subject: fix: keep copy mode active during projection updates (#4281) * fix: keep copy mode active during projection updates refs #3812 * fix: preserve linewise selections during projection gaps refs #3812 --------- Co-authored-by: Ogulcan Celik --- src/client/shell/copy_mode.rs | 26 +++++------- src/client/shell/tests/copy.rs | 92 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 16 deletions(-) diff --git a/src/client/shell/copy_mode.rs b/src/client/shell/copy_mode.rs index 07dc8d18..be5b63e3 100644 --- a/src/client/shell/copy_mode.rs +++ b/src/client/shell/copy_mode.rs @@ -504,13 +504,10 @@ impl ClientShellState { } fn move_copy_cursor(&mut self, row_delta: i16, col_delta: i16, outcome: &mut ClientShellInput) { - let Some(hit) = self.copy_hit() else { - self.exit_copy_mode(false, outcome); - return; - }; let Some(copy_mode) = self.copy_mode.as_mut() else { return; }; + let (width, height) = copy_mode.geometry; if col_delta < 0 { copy_mode.cursor.col = copy_mode .cursor @@ -521,11 +518,11 @@ impl ClientShellState { .cursor .col .saturating_add(col_delta as u16) - .min(hit.inner_rect.width.saturating_sub(1)); + .min(width.saturating_sub(1)); } let total_rows = copy_mode .max_offset_from_bottom - .saturating_add(hit.inner_rect.height as usize) + .saturating_add(height as usize) .max(1); if row_delta < 0 { copy_mode.cursor.row = copy_mode @@ -610,16 +607,13 @@ impl ClientShellState { } fn reveal_copy_cursor(&mut self, outcome: &mut ClientShellInput, reserve_mode_bar_row: bool) { - let Some(hit) = self.copy_hit() else { - return; - }; let request = self.copy_mode.as_mut().and_then(|copy_mode| { let current_top = copy_mode .max_offset_from_bottom .saturating_sub(copy_mode.offset_from_bottom) as u32; - let max_cursor_row = hit - .inner_rect - .height + let max_cursor_row = copy_mode + .geometry + .1 .saturating_sub(if reserve_mode_bar_row { 2 } else { 1 }); let bottom = current_top.saturating_add(u32::from(max_cursor_row)); let desired_top = if copy_mode.cursor.row < current_top { @@ -647,12 +641,11 @@ impl ClientShellState { } fn begin_copy_selection(&mut self, linewise: bool) { - let end_col = self - .copy_hit() - .map_or(0, |hit| hit.inner_rect.width.saturating_sub(1)); + let width = self.copy_hit().map(|hit| hit.inner_rect.width); let Some(copy_mode) = self.copy_mode.as_mut() else { return; }; + let end_col = width.unwrap_or(copy_mode.geometry.0).saturating_sub(1); if linewise { copy_mode.selection = Some(ClientCopySelection::Linewise { anchor_row: copy_mode.cursor.row, @@ -695,7 +688,8 @@ impl ClientShellState { anchor_row, copy_mode.cursor.row, self.copy_hit() - .map_or(0, |hit| hit.inner_rect.width.saturating_sub(1)), + .map_or(copy_mode.geometry.0, |hit| hit.inner_rect.width) + .saturating_sub(1), ) } }); diff --git a/src/client/shell/tests/copy.rs b/src/client/shell/tests/copy.rs index 67e36f23..c0d528e1 100644 --- a/src/client/shell/tests/copy.rs +++ b/src/client/shell/tests/copy.rs @@ -2216,3 +2216,95 @@ fn word_selection_result_survives_focus_snapshot_lag() { .as_ref() .is_some_and(crate::selection::Selection::is_visible)); } + +#[test] +fn copy_mode_repeat_during_projection_gap_stays_active() { + for selection_before_gap in [None, Some(true), Some(false)] { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 20, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let mut enter = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::CopyMode), + &mut enter, + ); + if selection_before_gap == Some(true) { + state.handle_input_bytes(b"V"); + } + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('k'), + KeyModifiers::empty(), + ))]); + + let end_col = state.copy_mode.as_ref().expect("copy mode").geometry.0 - 1; + let mut next = snapshot(); + next.revision += 1; + state.set_snapshot(Box::new(next)); + assert!(state.hits.panes.is_empty()); + assert_eq!(state.mode, ClientShellMode::Copy); + if selection_before_gap == Some(false) { + state.handle_input_bytes(b"V"); + assert_eq!( + state + .selection + .as_ref() + .expect("linewise selection") + .ordered_cells(), + ((20, 0), (20, end_col)) + ); + } + + let kind = if selection_before_gap == Some(false) { + crossterm::event::KeyEventKind::Press + } else { + crossterm::event::KeyEventKind::Repeat + }; + let moved = state.handle_raw_events(vec![RawInputEvent::Key( + crate::input::TerminalKey::new(KeyCode::Char('k'), KeyModifiers::empty()) + .with_kind(kind), + )]); + assert!(moved.actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!(&request.method, crate::api::schema::Method::PaneScroll(params) + if params.pane_id == "pane_1" && params.offset_from_bottom == 1) + ))); + if selection_before_gap.is_some() { + assert_eq!( + state + .selection + .as_ref() + .expect("linewise selection") + .ordered_cells(), + ( + (19, 0), + ( + if selection_before_gap == Some(true) { + 21 + } else { + 20 + }, + end_col + ) + ) + ); + } + + assert_eq!(state.mode, ClientShellMode::Copy); + assert!(state.copy_mode.is_some()); + assert_eq!( + state + .copy_mode + .as_ref() + .map(|copy_mode| copy_mode.cursor.row), + Some(19) + ); + } +} -- cgit v1.2.3