//! Mouse selection over the visible grid. //! //! A [`Selection`] is the raw gesture: where the drag started, where the //! pointer is now, and what granularity the click count asked for. It knows //! nothing about cell contents, so the binary can carry one across events //! without borrowing the grid. //! //! Resolving it against the grid produces a [`SelectionSpan`] — the actual //! covered cells, with word and line granularity expanded. The renderer asks a //! span whether a cell is covered; [`Grid::selection_text`] turns one into the //! string that goes on the clipboard. //! //! There is no scrollback in the grid yet, so every coordinate here is a //! viewport coordinate and a selection dies when its rows scroll off the top. use crate::{Cell, Grid}; /// Characters that end a word for double-click purposes. /// /// Deliberately short. Paths, URLs and flags are the things people /// double-click in a terminal, so `/`, `.`, `-`, `_`, `~`, `:` and `=` stay /// inside the word even though a prose-oriented list would split on them. const WORD_DELIMITERS: &str = " \t\u{a0},;'\"`|()[]{}<>"; fn is_word_char(c: char) -> bool { c != '\0' && !WORD_DELIMITERS.contains(c) } /// The character a column reads as when deciding where a word ends. /// /// A wide character's second column holds a blank, and a blank is a delimiter, /// so reading it literally would end every word on the first CJK character in /// it. It reads as the character it belongs to instead. fn word_char_at(cells: &[Cell], col: usize) -> char { if cells[col].is_spacer() && col > 0 { cells[col - 1].c() } else { cells[col].c() } } /// A cell coordinate in the viewport. Ordered row-major, which is the order /// text is read out in. #[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] pub struct Point { pub row: u16, pub col: u16, } impl Point { pub fn new(row: u16, col: u16) -> Self { Self { row, col } } } /// Granularity, set by click count (and Ctrl for the block variant). #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] pub enum SelectionMode { /// Single click: cell to cell. #[default] Char, /// Double click: whole words at both ends. Word, /// Triple click: whole rows. Line, /// Ctrl+drag: a rectangle rather than a run of text. Block, } /// One in-progress or finished drag. #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub struct Selection { /// Where the button went down. Fixed for the life of the drag. pub anchor: Point, /// Where the pointer is now. Moves with every motion event. pub head: Point, pub mode: SelectionMode, } impl Selection { pub fn new(mode: SelectionMode, at: Point) -> Self { Self { anchor: at, head: at, mode, } } /// Move the loose end. Called on every pointer motion while the button is /// held. pub fn drag_to(&mut self, at: Point) { self.head = at; } /// True when the drag never left its starting cell, in char mode — the /// gesture was a plain click, so there is nothing to copy and the binary /// should drop the selection rather than highlight one cell. pub fn is_empty(&self) -> bool { self.mode == SelectionMode::Char && self.anchor == self.head } /// Follow a full-screen scroll of `delta` rows (positive = content moved /// up, matching [`crate::Damage::scroll`]). /// /// Returns `None` once the selection has scrolled entirely off the top, /// which is the point at which the binary drops it. A selection that is /// only partly off-screen is clamped to what is still visible: the /// alternative is losing a long selection the moment one line of output /// arrives. pub fn scrolled(mut self, delta: i16, rows: u16) -> Option { if delta == 0 { return Some(self); } let last = i32::from(rows.saturating_sub(1)); let shift = |row: u16| i32::from(row) - i32::from(delta); let (a, h) = (shift(self.anchor.row), shift(self.head.row)); if (a < 0 && h < 0) || (a > last && h > last) { return None; } self.anchor.row = a.clamp(0, last) as u16; self.head.row = h.clamp(0, last) as u16; Some(self) } } /// A selection resolved against grid contents: the cells actually covered. /// /// `start` and `end` are both inclusive. For everything but [`SelectionMode::Block`] /// they bound a row-major run; for `Block` they are opposite corners of a /// rectangle. #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub struct SelectionSpan { pub start: Point, pub end: Point, pub block: bool, } impl SelectionSpan { /// Is this cell inside the selection? Called once per visible cell per /// frame by the renderer's fill scan. #[inline] pub fn contains(&self, row: u16, col: u16) -> bool { if row < self.start.row || row > self.end.row { return false; } if self.block { let (lo, hi) = min_max(self.start.col, self.end.col); return col >= lo && col <= hi; } if row == self.start.row && col < self.start.col { return false; } if row == self.end.row && col > self.end.col { return false; } true } /// Inclusive column range covered on `row`. `None` when the row is /// outside the selection. /// /// The renderer draws a selection as one quad per row rather than one per /// cell, so this is the shape it wants: a run, not a predicate. pub fn cols_on(&self, row: u16, grid_cols: u16) -> Option<(u16, u16)> { if row < self.start.row || row > self.end.row { return None; } let last = grid_cols.saturating_sub(1); if self.block { let (lo, hi) = min_max(self.start.col, self.end.col); return Some((lo, hi.min(last))); } let lo = if row == self.start.row { self.start.col } else { 0 }; let hi = if row == self.end.row { self.end.col } else { last }; Some((lo, hi.min(last))) } } fn min_max(a: u16, b: u16) -> (u16, u16) { if a <= b { (a, b) } else { (b, a) } } impl Grid { /// Resolve a gesture into the cells it covers, expanding word and line /// granularity against the current contents. pub fn selection_span(&self, sel: &Selection) -> SelectionSpan { let last_col = self.cols().saturating_sub(1); let last_row = self.rows().saturating_sub(1); let clamp = |p: Point| Point::new(p.row.min(last_row), p.col.min(last_col)); let (anchor, head) = (clamp(sel.anchor), clamp(sel.head)); if sel.mode == SelectionMode::Block { let (start_row, end_row) = min_max(anchor.row, head.row); return SelectionSpan { start: Point::new(start_row, anchor.col), end: Point::new(end_row, head.col), block: true, }; } let (mut start, mut end) = if anchor <= head { (anchor, head) } else { (head, anchor) }; match sel.mode { SelectionMode::Word => { start.col = self.word_start(start.row, start.col); end.col = self.word_end(end.row, end.col); } SelectionMode::Line => { start.col = 0; end.col = last_col; } SelectionMode::Char | SelectionMode::Block => {} } SelectionSpan { start, end, block: false, } } /// The selected text, ready for the clipboard. /// /// Two rules, both about not inventing characters the user never saw: /// /// - Trailing blanks come off each row. The grid pads every row out to /// full width, so without the strip a one-word selection spanning two /// rows would arrive carrying eighty spaces in the middle of it. /// - A row that ran off the right edge joins the next one with no newline /// ([`Grid::row_wrapped`]). A wrapped command line has to paste back as /// the one line it was, or half of it executes on its own. /// /// Block selections always break by row: a rectangle out of the middle of /// the screen is columnar by intent, and the wrap that produced the rows /// is not part of what was asked for. pub fn selection_text(&self, sel: &Selection) -> String { let span = self.selection_span(sel); let mut out = String::new(); for row in span.start.row..=span.end.row { let Some((lo, hi)) = span.cols_on(row, self.cols()) else { continue; }; if row > span.start.row && (span.block || !self.row_wrapped(row - 1)) { out.push('\n'); } let cells = self.row(row); // A drag that stopped on half of a wide character still meant that // character: the user cannot aim at a half. Grow the span to the // whole of the characters it touches before reading it. let lo = if cells[lo as usize].is_spacer() && lo > 0 { lo - 1 } else { lo }; let hi = if cells[hi as usize].is_wide() && hi + 1 < self.cols() { hi + 1 } else { hi }; // One entry per character, not per column: a wide character's // second column is not its own character and copies as nothing, // and a character carrying combining marks copies with them. let mut line = String::new(); for cell in cells[lo as usize..=hi as usize] .iter() .filter(|cell| !cell.is_spacer()) { self.push_cell_text(cell, &mut line); } // Trailing blanks on a wrapped row are real cells the text ran // through, not padding — stripping them would eat the space // between two words that happened to straddle the edge. if self.row_wrapped(row) && !span.block { out.push_str(&line); } else { out.push_str(line.trim_end()); } } out } /// First column of the word containing `col`. A click on a delimiter /// selects the run of delimiters instead, so double-clicking whitespace /// gives you the whitespace rather than nothing. fn word_start(&self, row: u16, col: u16) -> u16 { let cells = self.row(row); let wanted = is_word_char(word_char_at(cells, col as usize)); let mut c = col; while c > 0 && is_word_char(word_char_at(cells, c as usize - 1)) == wanted { c -= 1; } c } /// Last column of the word containing `col`, inclusive. fn word_end(&self, row: u16, col: u16) -> u16 { let cells = self.row(row); let last = self.cols() - 1; let wanted = is_word_char(word_char_at(cells, col as usize)); let mut c = col; while c < last && is_word_char(word_char_at(cells, c as usize + 1)) == wanted { c += 1; } c } } #[cfg(test)] mod tests { use super::*; use shop_vt::Parser; fn grid_with(lines: &[&str], cols: u16) -> Grid { let mut grid = Grid::new(cols, lines.len() as u16); let mut parser = Parser::new(); let joined = lines.join("\r\n"); parser.advance(&mut grid, joined.as_bytes()); grid } /// A grid fed raw bytes, so tests can drive the deferred wrap. fn grid_fed(cols: u16, rows: u16, bytes: &str) -> Grid { let mut grid = Grid::new(cols, rows); let mut parser = Parser::new(); parser.advance(&mut grid, bytes.as_bytes()); grid } fn sel(mode: SelectionMode, from: (u16, u16), to: (u16, u16)) -> Selection { let mut s = Selection::new(mode, Point::new(from.0, from.1)); s.drag_to(Point::new(to.0, to.1)); s } #[test] fn char_selection_within_one_row() { let grid = grid_with(&["hello world"], 20); let s = sel(SelectionMode::Char, (0, 0), (0, 4)); assert_eq!(grid.selection_text(&s), "hello"); } #[test] fn char_selection_is_direction_agnostic() { let grid = grid_with(&["hello world"], 20); let forward = sel(SelectionMode::Char, (0, 6), (0, 10)); let backward = sel(SelectionMode::Char, (0, 10), (0, 6)); assert_eq!(grid.selection_text(&forward), "world"); assert_eq!(grid.selection_text(&backward), "world"); } #[test] fn multi_row_selection_joins_with_newlines_and_strips_padding() { let grid = grid_with(&["one", "two", "three"], 20); let s = sel(SelectionMode::Char, (0, 0), (2, 4)); assert_eq!(grid.selection_text(&s), "one\ntwo\nthree"); } #[test] fn multi_row_selection_keeps_partial_first_and_last_rows() { let grid = grid_with(&["abcdef", "ghijkl"], 20); let s = sel(SelectionMode::Char, (0, 3), (1, 2)); assert_eq!(grid.selection_text(&s), "def\nghi"); } #[test] fn blank_row_inside_a_selection_stays_blank() { let grid = grid_with(&["top", "", "bottom"], 20); let s = sel(SelectionMode::Char, (0, 0), (2, 5)); assert_eq!(grid.selection_text(&s), "top\n\nbottom"); } #[test] fn a_wrapped_line_copies_back_as_one_line() { let grid = grid_fed(6, 3, "abcdefghij"); let s = sel(SelectionMode::Char, (0, 0), (1, 3)); assert_eq!(grid.selection_text(&s), "abcdefghij"); } #[test] fn a_row_that_filled_exactly_still_breaks_at_the_newline() { // Six columns of text ended with CR/LF is not a wrap, even though the // cursor sat on the right edge. let grid = grid_fed(6, 3, "abcdef\r\nghij"); let s = sel(SelectionMode::Char, (0, 0), (1, 3)); assert_eq!(grid.selection_text(&s), "abcdef\nghij"); } #[test] fn a_space_straddling_the_wrap_survives_the_copy() { // "ab " fills the row with real spaces before "cd" wraps onto the // next; trimming them would glue the two words together. let grid = grid_fed(6, 3, "ab cd"); let s = sel(SelectionMode::Char, (0, 0), (1, 1)); assert_eq!(grid.selection_text(&s), "ab cd"); } #[test] fn three_rows_of_one_wrapped_line_copy_as_one_line() { let grid = grid_fed(4, 4, "0123456789ab"); let s = sel(SelectionMode::Line, (0, 0), (2, 0)); assert_eq!(grid.selection_text(&s), "0123456789ab"); } #[test] fn a_block_selection_breaks_by_row_even_across_a_wrap() { let grid = grid_fed(6, 3, "abcdefghijkl"); let s = sel(SelectionMode::Block, (0, 1), (1, 2)); assert_eq!(grid.selection_text(&s), "bc\nhi"); } #[test] fn erasing_to_the_edge_ends_the_continuation() { // Wrap, then EL0 from the start of the first row: the tail that ran // off the edge is gone, so the rows are separate lines again. let grid = grid_fed(6, 3, "abcdefghij\x1b[H\x1b[K"); let s = sel(SelectionMode::Char, (0, 0), (1, 3)); assert_eq!(grid.selection_text(&s), "\nghij"); } #[test] fn word_mode_expands_both_ends() { let grid = grid_with(&["alpha beta gamma"], 20); // Anchor inside "beta", head inside "gamma". let s = sel(SelectionMode::Word, (0, 7), (0, 12)); assert_eq!(grid.selection_text(&s), "beta gamma"); } #[test] fn word_mode_on_a_single_click_takes_the_whole_word() { let grid = grid_with(&["alpha beta gamma"], 20); let s = Selection::new(SelectionMode::Word, Point::new(0, 8)); assert_eq!(grid.selection_text(&s), "beta"); } #[test] fn word_mode_keeps_paths_and_flags_intact() { let grid = grid_with(&["cargo --offline /usr/lib/foo.so"], 40); let path = Selection::new(SelectionMode::Word, Point::new(0, 20)); assert_eq!(grid.selection_text(&path), "/usr/lib/foo.so"); let flag = Selection::new(SelectionMode::Word, Point::new(0, 8)); assert_eq!(grid.selection_text(&flag), "--offline"); } #[test] fn word_mode_on_a_delimiter_takes_the_delimiter_run() { let grid = grid_with(&["a b"], 20); let s = Selection::new(SelectionMode::Word, Point::new(0, 3)); assert_eq!(grid.selection_text(&s), ""); let span = grid.selection_span(&s); assert_eq!((span.start.col, span.end.col), (1, 4)); } #[test] fn line_mode_takes_whole_rows() { let grid = grid_with(&["first line", "second line"], 20); let s = sel(SelectionMode::Line, (0, 4), (1, 2)); assert_eq!(grid.selection_text(&s), "first line\nsecond line"); } #[test] fn block_mode_cuts_a_column_out_of_every_row() { let grid = grid_with(&["abcdef", "ghijkl", "mnopqr"], 20); let s = sel(SelectionMode::Block, (0, 1), (2, 3)); assert_eq!(grid.selection_text(&s), "bcd\nhij\nnop"); } #[test] fn block_mode_is_corner_agnostic() { let grid = grid_with(&["abcdef", "ghijkl"], 20); let s = sel(SelectionMode::Block, (1, 3), (0, 1)); assert_eq!(grid.selection_text(&s), "bcd\nhij"); } #[test] fn contains_covers_the_run_not_the_bounding_box() { let grid = grid_with(&["abcdef", "ghijkl", "mnopqr"], 6); let span = grid.selection_span(&sel(SelectionMode::Char, (0, 3), (2, 1))); assert!(!span.contains(0, 2)); assert!(span.contains(0, 3)); // Middle row is covered end to end. assert!(span.contains(1, 0)); assert!(span.contains(1, 5)); assert!(span.contains(2, 1)); assert!(!span.contains(2, 2)); assert!(!span.contains(3, 0)); } #[test] fn contains_on_a_block_is_the_bounding_box() { let grid = grid_with(&["abcdef", "ghijkl", "mnopqr"], 6); let span = grid.selection_span(&sel(SelectionMode::Block, (0, 3), (2, 1))); assert!(span.contains(0, 1)); assert!(span.contains(1, 2)); assert!(!span.contains(1, 0)); assert!(!span.contains(1, 4)); } #[test] fn out_of_range_points_clamp_to_the_grid() { let grid = grid_with(&["abc"], 4); let s = sel(SelectionMode::Char, (0, 0), (99, 99)); assert_eq!(grid.selection_text(&s), "abc"); } #[test] fn a_plain_click_is_empty() { let s = Selection::new(SelectionMode::Char, Point::new(2, 5)); assert!(s.is_empty()); let mut dragged = s; dragged.drag_to(Point::new(2, 6)); assert!(!dragged.is_empty()); // A double click is a selection even without motion. assert!(!Selection::new(SelectionMode::Word, Point::new(2, 5)).is_empty()); } #[test] fn scrolling_moves_a_selection_up() { let s = sel(SelectionMode::Char, (4, 0), (6, 3)); let moved = s.scrolled(2, 24).expect("still on screen"); assert_eq!(moved.anchor.row, 2); assert_eq!(moved.head.row, 4); } #[test] fn scrolling_past_the_top_drops_the_selection() { let s = sel(SelectionMode::Char, (0, 0), (1, 3)); assert!(s.scrolled(5, 24).is_none()); } #[test] fn a_partly_scrolled_selection_clamps_to_what_is_left() { let s = sel(SelectionMode::Char, (1, 0), (8, 3)); let moved = s.scrolled(3, 24).expect("tail still on screen"); assert_eq!(moved.anchor.row, 0); assert_eq!(moved.head.row, 5); } }