418 lines
13 KiB
Rust
418 lines
13 KiB
Rust
//! Text selection and clipboard support.
|
|
//!
|
|
//! Selection lifecycle:
|
|
//!
|
|
//! MouseDown in pane → Anchor recorded (no visual yet)
|
|
//! MouseDrag → Selection becomes active, cells highlighted
|
|
//! MouseUp → Text extracted, copied via OSC 52, highlight stays
|
|
//! Next click / key → Selection cleared
|
|
//!
|
|
//! 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::{ffi::OsStr, io::Write};
|
|
|
|
use crate::{layout::PaneId, pane::ScrollMetrics};
|
|
|
|
/// Current phase of a selection.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum Phase {
|
|
/// Mouse is down but hasn't moved yet. If released without
|
|
/// moving, this was just a click — no selection created.
|
|
Anchored,
|
|
/// Mouse has moved from the anchor point. Cells are being highlighted.
|
|
Dragging,
|
|
/// Mouse released after dragging. Selection is visible and text
|
|
/// has been copied to clipboard. Cleared on next interaction.
|
|
Done,
|
|
}
|
|
|
|
/// A text selection within a terminal pane.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Selection {
|
|
/// Which pane the selection belongs to.
|
|
pub pane_id: PaneId,
|
|
/// 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,
|
|
}
|
|
|
|
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,
|
|
viewport_row: u16,
|
|
col: u16,
|
|
metrics: Option<ScrollMetrics>,
|
|
) -> Self {
|
|
let anchor = (absolute_row_for_viewport_row(viewport_row, metrics), col);
|
|
Self {
|
|
pane_id,
|
|
anchor,
|
|
cursor: anchor,
|
|
phase: Phase::Anchored,
|
|
}
|
|
}
|
|
|
|
/// Convert the anchor's absolute row and pane-relative column back to
|
|
/// screen coordinates. Adds the pane origin before clamping so the
|
|
/// returned (screen_row, screen_col) can be compared directly against
|
|
/// mouse screen positions.
|
|
pub fn anchor_screen_pos(
|
|
&self,
|
|
pane_inner: Rect,
|
|
metrics: Option<ScrollMetrics>,
|
|
) -> (u16, u16) {
|
|
let viewport_row = viewport_row_for_absolute_row(self.anchor.0, metrics);
|
|
// Convert pane-relative to screen coordinates, then clamp.
|
|
let row = (viewport_row.saturating_add(pane_inner.y)).clamp(
|
|
pane_inner.y,
|
|
pane_inner.y + pane_inner.height.saturating_sub(1),
|
|
);
|
|
let col = (self.anchor.1.saturating_add(pane_inner.x)).clamp(
|
|
pane_inner.x,
|
|
pane_inner.x + pane_inner.width.saturating_sub(1),
|
|
);
|
|
(row, col)
|
|
}
|
|
|
|
/// 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,
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// Finalize the selection. Returns the selected range if the user
|
|
/// actually dragged (not just clicked). Returns None for plain clicks.
|
|
pub fn finish(&mut self) -> bool {
|
|
if self.phase == Phase::Dragging {
|
|
self.phase = Phase::Done;
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
/// Whether this selection should be rendered (highlight visible).
|
|
pub fn is_visible(&self) -> bool {
|
|
self.phase == Phase::Dragging || self.phase == Phase::Done
|
|
}
|
|
|
|
/// Whether the user just clicked without dragging (not a selection).
|
|
pub fn was_just_click(&self) -> bool {
|
|
self.phase == Phase::Anchored
|
|
}
|
|
|
|
/// Whether the user just clicked without dragging (not a selection).
|
|
pub fn is_just_click(&self) -> bool {
|
|
self.phase == Phase::Anchored
|
|
}
|
|
|
|
/// Force the selection into Dragging phase, used when the mouse
|
|
/// has moved off the anchor cell but drag() couldn't transition
|
|
/// because the cursor was clamped to the same cell as the anchor.
|
|
pub fn force_dragging(&mut self) {
|
|
if self.phase == Phase::Anchored {
|
|
self.phase = Phase::Dragging;
|
|
}
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
|
|
/// Whether the user is actively dragging (cursor moved from anchor).
|
|
pub fn is_dragging(&self) -> bool {
|
|
self.phase == Phase::Dragging
|
|
}
|
|
|
|
/// Returns (start, end) in reading order (top-left to bottom-right).
|
|
fn ordered(&self) -> ((u32, u16), (u32, u16)) {
|
|
let (ar, ac) = self.anchor;
|
|
let (cr, cc) = self.cursor;
|
|
if ar < cr || (ar == cr && ac <= cc) {
|
|
((ar, ac), (cr, cc))
|
|
} else {
|
|
((cr, cc), (ar, ac))
|
|
}
|
|
}
|
|
|
|
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, 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 {
|
|
col >= sc && col <= ec
|
|
} else if row == sr {
|
|
col >= sc
|
|
} else if row == er {
|
|
col <= ec
|
|
} else {
|
|
true
|
|
}
|
|
}
|
|
}
|
|
|
|
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 viewport_row_for_absolute_row(absolute_row: u32, metrics: Option<ScrollMetrics>) -> u16 {
|
|
absolute_row
|
|
.saturating_sub(viewport_top_row(metrics))
|
|
.try_into()
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
fn osc52_sequence(bytes: &[u8]) -> String {
|
|
use base64::Engine;
|
|
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
|
|
format!("\x1b]52;c;{encoded}\x07")
|
|
}
|
|
|
|
fn should_prefer_osc52_for_env(ssh_connection: Option<&OsStr>, ssh_tty: Option<&OsStr>) -> bool {
|
|
ssh_connection.is_some() || ssh_tty.is_some()
|
|
}
|
|
|
|
fn should_prefer_osc52() -> bool {
|
|
should_prefer_osc52_for_env(
|
|
std::env::var_os("SSH_CONNECTION").as_deref(),
|
|
std::env::var_os("SSH_TTY").as_deref(),
|
|
)
|
|
}
|
|
|
|
/// Write clipboard bytes to the system clipboard via OSC 52.
|
|
///
|
|
/// OSC 52 format: `ESC ] 52 ; c ; <base64> BEL`
|
|
///
|
|
/// Some terminals still only honor BEL-terminated OSC 52 writes, so herdr
|
|
/// emits BEL here even though ST works in newer emulators.
|
|
pub fn write_osc52_bytes(bytes: &[u8]) {
|
|
if !should_prefer_osc52() && crate::platform::write_clipboard(bytes) {
|
|
return;
|
|
}
|
|
|
|
let sequence = osc52_sequence(bytes);
|
|
let _ = std::io::stdout().write_all(sequence.as_bytes());
|
|
let _ = std::io::stdout().flush();
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
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
|
|
}
|
|
|
|
#[test]
|
|
fn osc52_sequence_uses_bel_terminator() {
|
|
assert_eq!(osc52_sequence(b"hello"), "\x1b]52;c;aGVsbG8=\x07");
|
|
}
|
|
|
|
#[test]
|
|
fn ssh_sessions_prefer_osc52() {
|
|
assert!(should_prefer_osc52_for_env(
|
|
Some(OsStr::new("1 2 3 4")),
|
|
None
|
|
));
|
|
assert!(should_prefer_osc52_for_env(
|
|
None,
|
|
Some(OsStr::new("/dev/ttys001"))
|
|
));
|
|
assert!(!should_prefer_osc52_for_env(None, None));
|
|
}
|
|
|
|
#[test]
|
|
fn ordering_forward() {
|
|
let sel = make_sel(2, 5, 4, 10);
|
|
assert_eq!(sel.ordered(), ((2, 5), (4, 10)));
|
|
}
|
|
|
|
#[test]
|
|
fn ordering_backward() {
|
|
let sel = make_sel(4, 10, 2, 5);
|
|
assert_eq!(sel.ordered(), ((2, 5), (4, 10)));
|
|
}
|
|
|
|
#[test]
|
|
fn single_line_contains() {
|
|
let sel = make_sel(2, 5, 2, 15);
|
|
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);
|
|
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, None);
|
|
assert!(!sel.is_visible());
|
|
assert!(!sel.contains(5, 10, None));
|
|
}
|
|
|
|
#[test]
|
|
fn click_without_drag() {
|
|
let mut sel = Selection::anchor(PaneId::from_raw(0), 5, 10, None);
|
|
assert!(sel.was_just_click());
|
|
let copied = sel.finish();
|
|
assert!(!copied);
|
|
}
|
|
|
|
#[test]
|
|
fn drag_then_finish() {
|
|
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();
|
|
assert!(copied);
|
|
}
|
|
|
|
#[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 (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);
|
|
}
|
|
|
|
#[test]
|
|
fn anchor_screen_pos_adds_pane_origin() {
|
|
// Pane offset by sidebar (x=10) and tab bar (y=5).
|
|
// Anchor at viewport_row=3, col=5 (pane-relative).
|
|
let sel = Selection::anchor(PaneId::from_raw(0), 3, 5, None);
|
|
let pane_inner = Rect::new(10, 5, 80, 24);
|
|
let (row, col) = sel.anchor_screen_pos(pane_inner, None);
|
|
// Screen row = 3 + 5 = 8, screen col = 5 + 10 = 15
|
|
assert_eq!(row, 8);
|
|
assert_eq!(col, 15);
|
|
}
|
|
|
|
#[test]
|
|
fn anchor_screen_pos_same_cell_as_mouse_with_offset() {
|
|
// When the pane has a non-zero origin, anchor and mouse on the same
|
|
// screen cell must compare equal — no false drag detection.
|
|
let pane_inner = Rect::new(10, 5, 80, 24);
|
|
// Mouse clicked at screen (15, 8) → anchor stored as (viewport_row=3, col=5)
|
|
let sel = Selection::anchor(PaneId::from_raw(0), 3, 5, None);
|
|
let (ar, ac) = sel.anchor_screen_pos(pane_inner, None);
|
|
// Screen position of the anchor must match the mouse position
|
|
assert_eq!((ar, ac), (8, 15));
|
|
}
|
|
}
|