From 29024330c12f0acd45fb9177908f80bbd54d719b Mon Sep 17 00:00:00 2001 From: Robin Gagnon Date: Tue, 2 Jun 2026 15:04:57 -0500 Subject: [PATCH] fix: return to bottom after copy mode exit (#410) refs #398 Co-authored-by: Can Celik --- src/app/input/copy_mode.rs | 55 ++++++++++++++++++++++++++++++++++++++ src/app/state.rs | 1 + 2 files changed, 56 insertions(+) diff --git a/src/app/input/copy_mode.rs b/src/app/input/copy_mode.rs index 04c62bc0..9d9d516b 100644 --- a/src/app/input/copy_mode.rs +++ b/src/app/input/copy_mode.rs @@ -61,12 +61,16 @@ impl AppState { ) }) .unwrap_or_else(|| (info.inner_rect.height.saturating_sub(1), 0)); + let entry_offset_from_bottom = self + .pane_scroll_metrics(terminal_runtimes, pane_id) + .map_or(0, |metrics| metrics.offset_from_bottom); self.clear_selection(); self.copy_mode = Some(CopyModeState { pane_id, cursor_row: cursor.0.min(info.inner_rect.height.saturating_sub(1)), cursor_col: cursor.1.min(info.inner_rect.width.saturating_sub(1)), + entry_offset_from_bottom, selection: None, }); self.mode = Mode::Copy; @@ -158,11 +162,17 @@ impl AppState { } fn exit_copy_mode(&mut self, terminal_runtimes: &TerminalRuntimeRegistry, copy: bool) { + let restore_scroll = self + .copy_mode + .map(|copy_mode| (copy_mode.pane_id, copy_mode.entry_offset_from_bottom)); if copy { self.copy_selection(terminal_runtimes); } else { self.clear_selection(); } + if let Some((pane_id, offset_from_bottom)) = restore_scroll { + self.set_pane_scroll_offset(terminal_runtimes, pane_id, offset_from_bottom); + } self.copy_mode = None; self.mode = if self.active.is_some() { Mode::Terminal @@ -682,6 +692,14 @@ mod tests { .saturating_sub(metrics.offset_from_bottom) } + fn copy_mode_offset_from_bottom(app: &App, pane_id: crate::layout::PaneId) -> usize { + app.state + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .and_then(crate::terminal::TerminalRuntime::scroll_metrics) + .expect("copy mode scroll metrics") + .offset_from_bottom + } + #[tokio::test] async fn enter_copy_mode_tracks_focused_pane() { let (mut app, pane_id) = app_with_copy_screen(b"alpha\nbeta\n"); @@ -830,6 +848,43 @@ mod tests { .collect::>() .join("\n"); assert_eq!(copy_mode_clipboard_text(&mut app), expected); + assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), 0); + } + + #[tokio::test] + async fn copy_mode_q_exits_and_returns_to_bottom_after_scrollback() { + let bytes = numbered_lines_bytes(64); + let (mut app, pane_id) = app_with_copy_scrollback(&bytes); + app.state.enter_copy_mode(&app.terminal_runtimes); + + app.handle_copy_mode_key(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); + assert!(copy_mode_offset_from_bottom(&app, pane_id) > 0); + + app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('q'), KeyModifiers::empty())); + + assert_eq!(app.state.mode, Mode::Terminal); + assert!(app.state.copy_mode.is_none()); + assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), 0); + } + + #[tokio::test] + async fn copy_mode_q_restores_entry_scrollback_offset() { + let bytes = numbered_lines_bytes(64); + let (mut app, pane_id) = app_with_copy_scrollback(&bytes); + let entry_offset = 3; + app.state + .set_pane_scroll_offset(&app.terminal_runtimes, pane_id, entry_offset); + assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), entry_offset); + + app.state.enter_copy_mode(&app.terminal_runtimes); + app.handle_copy_mode_key(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); + assert!(copy_mode_offset_from_bottom(&app, pane_id) > entry_offset); + + app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('q'), KeyModifiers::empty())); + + assert_eq!(app.state.mode, Mode::Terminal); + assert!(app.state.copy_mode.is_none()); + assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), entry_offset); } #[tokio::test] diff --git a/src/app/state.rs b/src/app/state.rs index d38699eb..dd1b167e 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -813,6 +813,7 @@ pub(crate) struct CopyModeState { pub pane_id: PaneId, pub cursor_row: u16, pub cursor_col: u16, + pub entry_offset_from_bottom: usize, pub selection: Option, }