From 85aee98ddfea3237e34a57f2a7fcddd1750cec50 Mon Sep 17 00:00:00 2001 From: Robin Gagnon Date: Thu, 4 Jun 2026 06:36:00 -0500 Subject: [PATCH] fix: match copy mode page scrolling boundaries (#460) refs #459 Co-authored-by: Can Celik --- src/app/input/copy_mode.rs | 132 ++++++++++++++++++++++++++++++++++--- 1 file changed, 124 insertions(+), 8 deletions(-) diff --git a/src/app/input/copy_mode.rs b/src/app/input/copy_mode.rs index 9d9d516b..967d8d32 100644 --- a/src/app/input/copy_mode.rs +++ b/src/app/input/copy_mode.rs @@ -280,23 +280,53 @@ impl AppState { direction: i16, half_page: bool, ) { - let Some(copy_mode) = self.copy_mode else { + let Some(mut copy_mode) = self.copy_mode else { return; }; - let Some(info) = self.pane_info_by_id(copy_mode.pane_id) else { + let Some(info) = self.pane_info_by_id(copy_mode.pane_id).cloned() else { self.exit_copy_mode(terminal_runtimes, false); return; }; - let lines = if half_page { - (info.inner_rect.height / 2).max(1) - } else { - info.inner_rect.height.max(1) - } as usize; - if direction < 0 { + let lines = copy_mode_page_lines(info.inner_rect.height, half_page); + if let Some(metrics) = self.pane_scroll_metrics(terminal_runtimes, copy_mode.pane_id) { + if direction < 0 { + let next_offset = metrics.offset_from_bottom.saturating_add(lines); + if next_offset > metrics.max_offset_from_bottom { + let scrolled_lines = metrics + .max_offset_from_bottom + .saturating_sub(metrics.offset_from_bottom); + let cursor_lines = lines.saturating_sub(scrolled_lines); + self.set_pane_scroll_offset( + terminal_runtimes, + copy_mode.pane_id, + metrics.max_offset_from_bottom, + ); + copy_mode.cursor_row = copy_mode + .cursor_row + .saturating_sub(cursor_lines.min(u16::MAX as usize) as u16); + } else { + self.set_pane_scroll_offset(terminal_runtimes, copy_mode.pane_id, next_offset); + } + } else if metrics.offset_from_bottom < lines { + let cursor_lines = lines.saturating_sub(metrics.offset_from_bottom); + self.set_pane_scroll_offset(terminal_runtimes, copy_mode.pane_id, 0); + copy_mode.cursor_row = copy_mode + .cursor_row + .saturating_add(cursor_lines.min(u16::MAX as usize) as u16) + .min(info.inner_rect.height.saturating_sub(1)); + } else { + self.set_pane_scroll_offset( + terminal_runtimes, + copy_mode.pane_id, + metrics.offset_from_bottom - lines, + ); + } + } else if direction < 0 { self.scroll_pane_up(terminal_runtimes, copy_mode.pane_id, lines); } else { self.scroll_pane_down(terminal_runtimes, copy_mode.pane_id, lines); } + self.copy_mode = Some(copy_mode); self.sync_copy_mode_selection(terminal_runtimes); } @@ -581,6 +611,16 @@ fn char_cell_width(ch: char) -> u16 { UnicodeWidthChar::width(ch).unwrap_or(1).max(1) as u16 } +fn copy_mode_page_lines(height: u16, half_page: bool) -> usize { + if height <= 2 { + 1 + } else if half_page { + usize::from(height / 2) + } else { + usize::from(height - 2) + } +} + fn copy_mode_command_char(key: TerminalKey) -> Option { if !key.modifiers.difference(KeyModifiers::SHIFT).is_empty() { return None; @@ -700,6 +740,16 @@ mod tests { .offset_from_bottom } + fn copy_mode_scroll_metrics( + app: &App, + pane_id: crate::layout::PaneId, + ) -> crate::pane::ScrollMetrics { + 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") + } + #[tokio::test] async fn enter_copy_mode_tracks_focused_pane() { let (mut app, pane_id) = app_with_copy_screen(b"alpha\nbeta\n"); @@ -851,6 +901,72 @@ mod tests { assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), 0); } + #[tokio::test] + async fn copy_mode_page_up_uses_tmux_page_size() { + let bytes = numbered_lines_bytes(64); + let (mut app, pane_id) = app_with_copy_scrollback(&bytes); + app.state.enter_copy_mode(&app.terminal_runtimes); + let height = app.state.copy_mode.expect("copy mode").cursor_row + 1; + let expected_lines = copy_mode_page_lines(height, false); + + app.handle_copy_mode_key(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); + + assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), expected_lines); + } + + #[tokio::test] + async fn copy_mode_ctrl_u_moves_cursor_when_history_top_clamps() { + let bytes = numbered_lines_bytes(64); + let (mut app, pane_id) = app_with_copy_scrollback(&bytes); + app.state.enter_copy_mode(&app.terminal_runtimes); + let bottom = app.state.copy_mode.expect("copy mode").cursor_row; + let lines = copy_mode_page_lines(bottom + 1, true); + let metrics = copy_mode_scroll_metrics(&app, pane_id); + assert!(metrics.max_offset_from_bottom >= lines); + app.state.set_pane_scroll_offset( + &app.terminal_runtimes, + pane_id, + metrics.max_offset_from_bottom - lines + 1, + ); + if let Some(copy_mode) = app.state.copy_mode.as_mut() { + copy_mode.cursor_row = bottom; + } + + app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); + + let copy_mode = app.state.copy_mode.expect("copy mode"); + let expected_cursor_delta = 1; + assert_eq!( + copy_mode_offset_from_bottom(&app, pane_id), + metrics.max_offset_from_bottom + ); + assert_eq!( + copy_mode.cursor_row, + bottom.saturating_sub(expected_cursor_delta as u16) + ); + } + + #[tokio::test] + async fn copy_mode_ctrl_d_moves_cursor_when_live_bottom_clamps() { + let bytes = numbered_lines_bytes(64); + let (mut app, pane_id) = app_with_copy_scrollback(&bytes); + app.state.enter_copy_mode(&app.terminal_runtimes); + let bottom = app.state.copy_mode.expect("copy mode").cursor_row; + let lines = copy_mode_page_lines(bottom + 1, true); + assert!(lines > 1); + app.state + .set_pane_scroll_offset(&app.terminal_runtimes, pane_id, lines - 1); + if let Some(copy_mode) = app.state.copy_mode.as_mut() { + copy_mode.cursor_row = 0; + } + + app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('d'), KeyModifiers::CONTROL)); + + let copy_mode = app.state.copy_mode.expect("copy mode"); + assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), 0); + assert_eq!(copy_mode.cursor_row, 1); + } + #[tokio::test] async fn copy_mode_q_exits_and_returns_to_bottom_after_scrollback() { let bytes = numbered_lines_bytes(64);