Rust · 11302 bytes Raw Blame History
1 //! Text selection handling for terminal
2
3 use crate::terminal::Grid;
4
5 /// Selection mode
6 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
7 pub enum SelectionMode {
8 /// Normal character selection (follows line wrapping)
9 #[default]
10 Normal,
11 /// Line selection (selects entire lines)
12 Line,
13 /// Block/rectangular selection
14 Block,
15 }
16
17 /// A point in the terminal grid
18 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
19 pub struct SelectionPoint {
20 pub row: usize,
21 pub col: usize,
22 }
23
24 impl SelectionPoint {
25 pub fn new(row: usize, col: usize) -> Self {
26 Self { row, col }
27 }
28 }
29
30 /// Text selection state
31 #[derive(Debug, Clone, Default)]
32 pub struct Selection {
33 /// Selection start point (anchor)
34 start: Option<SelectionPoint>,
35 /// Selection end point (cursor)
36 end: Option<SelectionPoint>,
37 /// Selection mode
38 mode: SelectionMode,
39 /// Whether selection is active (mouse button held)
40 active: bool,
41 }
42
43 impl Selection {
44 /// Create a new empty selection
45 pub fn new() -> Self {
46 Self::default()
47 }
48
49 /// Start a new selection at the given point
50 pub fn start(&mut self, row: usize, col: usize, mode: SelectionMode) {
51 self.start = Some(SelectionPoint::new(row, col));
52 self.end = Some(SelectionPoint::new(row, col));
53 self.mode = mode;
54 self.active = true;
55 }
56
57 /// Update selection end point (during drag)
58 pub fn update(&mut self, row: usize, col: usize) {
59 if self.active {
60 self.end = Some(SelectionPoint::new(row, col));
61 }
62 }
63
64 /// Finish selection (mouse button released)
65 pub fn finish(&mut self) {
66 self.active = false;
67 }
68
69 /// Clear the selection
70 pub fn clear(&mut self) {
71 self.start = None;
72 self.end = None;
73 self.active = false;
74 }
75
76 /// Check if there is an active selection
77 pub fn is_empty(&self) -> bool {
78 self.start.is_none() || self.end.is_none()
79 }
80
81 /// Check if selection is currently being made (dragging)
82 pub fn is_active(&self) -> bool {
83 self.active
84 }
85
86 /// Get selection mode
87 pub fn mode(&self) -> SelectionMode {
88 self.mode
89 }
90
91 /// Get normalized selection bounds (start <= end)
92 pub fn bounds(&self) -> Option<(SelectionPoint, SelectionPoint)> {
93 let start = self.start?;
94 let end = self.end?;
95
96 // Normalize so start is before end
97 let (start, end) = if (start.row, start.col) <= (end.row, end.col) {
98 (start, end)
99 } else {
100 (end, start)
101 };
102
103 Some((start, end))
104 }
105
106 /// Check if a cell is within the selection
107 pub fn contains(&self, row: usize, col: usize, _cols: usize) -> bool {
108 let Some((start, end)) = self.bounds() else {
109 return false;
110 };
111
112 match self.mode {
113 SelectionMode::Normal => {
114 // Normal selection: continuous from start to end
115 if row < start.row || row > end.row {
116 return false;
117 }
118 if row == start.row && row == end.row {
119 // Single line
120 col >= start.col && col <= end.col
121 } else if row == start.row {
122 // First line
123 col >= start.col
124 } else if row == end.row {
125 // Last line
126 col <= end.col
127 } else {
128 // Middle lines - entire line selected
129 true
130 }
131 }
132 SelectionMode::Line => {
133 // Line selection: entire lines
134 row >= start.row && row <= end.row
135 }
136 SelectionMode::Block => {
137 // Block selection: rectangular
138 let (min_col, max_col) = if start.col <= end.col {
139 (start.col, end.col)
140 } else {
141 (end.col, start.col)
142 };
143 row >= start.row && row <= end.row && col >= min_col && col <= max_col
144 }
145 }
146 }
147
148 /// Get selected text from the grid
149 pub fn get_text(&self, grid: &Grid, cols: usize) -> String {
150 let Some((start, end)) = self.bounds() else {
151 return String::new();
152 };
153
154 let mut result = String::new();
155
156 match self.mode {
157 SelectionMode::Normal => {
158 for row in start.row..=end.row {
159 // Use line_absolute to access scrollback + active lines
160 if let Some(line) = grid.line_absolute(row) {
161 let start_col = if row == start.row { start.col } else { 0 };
162 let end_col = if row == end.row { end.col } else { cols - 1 };
163
164 for col in start_col..=end_col.min(cols - 1) {
165 let c = line[col].c;
166 if c != '\0' {
167 result.push(c);
168 }
169 }
170
171 // Add newline between lines (but not if line is wrapped)
172 if row != end.row && !line.wrapped {
173 result.push('\n');
174 }
175 }
176 }
177 }
178 SelectionMode::Line => {
179 for row in start.row..=end.row {
180 if let Some(line) = grid.line_absolute(row) {
181 // Find last non-space character
182 let mut last_non_space = 0;
183 for col in 0..cols {
184 if line[col].c != ' ' && line[col].c != '\0' {
185 last_non_space = col;
186 }
187 }
188
189 for col in 0..=last_non_space {
190 let c = line[col].c;
191 if c != '\0' {
192 result.push(c);
193 }
194 }
195
196 if row != end.row {
197 result.push('\n');
198 }
199 }
200 }
201 }
202 SelectionMode::Block => {
203 let (min_col, max_col) = if start.col <= end.col {
204 (start.col, end.col)
205 } else {
206 (end.col, start.col)
207 };
208
209 for row in start.row..=end.row {
210 if let Some(line) = grid.line_absolute(row) {
211 for col in min_col..=max_col.min(cols - 1) {
212 let c = line[col].c;
213 if c != '\0' {
214 result.push(c);
215 }
216 }
217
218 if row != end.row {
219 result.push('\n');
220 }
221 }
222 }
223 }
224 }
225
226 // Trim trailing whitespace from each line
227 result
228 .lines()
229 .map(|l| l.trim_end())
230 .collect::<Vec<_>>()
231 .join("\n")
232 }
233
234 /// Expand selection to word boundaries (row is absolute)
235 pub fn select_word(&mut self, row: usize, col: usize, grid: &Grid, cols: usize) {
236 let Some(line) = grid.line_absolute(row) else {
237 return;
238 };
239
240 // Find word boundaries
241 let is_word_char = |c: char| c.is_alphanumeric() || c == '_';
242
243 let mut start_col = col;
244 let mut end_col = col;
245
246 // Expand left
247 while start_col > 0 && is_word_char(line[start_col - 1].c) {
248 start_col -= 1;
249 }
250
251 // Expand right
252 while end_col < cols - 1 && is_word_char(line[end_col + 1].c) {
253 end_col += 1;
254 }
255
256 self.start = Some(SelectionPoint::new(row, start_col));
257 self.end = Some(SelectionPoint::new(row, end_col));
258 self.mode = SelectionMode::Normal;
259 self.active = false;
260 }
261
262 /// Select entire logical line (including wrapped continuations)
263 /// Row is absolute (scrollback + active)
264 pub fn select_line(&mut self, row: usize, cols: usize, grid: &Grid) {
265 // Find the start of the logical line by searching backwards
266 // A line is a continuation if the PREVIOUS line has wrapped=true
267 let mut start_row = row;
268 while start_row > 0 {
269 if let Some(prev_line) = grid.line_absolute(start_row - 1) {
270 if prev_line.wrapped {
271 // Previous line wraps into this one, keep going back
272 start_row -= 1;
273 } else {
274 // Previous line doesn't wrap, we found the start
275 break;
276 }
277 } else {
278 break;
279 }
280 }
281
282 // Find the end of the logical line by searching forwards
283 // Keep going while the current line has wrapped=true
284 let mut end_row = row;
285 loop {
286 if let Some(line) = grid.line_absolute(end_row) {
287 if line.wrapped {
288 // This line wraps to the next, keep going
289 end_row += 1;
290 } else {
291 // This line doesn't wrap, we found the end
292 break;
293 }
294 } else {
295 break;
296 }
297 }
298
299 self.start = Some(SelectionPoint::new(start_row, 0));
300 self.end = Some(SelectionPoint::new(end_row, cols - 1));
301 self.mode = SelectionMode::Line;
302 self.active = false;
303 }
304
305 /// Select all text
306 pub fn select_all(&mut self, rows: usize, cols: usize) {
307 self.start = Some(SelectionPoint::new(0, 0));
308 self.end = Some(SelectionPoint::new(rows - 1, cols - 1));
309 self.mode = SelectionMode::Normal;
310 self.active = false;
311 }
312 }
313
314 #[cfg(test)]
315 mod tests {
316 use super::*;
317
318 #[test]
319 fn test_selection_contains_normal() {
320 let mut sel = Selection::new();
321 sel.start(1, 5, SelectionMode::Normal);
322 sel.update(3, 10);
323 sel.finish();
324
325 // First line
326 assert!(!sel.contains(1, 4, 80));
327 assert!(sel.contains(1, 5, 80));
328 assert!(sel.contains(1, 79, 80));
329
330 // Middle line
331 assert!(sel.contains(2, 0, 80));
332 assert!(sel.contains(2, 79, 80));
333
334 // Last line
335 assert!(sel.contains(3, 0, 80));
336 assert!(sel.contains(3, 10, 80));
337 assert!(!sel.contains(3, 11, 80));
338
339 // Outside
340 assert!(!sel.contains(0, 0, 80));
341 assert!(!sel.contains(4, 0, 80));
342 }
343
344 #[test]
345 fn test_selection_contains_block() {
346 let mut sel = Selection::new();
347 sel.start(1, 5, SelectionMode::Block);
348 sel.update(3, 10);
349 sel.finish();
350
351 // Inside block
352 assert!(sel.contains(1, 5, 80));
353 assert!(sel.contains(2, 7, 80));
354 assert!(sel.contains(3, 10, 80));
355
356 // Outside block
357 assert!(!sel.contains(1, 4, 80));
358 assert!(!sel.contains(1, 11, 80));
359 assert!(!sel.contains(2, 4, 80));
360 assert!(!sel.contains(2, 11, 80));
361 }
362 }
363