fix: autoscroll and clear mouse text selections
This commit is contained in:
parent
be271f8dbe
commit
953ff58e9f
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
276
src/app/input.rs
276
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();
|
||||
|
|
|
|||
42
src/pane.rs
42
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 {
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
196
src/selection.rs
196
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 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue