diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-03-23 00:36:52 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-03-27 21:37:05 +0300 |
| commit | a57b97285c3308fbdcc167ab3da9ae1dd78767b6 (patch) | |
| tree | 25a0eb4df100ea9b09c7551fa7cc25f8d86027ef /src/selection.rs | |
initial release
terminal workspace manager for AI coding agents.
single rust binary, runs inside your existing terminal.
workspaces, tiled panes, intelligent agent state detection.
https://herdr.dev
Diffstat (limited to 'src/selection.rs')
| -rw-r--r-- | src/selection.rs | 253 |
1 files changed, 253 insertions, 0 deletions
diff --git a/src/selection.rs b/src/selection.rs new file mode 100644 index 00000000..7b020575 --- /dev/null +++ b/src/selection.rs @@ -0,0 +1,253 @@ +//! 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 +//! +//! Coordinates are stored relative to the pane's inner area (the region +//! where terminal content is rendered, excluding borders). + +use ratatui::layout::Rect; +use std::io::Write; + +use crate::layout::PaneId; + +/// 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 pane-relative coordinates (row, col). + anchor: (u16, u16), + /// Current/final position in pane-relative coordinates (row, col). + cursor: (u16, 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 { + Self { + pane_id, + anchor: (row, col), + cursor: (row, col), + 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); + 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 + } + + /// Returns (start, end) in reading order (top-left to bottom-right). + fn ordered(&self) -> ((u16, u16), (u16, 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)) + } + } + + /// Check whether a pane-relative cell (row, col) is inside the selection. + pub fn contains(&self, row: u16, col: u16) -> bool { + if !self.is_visible() { + return false; + } + 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) + } +} + +/// Extract selected text from a vt100 screen. +pub fn extract_text(screen: &vt100::Screen, selection: &Selection) -> String { + let ((sr, sc), (er, ec)) = selection.ordered(); + // contents_between end_col is exclusive in vt100 + screen.contents_between(sr, sc, er, ec + 1) +} + +/// Write text to the system clipboard via OSC 52. +/// +/// OSC 52 format: `ESC ] 52 ; c ; <base64> BEL` +/// +/// The terminal emulator (Ghostty, kitty, etc.) intercepts this +/// and sets the system clipboard. Ghostty has `clipboard-write = allow` +/// by default. +pub fn write_osc52(text: &str) { + use base64::Engine; + let encoded = base64::engine::general_purpose::STANDARD.encode(text); + // Use ST (ESC \) terminator — more widely supported than BEL in some terminals + let sequence = format!("\x1b]52;c;{encoded}\x1b\\"); + let _ = std::io::stdout().write_all(sequence.as_bytes()); + let _ = std::io::stdout().flush(); +} + +#[cfg(test)] +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)); + sel.cursor = (er, ec); + sel.phase = Phase::Dragging; + sel + } + + #[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)); + 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)); + } + + #[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)); + } + + #[test] + fn anchored_not_visible() { + let sel = Selection::anchor(PaneId::from_raw(0), 5, 10, Rect::new(0, 0, 80, 24)); + assert!(!sel.is_visible()); + assert!(!sel.contains(5, 10)); + } + + #[test] + fn click_without_drag() { + let mut sel = Selection::anchor(PaneId::from_raw(0), 5, 10, Rect::new(0, 0, 80, 24)); + 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, Rect::new(10, 5, 80, 24)); + sel.drag(20, 7); // screen coords → clamped to pane + assert!(sel.is_visible()); + assert!(!sel.was_just_click()); + let copied = sel.finish(); + assert!(copied); + } + + #[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); + assert_eq!(row, 0); + assert_eq!(col, 0); + } + + +} |
