//! 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, ) -> 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, ) -> (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, ) { 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) -> 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) -> 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 viewport_row_for_absolute_row(absolute_row: u32, metrics: Option) -> 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 ; 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)); } }