Rust · 9853 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 if let Some(line) = grid.line(row) {
160 let start_col = if row == start.row { start.col } else { 0 };
161 let end_col = if row == end.row { end.col } else { cols - 1 };
162
163 for col in start_col..=end_col.min(cols - 1) {
164 let c = line[col].c;
165 if c != '\0' {
166 result.push(c);
167 }
168 }
169
170 // Add newline between lines (but not if line is wrapped)
171 if row != end.row && !line.wrapped {
172 result.push('\n');
173 }
174 }
175 }
176 }
177 SelectionMode::Line => {
178 for row in start.row..=end.row {
179 if let Some(line) = grid.line(row) {
180 // Find last non-space character
181 let mut last_non_space = 0;
182 for col in 0..cols {
183 if line[col].c != ' ' && line[col].c != '\0' {
184 last_non_space = col;
185 }
186 }
187
188 for col in 0..=last_non_space {
189 let c = line[col].c;
190 if c != '\0' {
191 result.push(c);
192 }
193 }
194
195 if row != end.row {
196 result.push('\n');
197 }
198 }
199 }
200 }
201 SelectionMode::Block => {
202 let (min_col, max_col) = if start.col <= end.col {
203 (start.col, end.col)
204 } else {
205 (end.col, start.col)
206 };
207
208 for row in start.row..=end.row {
209 if let Some(line) = grid.line(row) {
210 for col in min_col..=max_col.min(cols - 1) {
211 let c = line[col].c;
212 if c != '\0' {
213 result.push(c);
214 }
215 }
216
217 if row != end.row {
218 result.push('\n');
219 }
220 }
221 }
222 }
223 }
224
225 // Trim trailing whitespace from each line
226 result
227 .lines()
228 .map(|l| l.trim_end())
229 .collect::<Vec<_>>()
230 .join("\n")
231 }
232
233 /// Expand selection to word boundaries
234 pub fn select_word(&mut self, row: usize, col: usize, grid: &Grid, cols: usize) {
235 let Some(line) = grid.line(row) else {
236 return;
237 };
238
239 // Find word boundaries
240 let is_word_char = |c: char| c.is_alphanumeric() || c == '_';
241
242 let mut start_col = col;
243 let mut end_col = col;
244
245 // Expand left
246 while start_col > 0 && is_word_char(line[start_col - 1].c) {
247 start_col -= 1;
248 }
249
250 // Expand right
251 while end_col < cols - 1 && is_word_char(line[end_col + 1].c) {
252 end_col += 1;
253 }
254
255 self.start = Some(SelectionPoint::new(row, start_col));
256 self.end = Some(SelectionPoint::new(row, end_col));
257 self.mode = SelectionMode::Normal;
258 self.active = false;
259 }
260
261 /// Select entire line
262 pub fn select_line(&mut self, row: usize, cols: usize) {
263 self.start = Some(SelectionPoint::new(row, 0));
264 self.end = Some(SelectionPoint::new(row, cols - 1));
265 self.mode = SelectionMode::Line;
266 self.active = false;
267 }
268
269 /// Select all text
270 pub fn select_all(&mut self, rows: usize, cols: usize) {
271 self.start = Some(SelectionPoint::new(0, 0));
272 self.end = Some(SelectionPoint::new(rows - 1, cols - 1));
273 self.mode = SelectionMode::Normal;
274 self.active = false;
275 }
276 }
277
278 #[cfg(test)]
279 mod tests {
280 use super::*;
281
282 #[test]
283 fn test_selection_contains_normal() {
284 let mut sel = Selection::new();
285 sel.start(1, 5, SelectionMode::Normal);
286 sel.update(3, 10);
287 sel.finish();
288
289 // First line
290 assert!(!sel.contains(1, 4, 80));
291 assert!(sel.contains(1, 5, 80));
292 assert!(sel.contains(1, 79, 80));
293
294 // Middle line
295 assert!(sel.contains(2, 0, 80));
296 assert!(sel.contains(2, 79, 80));
297
298 // Last line
299 assert!(sel.contains(3, 0, 80));
300 assert!(sel.contains(3, 10, 80));
301 assert!(!sel.contains(3, 11, 80));
302
303 // Outside
304 assert!(!sel.contains(0, 0, 80));
305 assert!(!sel.contains(4, 0, 80));
306 }
307
308 #[test]
309 fn test_selection_contains_block() {
310 let mut sel = Selection::new();
311 sel.start(1, 5, SelectionMode::Block);
312 sel.update(3, 10);
313 sel.finish();
314
315 // Inside block
316 assert!(sel.contains(1, 5, 80));
317 assert!(sel.contains(2, 7, 80));
318 assert!(sel.contains(3, 10, 80));
319
320 // Outside block
321 assert!(!sel.contains(1, 4, 80));
322 assert!(!sel.contains(1, 11, 80));
323 assert!(!sel.contains(2, 4, 80));
324 assert!(!sel.contains(2, 11, 80));
325 }
326 }
327