aboutsummaryrefslogtreecommitdiff
path: root/src/selection.rs
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-03-23 00:36:52 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-03-27 21:37:05 +0300
commita57b97285c3308fbdcc167ab3da9ae1dd78767b6 (patch)
tree25a0eb4df100ea9b09c7551fa7cc25f8d86027ef /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.rs253
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);
+ }
+
+
+}