Skip to main content

max / shop

19.4 KB · 548 lines History Blame Raw
1 //! Mouse selection over the visible grid.
2 //!
3 //! A [`Selection`] is the raw gesture: where the drag started, where the
4 //! pointer is now, and what granularity the click count asked for. It knows
5 //! nothing about cell contents, so the binary can carry one across events
6 //! without borrowing the grid.
7 //!
8 //! Resolving it against the grid produces a [`SelectionSpan`] — the actual
9 //! covered cells, with word and line granularity expanded. The renderer asks a
10 //! span whether a cell is covered; [`Grid::selection_text`] turns one into the
11 //! string that goes on the clipboard.
12 //!
13 //! There is no scrollback in the grid yet, so every coordinate here is a
14 //! viewport coordinate and a selection dies when its rows scroll off the top.
15
16 use crate::{Cell, Grid};
17
18 /// Characters that end a word for double-click purposes.
19 ///
20 /// Deliberately short. Paths, URLs and flags are the things people
21 /// double-click in a terminal, so `/`, `.`, `-`, `_`, `~`, `:` and `=` stay
22 /// inside the word even though a prose-oriented list would split on them.
23 const WORD_DELIMITERS: &str = " \t\u{a0},;'\"`|()[]{}<>";
24
25 fn is_word_char(c: char) -> bool {
26 c != '\0' && !WORD_DELIMITERS.contains(c)
27 }
28
29 /// The character a column reads as when deciding where a word ends.
30 ///
31 /// A wide character's second column holds a blank, and a blank is a delimiter,
32 /// so reading it literally would end every word on the first CJK character in
33 /// it. It reads as the character it belongs to instead.
34 fn word_char_at(cells: &[Cell], col: usize) -> char {
35 if cells[col].is_spacer() && col > 0 {
36 cells[col - 1].c()
37 } else {
38 cells[col].c()
39 }
40 }
41
42 /// A cell coordinate in the viewport. Ordered row-major, which is the order
43 /// text is read out in.
44 #[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
45 pub struct Point {
46 pub row: u16,
47 pub col: u16,
48 }
49
50 impl Point {
51 pub fn new(row: u16, col: u16) -> Self {
52 Self { row, col }
53 }
54 }
55
56 /// Granularity, set by click count (and Ctrl for the block variant).
57 #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
58 pub enum SelectionMode {
59 /// Single click: cell to cell.
60 #[default]
61 Char,
62 /// Double click: whole words at both ends.
63 Word,
64 /// Triple click: whole rows.
65 Line,
66 /// Ctrl+drag: a rectangle rather than a run of text.
67 Block,
68 }
69
70 /// One in-progress or finished drag.
71 #[derive(Copy, Clone, Debug, PartialEq, Eq)]
72 pub struct Selection {
73 /// Where the button went down. Fixed for the life of the drag.
74 pub anchor: Point,
75 /// Where the pointer is now. Moves with every motion event.
76 pub head: Point,
77 pub mode: SelectionMode,
78 }
79
80 impl Selection {
81 pub fn new(mode: SelectionMode, at: Point) -> Self {
82 Self {
83 anchor: at,
84 head: at,
85 mode,
86 }
87 }
88
89 /// Move the loose end. Called on every pointer motion while the button is
90 /// held.
91 pub fn drag_to(&mut self, at: Point) {
92 self.head = at;
93 }
94
95 /// True when the drag never left its starting cell, in char mode — the
96 /// gesture was a plain click, so there is nothing to copy and the binary
97 /// should drop the selection rather than highlight one cell.
98 pub fn is_empty(&self) -> bool {
99 self.mode == SelectionMode::Char && self.anchor == self.head
100 }
101
102 /// Follow a full-screen scroll of `delta` rows (positive = content moved
103 /// up, matching [`crate::Damage::scroll`]).
104 ///
105 /// Returns `None` once the selection has scrolled entirely off the top,
106 /// which is the point at which the binary drops it. A selection that is
107 /// only partly off-screen is clamped to what is still visible: the
108 /// alternative is losing a long selection the moment one line of output
109 /// arrives.
110 pub fn scrolled(mut self, delta: i16, rows: u16) -> Option<Self> {
111 if delta == 0 {
112 return Some(self);
113 }
114 let last = i32::from(rows.saturating_sub(1));
115 let shift = |row: u16| i32::from(row) - i32::from(delta);
116 let (a, h) = (shift(self.anchor.row), shift(self.head.row));
117 if (a < 0 && h < 0) || (a > last && h > last) {
118 return None;
119 }
120 self.anchor.row = a.clamp(0, last) as u16;
121 self.head.row = h.clamp(0, last) as u16;
122 Some(self)
123 }
124 }
125
126 /// A selection resolved against grid contents: the cells actually covered.
127 ///
128 /// `start` and `end` are both inclusive. For everything but [`SelectionMode::Block`]
129 /// they bound a row-major run; for `Block` they are opposite corners of a
130 /// rectangle.
131 #[derive(Copy, Clone, Debug, PartialEq, Eq)]
132 pub struct SelectionSpan {
133 pub start: Point,
134 pub end: Point,
135 pub block: bool,
136 }
137
138 impl SelectionSpan {
139 /// Is this cell inside the selection? Called once per visible cell per
140 /// frame by the renderer's fill scan.
141 #[inline]
142 pub fn contains(&self, row: u16, col: u16) -> bool {
143 if row < self.start.row || row > self.end.row {
144 return false;
145 }
146 if self.block {
147 let (lo, hi) = min_max(self.start.col, self.end.col);
148 return col >= lo && col <= hi;
149 }
150 if row == self.start.row && col < self.start.col {
151 return false;
152 }
153 if row == self.end.row && col > self.end.col {
154 return false;
155 }
156 true
157 }
158
159 /// Inclusive column range covered on `row`. `None` when the row is
160 /// outside the selection.
161 ///
162 /// The renderer draws a selection as one quad per row rather than one per
163 /// cell, so this is the shape it wants: a run, not a predicate.
164 pub fn cols_on(&self, row: u16, grid_cols: u16) -> Option<(u16, u16)> {
165 if row < self.start.row || row > self.end.row {
166 return None;
167 }
168 let last = grid_cols.saturating_sub(1);
169 if self.block {
170 let (lo, hi) = min_max(self.start.col, self.end.col);
171 return Some((lo, hi.min(last)));
172 }
173 let lo = if row == self.start.row {
174 self.start.col
175 } else {
176 0
177 };
178 let hi = if row == self.end.row {
179 self.end.col
180 } else {
181 last
182 };
183 Some((lo, hi.min(last)))
184 }
185 }
186
187 fn min_max(a: u16, b: u16) -> (u16, u16) {
188 if a <= b { (a, b) } else { (b, a) }
189 }
190
191 impl Grid {
192 /// Resolve a gesture into the cells it covers, expanding word and line
193 /// granularity against the current contents.
194 pub fn selection_span(&self, sel: &Selection) -> SelectionSpan {
195 let last_col = self.cols().saturating_sub(1);
196 let last_row = self.rows().saturating_sub(1);
197 let clamp = |p: Point| Point::new(p.row.min(last_row), p.col.min(last_col));
198 let (anchor, head) = (clamp(sel.anchor), clamp(sel.head));
199
200 if sel.mode == SelectionMode::Block {
201 let (start_row, end_row) = min_max(anchor.row, head.row);
202 return SelectionSpan {
203 start: Point::new(start_row, anchor.col),
204 end: Point::new(end_row, head.col),
205 block: true,
206 };
207 }
208
209 let (mut start, mut end) = if anchor <= head {
210 (anchor, head)
211 } else {
212 (head, anchor)
213 };
214 match sel.mode {
215 SelectionMode::Word => {
216 start.col = self.word_start(start.row, start.col);
217 end.col = self.word_end(end.row, end.col);
218 }
219 SelectionMode::Line => {
220 start.col = 0;
221 end.col = last_col;
222 }
223 SelectionMode::Char | SelectionMode::Block => {}
224 }
225 SelectionSpan {
226 start,
227 end,
228 block: false,
229 }
230 }
231
232 /// The selected text, ready for the clipboard.
233 ///
234 /// Two rules, both about not inventing characters the user never saw:
235 ///
236 /// - Trailing blanks come off each row. The grid pads every row out to
237 /// full width, so without the strip a one-word selection spanning two
238 /// rows would arrive carrying eighty spaces in the middle of it.
239 /// - A row that ran off the right edge joins the next one with no newline
240 /// ([`Grid::row_wrapped`]). A wrapped command line has to paste back as
241 /// the one line it was, or half of it executes on its own.
242 ///
243 /// Block selections always break by row: a rectangle out of the middle of
244 /// the screen is columnar by intent, and the wrap that produced the rows
245 /// is not part of what was asked for.
246 pub fn selection_text(&self, sel: &Selection) -> String {
247 let span = self.selection_span(sel);
248 let mut out = String::new();
249 for row in span.start.row..=span.end.row {
250 let Some((lo, hi)) = span.cols_on(row, self.cols()) else {
251 continue;
252 };
253 if row > span.start.row && (span.block || !self.row_wrapped(row - 1)) {
254 out.push('\n');
255 }
256 let cells = self.row(row);
257 // A drag that stopped on half of a wide character still meant that
258 // character: the user cannot aim at a half. Grow the span to the
259 // whole of the characters it touches before reading it.
260 let lo = if cells[lo as usize].is_spacer() && lo > 0 {
261 lo - 1
262 } else {
263 lo
264 };
265 let hi = if cells[hi as usize].is_wide() && hi + 1 < self.cols() {
266 hi + 1
267 } else {
268 hi
269 };
270 // One entry per character, not per column: a wide character's
271 // second column is not its own character and copies as nothing,
272 // and a character carrying combining marks copies with them.
273 let mut line = String::new();
274 for cell in cells[lo as usize..=hi as usize]
275 .iter()
276 .filter(|cell| !cell.is_spacer())
277 {
278 self.push_cell_text(cell, &mut line);
279 }
280 // Trailing blanks on a wrapped row are real cells the text ran
281 // through, not padding — stripping them would eat the space
282 // between two words that happened to straddle the edge.
283 if self.row_wrapped(row) && !span.block {
284 out.push_str(&line);
285 } else {
286 out.push_str(line.trim_end());
287 }
288 }
289 out
290 }
291
292 /// First column of the word containing `col`. A click on a delimiter
293 /// selects the run of delimiters instead, so double-clicking whitespace
294 /// gives you the whitespace rather than nothing.
295 fn word_start(&self, row: u16, col: u16) -> u16 {
296 let cells = self.row(row);
297 let wanted = is_word_char(word_char_at(cells, col as usize));
298 let mut c = col;
299 while c > 0 && is_word_char(word_char_at(cells, c as usize - 1)) == wanted {
300 c -= 1;
301 }
302 c
303 }
304
305 /// Last column of the word containing `col`, inclusive.
306 fn word_end(&self, row: u16, col: u16) -> u16 {
307 let cells = self.row(row);
308 let last = self.cols() - 1;
309 let wanted = is_word_char(word_char_at(cells, col as usize));
310 let mut c = col;
311 while c < last && is_word_char(word_char_at(cells, c as usize + 1)) == wanted {
312 c += 1;
313 }
314 c
315 }
316 }
317
318 #[cfg(test)]
319 mod tests {
320 use super::*;
321 use shop_vt::Parser;
322
323 fn grid_with(lines: &[&str], cols: u16) -> Grid {
324 let mut grid = Grid::new(cols, lines.len() as u16);
325 let mut parser = Parser::new();
326 let joined = lines.join("\r\n");
327 parser.advance(&mut grid, joined.as_bytes());
328 grid
329 }
330
331 /// A grid fed raw bytes, so tests can drive the deferred wrap.
332 fn grid_fed(cols: u16, rows: u16, bytes: &str) -> Grid {
333 let mut grid = Grid::new(cols, rows);
334 let mut parser = Parser::new();
335 parser.advance(&mut grid, bytes.as_bytes());
336 grid
337 }
338
339 fn sel(mode: SelectionMode, from: (u16, u16), to: (u16, u16)) -> Selection {
340 let mut s = Selection::new(mode, Point::new(from.0, from.1));
341 s.drag_to(Point::new(to.0, to.1));
342 s
343 }
344
345 #[test]
346 fn char_selection_within_one_row() {
347 let grid = grid_with(&["hello world"], 20);
348 let s = sel(SelectionMode::Char, (0, 0), (0, 4));
349 assert_eq!(grid.selection_text(&s), "hello");
350 }
351
352 #[test]
353 fn char_selection_is_direction_agnostic() {
354 let grid = grid_with(&["hello world"], 20);
355 let forward = sel(SelectionMode::Char, (0, 6), (0, 10));
356 let backward = sel(SelectionMode::Char, (0, 10), (0, 6));
357 assert_eq!(grid.selection_text(&forward), "world");
358 assert_eq!(grid.selection_text(&backward), "world");
359 }
360
361 #[test]
362 fn multi_row_selection_joins_with_newlines_and_strips_padding() {
363 let grid = grid_with(&["one", "two", "three"], 20);
364 let s = sel(SelectionMode::Char, (0, 0), (2, 4));
365 assert_eq!(grid.selection_text(&s), "one\ntwo\nthree");
366 }
367
368 #[test]
369 fn multi_row_selection_keeps_partial_first_and_last_rows() {
370 let grid = grid_with(&["abcdef", "ghijkl"], 20);
371 let s = sel(SelectionMode::Char, (0, 3), (1, 2));
372 assert_eq!(grid.selection_text(&s), "def\nghi");
373 }
374
375 #[test]
376 fn blank_row_inside_a_selection_stays_blank() {
377 let grid = grid_with(&["top", "", "bottom"], 20);
378 let s = sel(SelectionMode::Char, (0, 0), (2, 5));
379 assert_eq!(grid.selection_text(&s), "top\n\nbottom");
380 }
381
382 #[test]
383 fn a_wrapped_line_copies_back_as_one_line() {
384 let grid = grid_fed(6, 3, "abcdefghij");
385 let s = sel(SelectionMode::Char, (0, 0), (1, 3));
386 assert_eq!(grid.selection_text(&s), "abcdefghij");
387 }
388
389 #[test]
390 fn a_row_that_filled_exactly_still_breaks_at_the_newline() {
391 // Six columns of text ended with CR/LF is not a wrap, even though the
392 // cursor sat on the right edge.
393 let grid = grid_fed(6, 3, "abcdef\r\nghij");
394 let s = sel(SelectionMode::Char, (0, 0), (1, 3));
395 assert_eq!(grid.selection_text(&s), "abcdef\nghij");
396 }
397
398 #[test]
399 fn a_space_straddling_the_wrap_survives_the_copy() {
400 // "ab " fills the row with real spaces before "cd" wraps onto the
401 // next; trimming them would glue the two words together.
402 let grid = grid_fed(6, 3, "ab cd");
403 let s = sel(SelectionMode::Char, (0, 0), (1, 1));
404 assert_eq!(grid.selection_text(&s), "ab cd");
405 }
406
407 #[test]
408 fn three_rows_of_one_wrapped_line_copy_as_one_line() {
409 let grid = grid_fed(4, 4, "0123456789ab");
410 let s = sel(SelectionMode::Line, (0, 0), (2, 0));
411 assert_eq!(grid.selection_text(&s), "0123456789ab");
412 }
413
414 #[test]
415 fn a_block_selection_breaks_by_row_even_across_a_wrap() {
416 let grid = grid_fed(6, 3, "abcdefghijkl");
417 let s = sel(SelectionMode::Block, (0, 1), (1, 2));
418 assert_eq!(grid.selection_text(&s), "bc\nhi");
419 }
420
421 #[test]
422 fn erasing_to_the_edge_ends_the_continuation() {
423 // Wrap, then EL0 from the start of the first row: the tail that ran
424 // off the edge is gone, so the rows are separate lines again.
425 let grid = grid_fed(6, 3, "abcdefghij\x1b[H\x1b[K");
426 let s = sel(SelectionMode::Char, (0, 0), (1, 3));
427 assert_eq!(grid.selection_text(&s), "\nghij");
428 }
429
430 #[test]
431 fn word_mode_expands_both_ends() {
432 let grid = grid_with(&["alpha beta gamma"], 20);
433 // Anchor inside "beta", head inside "gamma".
434 let s = sel(SelectionMode::Word, (0, 7), (0, 12));
435 assert_eq!(grid.selection_text(&s), "beta gamma");
436 }
437
438 #[test]
439 fn word_mode_on_a_single_click_takes_the_whole_word() {
440 let grid = grid_with(&["alpha beta gamma"], 20);
441 let s = Selection::new(SelectionMode::Word, Point::new(0, 8));
442 assert_eq!(grid.selection_text(&s), "beta");
443 }
444
445 #[test]
446 fn word_mode_keeps_paths_and_flags_intact() {
447 let grid = grid_with(&["cargo --offline /usr/lib/foo.so"], 40);
448 let path = Selection::new(SelectionMode::Word, Point::new(0, 20));
449 assert_eq!(grid.selection_text(&path), "/usr/lib/foo.so");
450 let flag = Selection::new(SelectionMode::Word, Point::new(0, 8));
451 assert_eq!(grid.selection_text(&flag), "--offline");
452 }
453
454 #[test]
455 fn word_mode_on_a_delimiter_takes_the_delimiter_run() {
456 let grid = grid_with(&["a b"], 20);
457 let s = Selection::new(SelectionMode::Word, Point::new(0, 3));
458 assert_eq!(grid.selection_text(&s), "");
459 let span = grid.selection_span(&s);
460 assert_eq!((span.start.col, span.end.col), (1, 4));
461 }
462
463 #[test]
464 fn line_mode_takes_whole_rows() {
465 let grid = grid_with(&["first line", "second line"], 20);
466 let s = sel(SelectionMode::Line, (0, 4), (1, 2));
467 assert_eq!(grid.selection_text(&s), "first line\nsecond line");
468 }
469
470 #[test]
471 fn block_mode_cuts_a_column_out_of_every_row() {
472 let grid = grid_with(&["abcdef", "ghijkl", "mnopqr"], 20);
473 let s = sel(SelectionMode::Block, (0, 1), (2, 3));
474 assert_eq!(grid.selection_text(&s), "bcd\nhij\nnop");
475 }
476
477 #[test]
478 fn block_mode_is_corner_agnostic() {
479 let grid = grid_with(&["abcdef", "ghijkl"], 20);
480 let s = sel(SelectionMode::Block, (1, 3), (0, 1));
481 assert_eq!(grid.selection_text(&s), "bcd\nhij");
482 }
483
484 #[test]
485 fn contains_covers_the_run_not_the_bounding_box() {
486 let grid = grid_with(&["abcdef", "ghijkl", "mnopqr"], 6);
487 let span = grid.selection_span(&sel(SelectionMode::Char, (0, 3), (2, 1)));
488 assert!(!span.contains(0, 2));
489 assert!(span.contains(0, 3));
490 // Middle row is covered end to end.
491 assert!(span.contains(1, 0));
492 assert!(span.contains(1, 5));
493 assert!(span.contains(2, 1));
494 assert!(!span.contains(2, 2));
495 assert!(!span.contains(3, 0));
496 }
497
498 #[test]
499 fn contains_on_a_block_is_the_bounding_box() {
500 let grid = grid_with(&["abcdef", "ghijkl", "mnopqr"], 6);
501 let span = grid.selection_span(&sel(SelectionMode::Block, (0, 3), (2, 1)));
502 assert!(span.contains(0, 1));
503 assert!(span.contains(1, 2));
504 assert!(!span.contains(1, 0));
505 assert!(!span.contains(1, 4));
506 }
507
508 #[test]
509 fn out_of_range_points_clamp_to_the_grid() {
510 let grid = grid_with(&["abc"], 4);
511 let s = sel(SelectionMode::Char, (0, 0), (99, 99));
512 assert_eq!(grid.selection_text(&s), "abc");
513 }
514
515 #[test]
516 fn a_plain_click_is_empty() {
517 let s = Selection::new(SelectionMode::Char, Point::new(2, 5));
518 assert!(s.is_empty());
519 let mut dragged = s;
520 dragged.drag_to(Point::new(2, 6));
521 assert!(!dragged.is_empty());
522 // A double click is a selection even without motion.
523 assert!(!Selection::new(SelectionMode::Word, Point::new(2, 5)).is_empty());
524 }
525
526 #[test]
527 fn scrolling_moves_a_selection_up() {
528 let s = sel(SelectionMode::Char, (4, 0), (6, 3));
529 let moved = s.scrolled(2, 24).expect("still on screen");
530 assert_eq!(moved.anchor.row, 2);
531 assert_eq!(moved.head.row, 4);
532 }
533
534 #[test]
535 fn scrolling_past_the_top_drops_the_selection() {
536 let s = sel(SelectionMode::Char, (0, 0), (1, 3));
537 assert!(s.scrolled(5, 24).is_none());
538 }
539
540 #[test]
541 fn a_partly_scrolled_selection_clamps_to_what_is_left() {
542 let s = sel(SelectionMode::Char, (1, 0), (8, 3));
543 let moved = s.scrolled(3, 24).expect("tail still on screen");
544 assert_eq!(moved.anchor.row, 0);
545 assert_eq!(moved.head.row, 5);
546 }
547 }
548