Skip to main content

opendev_tui/
selection.rs

1//! Custom text selection state for mouse-based copy.
2
3use ratatui::layout::Rect;
4
5/// A position within the content (line index + character offset).
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub struct TextPosition {
8    /// Index into the wrapped line array.
9    pub line_index: usize,
10    /// Character (column) offset within the wrapped line.
11    pub char_offset: usize,
12}
13
14impl TextPosition {
15    pub fn new(line_index: usize, char_offset: usize) -> Self {
16        Self {
17            line_index,
18            char_offset,
19        }
20    }
21}
22
23impl PartialOrd for TextPosition {
24    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
25        Some(self.cmp(other))
26    }
27}
28
29impl Ord for TextPosition {
30    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
31        self.line_index
32            .cmp(&other.line_index)
33            .then(self.char_offset.cmp(&other.char_offset))
34    }
35}
36
37/// A selection range defined by anchor (where mouse-down happened) and cursor (current drag position).
38#[derive(Debug, Clone, Copy)]
39pub struct SelectionRange {
40    /// Where the selection started (mouse-down).
41    pub anchor: TextPosition,
42    /// Current end of selection (follows the mouse).
43    pub cursor: TextPosition,
44}
45
46impl SelectionRange {
47    /// Returns (start, end) in document order.
48    pub fn ordered(&self) -> (TextPosition, TextPosition) {
49        if self.anchor <= self.cursor {
50            (self.anchor, self.cursor)
51        } else {
52            (self.cursor, self.anchor)
53        }
54    }
55
56    /// Returns true if the given line index is within the selection.
57    pub fn contains_line(&self, line_index: usize) -> bool {
58        let (start, end) = self.ordered();
59        line_index >= start.line_index && line_index <= end.line_index
60    }
61
62    /// Returns the column range selected on a given line.
63    /// Returns (start_col, end_col) where end_col is exclusive.
64    pub fn columns_on_line(&self, line_index: usize, line_width: usize) -> Option<(usize, usize)> {
65        let (start, end) = self.ordered();
66        if line_index < start.line_index || line_index > end.line_index {
67            return None;
68        }
69        let col_start = if line_index == start.line_index {
70            start.char_offset
71        } else {
72            0
73        };
74        let col_end = if line_index == end.line_index {
75            end.char_offset
76        } else {
77            line_width
78        };
79        if col_start >= col_end {
80            None
81        } else {
82            Some((col_start, col_end))
83        }
84    }
85}
86
87/// State for tracking an active text selection.
88#[derive(Debug, Default)]
89pub struct SelectionState {
90    /// Whether a selection is currently active (mouse button held).
91    pub active: bool,
92    /// The current selection range (if any text is selected).
93    pub range: Option<SelectionRange>,
94    /// The conversation content area rect (set after each render).
95    pub conversation_area: Rect,
96    /// The actual scroll position (lines from top) used in the last render.
97    pub actual_scroll: usize,
98    /// Total content lines in the last render.
99    pub total_content_lines: usize,
100    /// Auto-scroll direction: -1 = up (toward top), 1 = down (toward bottom), None = no auto-scroll.
101    pub auto_scroll_direction: Option<i8>,
102}
103
104impl SelectionState {
105    /// Map a screen position (col, row) to a content-space TextPosition.
106    ///
107    /// - `col`, `row`: absolute terminal coordinates
108    /// - Returns `None` if the position is outside the conversation area.
109    pub fn screen_to_text_position(&self, col: u16, row: u16) -> Option<TextPosition> {
110        let area = self.conversation_area;
111        if area.width == 0 || area.height == 0 {
112            return None;
113        }
114        // Allow slightly out-of-area for auto-scroll (clamp later)
115        let rel_col = col.saturating_sub(area.x) as usize;
116        let rel_row = if row < area.y {
117            0
118        } else {
119            (row - area.y) as usize
120        };
121        let line_index = self.actual_scroll + rel_row;
122        let char_offset = rel_col.min(area.width as usize);
123        Some(TextPosition::new(line_index, char_offset))
124    }
125
126    /// Check if a screen position is within the conversation area.
127    pub fn is_in_conversation_area(&self, col: u16, row: u16) -> bool {
128        let area = self.conversation_area;
129        col >= area.x && col < area.x + area.width && row >= area.y && row < area.y + area.height
130    }
131
132    /// Clear the selection state.
133    pub fn clear(&mut self) {
134        self.active = false;
135        self.range = None;
136        self.auto_scroll_direction = None;
137    }
138
139    /// Start a selection at the given screen position.
140    pub fn start(&mut self, col: u16, row: u16) {
141        if let Some(pos) = self.screen_to_text_position(col, row) {
142            self.active = true;
143            self.range = Some(SelectionRange {
144                anchor: pos,
145                cursor: pos,
146            });
147            self.auto_scroll_direction = None;
148        }
149    }
150
151    /// Extend the selection to the given screen position (during drag).
152    pub fn extend(&mut self, col: u16, row: u16) {
153        let area = self.conversation_area;
154
155        // Set auto-scroll direction based on position relative to conversation area
156        if row < area.y + 1 {
157            self.auto_scroll_direction = Some(-1); // scroll up
158        } else if row >= area.y + area.height.saturating_sub(1) {
159            self.auto_scroll_direction = Some(1); // scroll down
160        } else {
161            self.auto_scroll_direction = None;
162        }
163
164        if let Some(pos) = self.screen_to_text_position(col, row)
165            && let Some(ref mut range) = self.range
166        {
167            range.cursor = pos;
168        }
169    }
170
171    /// Finalize the selection on mouse-up. Returns true if there's a non-empty selection.
172    pub fn finalize(&mut self) -> bool {
173        self.active = false;
174        self.auto_scroll_direction = None;
175        self.range.is_some_and(|r| {
176            let (start, end) = r.ordered();
177            start != end
178        })
179    }
180}
181
182#[cfg(test)]
183#[path = "selection_tests.rs"]
184mod tests;