aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/app/actions.rs42
-rw-r--r--src/app/input.rs276
-rw-r--r--src/pane.rs42
-rw-r--r--src/selection.rs196
-rw-r--r--src/ui.rs4
5 files changed, 466 insertions, 94 deletions
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 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 => {
+ self.selection = None;
+ return;
+ }
+ };
- let rt = match ws.runtime(sel.pane_id) {
- Some(r) => r,
- None => return,
+ rt.extract_selection(sel)
};
- 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<crate::pane::ScrollMetrics> {
+ 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<u8> {
+ (0..count)
+ .map(|i| format!("{i:06}\r\n"))
+ .collect::<String>()
+ .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<String, crate::ghostty::Error> {
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 {
@@ -2167,6 +2174,29 @@ mod tests {
}
#[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);
let mut terminal = crate::ghostty::Terminal::new(5, 3, 100).unwrap();
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<ScrollMetrics>,
+ ) -> 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<ScrollMetrics>,
+ ) {
+ 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<ScrollMetrics>) -> 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<ScrollMetrics>) -> 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<ScrollMetrics>) -> 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 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,
+ }),
+ );
+
+ 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 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
-
- // Drag left of pane
- let (row, col) = sel.clamp_to_pane(0, 0);
+ 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<crate::pane::ScrollMetrics>,
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));
}