diff --git a/src/app/actions.rs b/src/app/actions.rs index 3622787e..6989a909 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -357,33 +357,45 @@ impl AppState { } pub fn copy_selection(&mut self) { - let sel = match self.selection.as_mut() { - Some(s) => { - if !s.finish() { + let text = { + let sel = match self.selection.as_mut() { + Some(s) => { + if !s.finish() { + self.selection = None; + return; + } + s + } + None => return, + }; + + let ws = match self.active.and_then(|i| self.workspaces.get(i)) { + Some(ws) => ws, + None => { self.selection = None; return; } - s - } - None => return, + }; + + let rt = match ws.runtime(sel.pane_id) { + Some(r) => r, + None => { + self.selection = None; + return; + } + }; + + rt.extract_selection(sel) }; - let ws = match self.active.and_then(|i| self.workspaces.get(i)) { - Some(ws) => ws, - None => return, - }; - - let rt = match ws.runtime(sel.pane_id) { - Some(r) => r, - None => return, - }; - - if let Some(text) = rt.extract_selection(sel) { + if let Some(text) = text { if !text.is_empty() { crate::selection::write_osc52(&text); info!(len = text.len(), "copied selection to clipboard"); } } + + self.selection = None; } } diff --git a/src/app/input.rs b/src/app/input.rs index d1c20d8d..cf9c9de9 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -2164,7 +2164,12 @@ impl AppState { mouse.row - info.inner_rect.y, mouse.column - info.inner_rect.x, ); - self.selection = Some(Selection::anchor(info.id, row, col, info.inner_rect)); + self.selection = Some(Selection::anchor( + info.id, + row, + col, + self.pane_scroll_metrics(info.id), + )); self.focus_pane(info.id); if self.mode != Mode::Terminal { @@ -2185,6 +2190,11 @@ impl AppState { } MouseEventKind::Drag(MouseButton::Left) => { + if self.selection.is_some() { + self.update_selection_drag(mouse.column, mouse.row); + return None; + } + if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { if self.forward_pane_mouse_button(&info, mouse) { self.selection = None; @@ -2273,12 +2283,22 @@ impl AppState { DragTarget::ReleaseNotesScrollbar { .. } | DragTarget::KeybindHelpScrollbar { .. } => {} } - } else if let Some(sel) = &mut self.selection { - sel.drag(mouse.column, mouse.row); } } MouseEventKind::Up(MouseButton::Left) => { + if self.selection.is_some() { + self.workspace_press = None; + self.drag = None; + let was_click = self.selection.as_ref().is_some_and(|s| s.was_just_click()); + if was_click { + self.selection = None; + } else { + self.copy_selection(); + } + return None; + } + if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { if self.forward_pane_mouse_button(&info, mouse) { self.selection = None; @@ -2328,8 +2348,10 @@ impl AppState { } MouseEventKind::ScrollUp | MouseEventKind::ScrollDown if !in_sidebar => { - self.selection = None; - self.handle_terminal_wheel(mouse); + if !self.scroll_selection_with_wheel(mouse) { + self.selection = None; + self.handle_terminal_wheel(mouse); + } } MouseEventKind::ScrollUp if in_sidebar => { @@ -2769,6 +2791,10 @@ impl AppState { .or_else(|| self.pane_frame_at(col, row)) } + fn pane_info_by_id(&self, pane_id: crate::layout::PaneId) -> Option<&PaneInfo> { + self.view.pane_infos.iter().find(|info| info.id == pane_id) + } + fn pane_frame_at(&self, col: u16, row: u16) -> Option<&PaneInfo> { self.view.pane_infos.iter().find(|p| { col >= p.rect.x @@ -2803,6 +2829,79 @@ impl AppState { } } + fn pane_scroll_metrics( + &self, + pane_id: crate::layout::PaneId, + ) -> Option { + self.active + .and_then(|i| self.workspaces.get(i)) + .and_then(|ws| ws.runtime(pane_id)) + .and_then(crate::pane::PaneRuntime::scroll_metrics) + } + + fn update_selection_cursor( + &mut self, + pane_id: crate::layout::PaneId, + screen_col: u16, + screen_row: u16, + ) { + let Some(info) = self.pane_info_by_id(pane_id).cloned() else { + return; + }; + let metrics = self.pane_scroll_metrics(pane_id); + if let Some(selection) = self.selection.as_mut() { + selection.drag(screen_col, screen_row, info.inner_rect, metrics); + } + } + + fn selection_edge_scroll_lines(distance: u16) -> usize { + usize::from(distance).saturating_mul(3).clamp(3, 15) + } + + fn update_selection_drag(&mut self, screen_col: u16, screen_row: u16) { + let Some(pane_id) = self.selection.as_ref().map(|selection| selection.pane_id) else { + return; + }; + let Some(info) = self.pane_info_by_id(pane_id).cloned() else { + return; + }; + + let bottom = info.inner_rect.y + info.inner_rect.height.saturating_sub(1); + if screen_row < info.inner_rect.y { + self.scroll_pane_up( + pane_id, + Self::selection_edge_scroll_lines(info.inner_rect.y - screen_row), + ); + } else if screen_row > bottom { + self.scroll_pane_down( + pane_id, + Self::selection_edge_scroll_lines(screen_row - bottom), + ); + } + + self.update_selection_cursor(pane_id, screen_col, screen_row); + } + + fn scroll_selection_with_wheel(&mut self, mouse: MouseEvent) -> bool { + const LINES_PER_NOTCH: usize = 3; + + let Some(selection) = self.selection.as_ref() else { + return false; + }; + if !selection.is_in_progress() { + return false; + } + let pane_id = selection.pane_id; + self.focus_pane(pane_id); + match mouse.kind { + MouseEventKind::ScrollUp => self.scroll_pane_up(pane_id, LINES_PER_NOTCH), + MouseEventKind::ScrollDown => self.scroll_pane_down(pane_id, LINES_PER_NOTCH), + _ => return false, + } + self.update_selection_cursor(pane_id, mouse.column, mouse.row); + true + } + fn handle_terminal_wheel(&mut self, mouse: MouseEvent) { const LINES_PER_NOTCH: usize = 3; @@ -3051,6 +3150,13 @@ mod tests { } } + fn numbered_lines_bytes(count: usize) -> Vec { + (0..count) + .map(|i| format!("{i:06}\r\n")) + .collect::() + .into_bytes() + } + fn capture_snapshot(state: &AppState) -> crate::persist::SessionSnapshot { crate::persist::capture( &state.workspaces, @@ -4060,6 +4166,166 @@ mod tests { assert!(!app.state.sidebar_collapsed); } + #[tokio::test] + async fn dragging_selection_above_pane_autoscrolls_and_extends_into_scrollback() { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("test"); + let pane_id = ws.tabs[0].root_pane; + let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); + let info = pane_infos[0].clone(); + ws.tabs[0].runtimes.insert( + pane_id, + crate::pane::PaneRuntime::test_with_scrollback_bytes( + info.inner_rect.width, + info.inner_rect.height, + 16 * 1024, + &numbered_lines_bytes(64), + ), + ); + + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + app.state.view.pane_infos = pane_infos; + + let start_metrics = app.state.workspaces[0] + .runtime(pane_id) + .and_then(crate::pane::PaneRuntime::scroll_metrics) + .expect("initial scroll metrics"); + let start_row = info.inner_rect.y; + let start_col = info.inner_rect.x + 2; + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + start_col, + start_row, + )); + app.handle_mouse(mouse( + MouseEventKind::Drag(MouseButton::Left), + start_col, + info.inner_rect.y.saturating_sub(1), + )); + + let end_metrics = app.state.workspaces[0] + .runtime(pane_id) + .and_then(crate::pane::PaneRuntime::scroll_metrics) + .expect("scroll metrics after drag"); + assert_eq!( + end_metrics.offset_from_bottom, + start_metrics.offset_from_bottom + 3 + ); + + let selection = app.state.selection.as_ref().expect("selection after drag"); + assert!(selection.is_visible()); + assert_eq!( + selection.ordered_cells(), + ( + ( + (start_metrics.max_offset_from_bottom - end_metrics.offset_from_bottom) as u32, + 2, + ), + (start_metrics.max_offset_from_bottom as u32, 2), + ) + ); + } + + #[tokio::test] + async fn releasing_dragged_selection_clears_highlight_after_copy() { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("test"); + let pane_id = ws.tabs[0].root_pane; + let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); + let info = pane_infos[0].clone(); + ws.tabs[0].runtimes.insert( + pane_id, + crate::pane::PaneRuntime::test_with_scrollback_bytes( + info.inner_rect.width, + info.inner_rect.height, + 16 * 1024, + &numbered_lines_bytes(64), + ), + ); + + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + app.state.view.pane_infos = pane_infos; + + let row = info.inner_rect.y; + let start_col = info.inner_rect.x + 1; + let end_col = info.inner_rect.x + 4; + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + start_col, + row, + )); + app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), end_col, row)); + assert!(app.state.selection.is_some()); + + app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), end_col, row)); + + assert!(app.state.selection.is_none()); + } + + #[tokio::test] + async fn wheel_scroll_keeps_in_progress_selection_and_extends_it() { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("test"); + let pane_id = ws.tabs[0].root_pane; + let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); + let info = pane_infos[0].clone(); + ws.tabs[0].runtimes.insert( + pane_id, + crate::pane::PaneRuntime::test_with_scrollback_bytes( + info.inner_rect.width, + info.inner_rect.height, + 16 * 1024, + &numbered_lines_bytes(64), + ), + ); + + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + app.state.view.pane_infos = pane_infos; + + let start_metrics = app.state.workspaces[0] + .runtime(pane_id) + .and_then(crate::pane::PaneRuntime::scroll_metrics) + .expect("initial scroll metrics"); + let top_row = info.inner_rect.y; + let col = info.inner_rect.x + 2; + + app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, top_row)); + app.handle_mouse(mouse(MouseEventKind::ScrollUp, col, top_row)); + + let end_metrics = app.state.workspaces[0] + .runtime(pane_id) + .and_then(crate::pane::PaneRuntime::scroll_metrics) + .expect("scroll metrics after wheel"); + assert_eq!( + end_metrics.offset_from_bottom, + start_metrics.offset_from_bottom + 3 + ); + + let selection = app.state.selection.as_ref().expect("selection after wheel"); + assert!(selection.is_visible()); + assert_eq!( + selection.ordered_cells(), + ( + ( + (start_metrics.max_offset_from_bottom - end_metrics.offset_from_bottom) as u32, + 2, + ), + (start_metrics.max_offset_from_bottom as u32, 2), + ) + ); + } + #[test] fn clicking_workspace_switches_on_mouse_up() { let mut app = app_for_mouse_test(); diff --git a/src/pane.rs b/src/pane.rs index a578eb33..192cb064 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -1112,11 +1112,8 @@ fn ghostty_extract_selection( selection: &crate::selection::Selection, ) -> Result { let ((start_row, start_col), (end_row, end_col)) = selection.ordered_cells(); - core.terminal.read_text_viewport( - (start_col, u32::from(start_row)), - (end_col, u32::from(end_row)), - false, - ) + core.terminal + .read_text_screen((start_col, start_row), (end_col, end_row), false) } fn ghostty_screen_row( @@ -1868,9 +1865,19 @@ impl PaneRuntime { #[cfg(test)] impl PaneRuntime { pub(crate) fn test_with_screen_bytes(cols: u16, rows: u16, bytes: &[u8]) -> Self { + Self::test_with_scrollback_bytes(cols, rows, 0, bytes) + } + + pub(crate) fn test_with_scrollback_bytes( + cols: u16, + rows: u16, + scrollback_limit_bytes: usize, + bytes: &[u8], + ) -> Self { let (tx, _rx) = mpsc::channel(4); let (resize_tx, _resize_rx) = mpsc::channel(1); - let mut terminal = crate::ghostty::Terminal::new(cols, rows, 0).unwrap(); + let mut terminal = + crate::ghostty::Terminal::new(cols, rows, scrollback_limit_bytes).unwrap(); terminal.write(bytes); Self { @@ -2166,6 +2173,29 @@ mod tests { assert_eq!(pane.detection_text(), bottom_snapshot); } + #[test] + fn extract_selection_reads_screen_rows_not_current_viewport() { + let (tx, _rx) = mpsc::channel(4); + let mut terminal = crate::ghostty::Terminal::new(8, 3, 1024).unwrap(); + write_numbered_lines(&mut terminal, 8); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + + pane.set_scroll_offset_from_bottom(3); + let metrics = pane + .scroll_metrics() + .expect("scroll metrics after initial scroll"); + let mut selection = + crate::selection::Selection::anchor(PaneId::from_raw(1), 0, 0, Some(metrics)); + selection.drag(5, 2, Rect::new(0, 0, 8, 3), Some(metrics)); + + pane.scroll_reset(); + + let text = pane + .extract_selection(&selection) + .expect("selection should extract text"); + assert_eq!(text, "000003\n000004\n000005"); + } + #[test] fn recent_unwrapped_text_ignores_soft_wraps() { let (tx, _rx) = mpsc::channel(4); diff --git a/src/selection.rs b/src/selection.rs index bb5a76e8..fab687e5 100644 --- a/src/selection.rs +++ b/src/selection.rs @@ -7,13 +7,13 @@ //! MouseUp → Text extracted, copied via OSC 52, highlight stays //! Next click / key → Selection cleared //! -//! Coordinates are stored relative to the pane's inner area (the region -//! where terminal content is rendered, excluding borders). +//! Rows are stored in screen-buffer coordinates instead of viewport-relative +//! coordinates. That keeps selection stable while the pane scrolls. use ratatui::layout::Rect; use std::io::Write; -use crate::layout::PaneId; +use crate::{layout::PaneId, pane::ScrollMetrics}; /// Current phase of a selection. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -33,36 +33,44 @@ enum Phase { pub struct Selection { /// Which pane the selection belongs to. pub pane_id: PaneId, - /// Anchor position in pane-relative coordinates (row, col). - anchor: (u16, u16), - /// Current/final position in pane-relative coordinates (row, col). - cursor: (u16, u16), + /// Anchor position in screen-buffer coordinates (row, col). + anchor: (u32, u16), + /// Current/final position in screen-buffer coordinates (row, col). + cursor: (u32, u16), /// Selection phase. phase: Phase, - /// The inner rect of the pane (for clamping during drag). - /// This is the content area, excluding borders. - pane_inner: Rect, } impl Selection { /// Start a potential selection. This records the anchor but doesn't /// make anything visible yet — the user might just be clicking. - pub fn anchor(pane_id: PaneId, row: u16, col: u16, pane_inner: Rect) -> Self { + pub fn anchor( + pane_id: PaneId, + viewport_row: u16, + col: u16, + metrics: Option, + ) -> Self { + let anchor = (absolute_row_for_viewport_row(viewport_row, metrics), col); Self { pane_id, - anchor: (row, col), - cursor: (row, col), + anchor, + cursor: anchor, phase: Phase::Anchored, - pane_inner, } } /// Extend the selection as the mouse drags. Activates highlighting /// once the cursor moves to a different cell than the anchor. /// Screen coordinates are clamped to the pane boundary. - pub fn drag(&mut self, screen_col: u16, screen_row: u16) { - let (row, col) = self.clamp_to_pane(screen_col, screen_row); - self.cursor = (row, col); + pub fn drag( + &mut self, + screen_col: u16, + screen_row: u16, + pane_inner: Rect, + metrics: Option, + ) { + let (viewport_row, col) = clamp_to_pane(screen_col, screen_row, pane_inner); + self.cursor = (absolute_row_for_viewport_row(viewport_row, metrics), col); if self.cursor != self.anchor { self.phase = Phase::Dragging; } @@ -89,8 +97,13 @@ impl Selection { self.phase == Phase::Anchored } + /// Whether the pointer is still down and the selection can keep extending. + pub fn is_in_progress(&self) -> bool { + matches!(self.phase, Phase::Anchored | Phase::Dragging) + } + /// Returns (start, end) in reading order (top-left to bottom-right). - fn ordered(&self) -> ((u16, u16), (u16, u16)) { + fn ordered(&self) -> ((u32, u16), (u32, u16)) { let (ar, ac) = self.anchor; let (cr, cc) = self.cursor; if ar < cr || (ar == cr && ac <= cc) { @@ -100,42 +113,56 @@ impl Selection { } } - pub(crate) fn ordered_cells(&self) -> ((u16, u16), (u16, u16)) { + pub(crate) fn ordered_cells(&self) -> ((u32, u16), (u32, u16)) { self.ordered() } /// Check whether a pane-relative cell (row, col) is inside the selection. - pub fn contains(&self, row: u16, col: u16) -> bool { + pub fn contains(&self, viewport_row: u16, col: u16, metrics: Option) -> bool { if !self.is_visible() { return false; } + let row = absolute_row_for_viewport_row(viewport_row, metrics); let ((sr, sc), (er, ec)) = self.ordered(); if row < sr || row > er { return false; } if sr == er { - // Single-line: from sc to ec (inclusive) col >= sc && col <= ec } else if row == sr { - // First line: from sc to end of line col >= sc } else if row == er { - // Last line: from start to ec col <= ec } else { - // Middle rows: fully selected true } } +} - /// Clamp screen coordinates to the pane's inner area and convert - /// to pane-relative coordinates. - fn clamp_to_pane(&self, screen_col: u16, screen_row: u16) -> (u16, u16) { - let r = &self.pane_inner; - let clamped_col = screen_col.clamp(r.x, r.x + r.width.saturating_sub(1)); - let clamped_row = screen_row.clamp(r.y, r.y + r.height.saturating_sub(1)); - (clamped_row - r.y, clamped_col - r.x) - } +fn viewport_top_row(metrics: Option) -> u32 { + metrics + .map(|metrics| { + metrics + .max_offset_from_bottom + .saturating_sub(metrics.offset_from_bottom) + }) + .unwrap_or(0) as u32 +} + +fn absolute_row_for_viewport_row(viewport_row: u16, metrics: Option) -> u32 { + viewport_top_row(metrics) + u32::from(viewport_row) +} + +fn clamp_to_pane(screen_col: u16, screen_row: u16, pane_inner: Rect) -> (u16, u16) { + let clamped_col = screen_col.clamp( + pane_inner.x, + pane_inner.x + pane_inner.width.saturating_sub(1), + ); + let clamped_row = screen_row.clamp( + pane_inner.y, + pane_inner.y + pane_inner.height.saturating_sub(1), + ); + (clamped_row - pane_inner.y, clamped_col - pane_inner.x) } /// Write text to the system clipboard via OSC 52. @@ -158,8 +185,9 @@ pub fn write_osc52(text: &str) { mod tests { use super::*; - fn make_sel(sr: u16, sc: u16, er: u16, ec: u16) -> Selection { - let mut sel = Selection::anchor(PaneId::from_raw(0), sr, sc, Rect::new(0, 0, 80, 24)); + fn make_sel(sr: u32, sc: u16, er: u32, ec: u16) -> Selection { + let mut sel = Selection::anchor(PaneId::from_raw(0), sr as u16, sc, None); + sel.anchor = (sr, sc); sel.cursor = (er, ec); sel.phase = Phase::Dragging; sel @@ -180,43 +208,38 @@ mod tests { #[test] fn single_line_contains() { let sel = make_sel(2, 5, 2, 15); - assert!(!sel.contains(2, 4)); - assert!(sel.contains(2, 5)); - assert!(sel.contains(2, 10)); - assert!(sel.contains(2, 15)); // inclusive - assert!(!sel.contains(2, 16)); - assert!(!sel.contains(1, 10)); - assert!(!sel.contains(3, 10)); + assert!(!sel.contains(2, 4, None)); + assert!(sel.contains(2, 5, None)); + assert!(sel.contains(2, 10, None)); + assert!(sel.contains(2, 15, None)); + assert!(!sel.contains(2, 16, None)); + assert!(!sel.contains(1, 10, None)); + assert!(!sel.contains(3, 10, None)); } #[test] fn multi_line_contains() { let sel = make_sel(2, 5, 4, 10); - // Row 2: from col 5 to end - assert!(!sel.contains(2, 4)); - assert!(sel.contains(2, 5)); - assert!(sel.contains(2, 79)); - - // Row 3: fully selected - assert!(sel.contains(3, 0)); - assert!(sel.contains(3, 79)); - - // Row 4: from start to col 10 - assert!(sel.contains(4, 0)); - assert!(sel.contains(4, 10)); - assert!(!sel.contains(4, 11)); + assert!(!sel.contains(2, 4, None)); + assert!(sel.contains(2, 5, None)); + assert!(sel.contains(2, 79, None)); + assert!(sel.contains(3, 0, None)); + assert!(sel.contains(3, 79, None)); + assert!(sel.contains(4, 0, None)); + assert!(sel.contains(4, 10, None)); + assert!(!sel.contains(4, 11, None)); } #[test] fn anchored_not_visible() { - let sel = Selection::anchor(PaneId::from_raw(0), 5, 10, Rect::new(0, 0, 80, 24)); + let sel = Selection::anchor(PaneId::from_raw(0), 5, 10, None); assert!(!sel.is_visible()); - assert!(!sel.contains(5, 10)); + assert!(!sel.contains(5, 10, None)); } #[test] fn click_without_drag() { - let mut sel = Selection::anchor(PaneId::from_raw(0), 5, 10, Rect::new(0, 0, 80, 24)); + let mut sel = Selection::anchor(PaneId::from_raw(0), 5, 10, None); assert!(sel.was_just_click()); let copied = sel.finish(); assert!(!copied); @@ -224,8 +247,8 @@ mod tests { #[test] fn drag_then_finish() { - let mut sel = Selection::anchor(PaneId::from_raw(0), 5, 10, Rect::new(10, 5, 80, 24)); - sel.drag(20, 7); // screen coords → clamped to pane + let mut sel = Selection::anchor(PaneId::from_raw(0), 5, 10, None); + sel.drag(20, 7, Rect::new(10, 5, 80, 24), None); assert!(sel.is_visible()); assert!(!sel.was_just_click()); let copied = sel.finish(); @@ -233,15 +256,54 @@ mod tests { } #[test] - fn clamp_to_pane_bounds() { - let sel = Selection::anchor(PaneId::from_raw(0), 0, 0, Rect::new(10, 5, 80, 24)); - // Drag way outside pane bounds - let (row, col) = sel.clamp_to_pane(200, 100); - assert_eq!(row, 23); // clamped to height - 1 - assert_eq!(col, 79); // clamped to width - 1 + fn drag_uses_buffer_rows_when_scrolled() { + let mut sel = Selection::anchor( + PaneId::from_raw(0), + 0, + 10, + Some(ScrollMetrics { + offset_from_bottom: 1, + max_offset_from_bottom: 10, + viewport_rows: 4, + }), + ); - // Drag left of pane - let (row, col) = sel.clamp_to_pane(0, 0); + sel.drag( + 10, + 5, + Rect::new(10, 5, 80, 4), + Some(ScrollMetrics { + offset_from_bottom: 2, + max_offset_from_bottom: 10, + viewport_rows: 4, + }), + ); + + assert_eq!(sel.ordered_cells(), ((8, 0), (9, 10))); + } + + #[test] + fn contains_tracks_current_viewport_after_scroll() { + let sel = make_sel(8, 2, 10, 4); + let metrics = Some(ScrollMetrics { + offset_from_bottom: 2, + max_offset_from_bottom: 10, + viewport_rows: 4, + }); + + assert!(sel.contains(0, 2, metrics)); + assert!(sel.contains(1, 40, metrics)); + assert!(sel.contains(2, 4, metrics)); + assert!(!sel.contains(3, 4, metrics)); + } + + #[test] + fn clamp_to_pane_bounds() { + let (row, col) = clamp_to_pane(200, 100, Rect::new(10, 5, 80, 24)); + assert_eq!(row, 23); + assert_eq!(col, 79); + + let (row, col) = clamp_to_pane(0, 0, Rect::new(10, 5, 80, 24)); assert_eq!(row, 0); assert_eq!(col, 0); } diff --git a/src/ui.rs b/src/ui.rs index 33ba4e0b..b1b5f699 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1240,6 +1240,7 @@ fn render_panes(app: &AppState, frame: &mut Frame, area: Rect) { frame, info.id, info.inner_rect, + rt.scroll_metrics(), &app.palette, ); } @@ -1459,6 +1460,7 @@ fn render_selection_highlight( frame: &mut Frame, pane_id: crate::layout::PaneId, inner: Rect, + scroll_metrics: Option, p: &Palette, ) { if let Some(sel) = selection { @@ -1466,7 +1468,7 @@ fn render_selection_highlight( let buf = frame.buffer_mut(); for y in 0..inner.height { for x in 0..inner.width { - if sel.contains(y, x) { + if sel.contains(y, x, scroll_metrics) { let cell = &mut buf[(inner.x + x, inner.y + y)]; cell.set_style(Style::default().fg(p.panel_bg).bg(p.blue)); }