From 1b4c293c998db6084dc7baefef9d45eef3f9cc93 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 25 Jun 2026 09:22:06 +0300 Subject: [PATCH] selection: block (Ctrl-drag) selection, edge autoscroll, word delimiters Signed-off-by: NotAShelf Change-Id: Icd505e07375273e3dd7b14f0b05e44e16a6a6964 --- src/grid.rs | 127 +++++++++++++++++++++++++++++++++++++++++++------ src/wayland.rs | 66 ++++++++++++++++++++++++- 2 files changed, 177 insertions(+), 16 deletions(-) diff --git a/src/grid.rs b/src/grid.rs index 6707c39..b208fe8 100644 --- a/src/grid.rs +++ b/src/grid.rs @@ -177,6 +177,8 @@ pub struct Grid { cursor_color: Option<(u8, u8, u8)>, /// Active mouse selection as (anchor, head) in absolute coordinates. selection: Option<(Point, Point)>, + /// Whether the selection is a rectangular block rather than linear flow. + selection_block: bool, /// Bracketed paste mode (DECSET 2004): wrap pasted text in `ESC[200~`/`201~`. bracketed_paste: bool, /// Synchronized output (DECSET 2026): hold presentation while a frame is @@ -194,6 +196,16 @@ fn default_tabs(cols: usize) -> Vec { (0..cols).map(|i| i % 8 == 0 && i != 0).collect() } +/// Characters that terminate a word for double-click selection. `_`, `-`, `.`, +/// `/`, `:`, `~` are deliberately *not* delimiters so paths, URLs, and option +/// flags select as one unit. +const WORD_DELIMITERS: &str = " \t`!@#$%^&*()+=[]{}\\|;'\",<>?"; + +/// Whether `c` is part of a word (not whitespace, not a delimiter). +fn is_word(c: char) -> bool { + !c.is_whitespace() && !WORD_DELIMITERS.contains(c) +} + impl Grid { pub fn new(cols: usize, rows: usize) -> Self { let cols = cols.max(1); @@ -221,6 +233,7 @@ impl Grid { cursor_color: None, app_cursor: false, selection: None, + selection_block: false, bracketed_paste: false, sync: false, mouse_protocol: MouseProtocol::Off, @@ -809,10 +822,18 @@ impl Grid { self.selection = None; } - /// Begin a selection at an absolute point (drag anchor). + /// Begin a linear selection at an absolute point (drag anchor). pub fn start_selection(&mut self, row: usize, col: usize) { let p = Point { row, col }; self.selection = Some((p, p)); + self.selection_block = false; + } + + /// Begin a rectangular (block) selection at an absolute point. + pub fn start_block_selection(&mut self, row: usize, col: usize) { + let p = Point { row, col }; + self.selection = Some((p, p)); + self.selection_block = true; } /// Move the selection head (drag), keeping the anchor fixed. @@ -822,29 +843,43 @@ impl Grid { } } - /// Select the whitespace-delimited word at an absolute point. + /// Select the word at an absolute point, breaking on whitespace and the + /// default delimiter set. pub fn select_word(&mut self, row: usize, col: usize) { let line = self.abs_row(row); - let word = |c: char| !c.is_whitespace(); - if col >= line.len() || !word(line[col].c) { + if col >= line.len() || !is_word(line[col].c) { self.start_selection(row, col); return; } let mut lo = col; - while lo > 0 && word(line[lo - 1].c) { + while lo > 0 && is_word(line[lo - 1].c) { lo -= 1; } let mut hi = col; - while hi + 1 < line.len() && word(line[hi + 1].c) { + while hi + 1 < line.len() && is_word(line[hi + 1].c) { hi += 1; } self.selection = Some((Point { row, col: lo }, Point { row, col: hi })); + self.selection_block = false; } /// Select the whole line at an absolute row. pub fn select_line(&mut self, row: usize) { let last = self.abs_row(row).len().saturating_sub(1); self.selection = Some((Point { row, col: 0 }, Point { row, col: last })); + self.selection_block = false; + } + + /// The rectangle `(top_row, bottom_row, left_col, right_col)` of a block + /// selection. + fn block_rect(&self) -> Option<(usize, usize, usize, usize)> { + let (a, b) = self.selection?; + Some(( + a.row.min(b.row), + a.row.max(b.row), + a.col.min(b.col), + a.col.max(b.col), + )) } /// Normalized selection (start <= end in reading order), if any. @@ -860,6 +895,12 @@ impl Grid { /// Whether the cell at an absolute `(row, col)` falls inside the selection. pub fn is_selected(&self, row: usize, col: usize) -> bool { + if self.selection_block { + let Some((r0, r1, c0, c1)) = self.block_rect() else { + return false; + }; + return row >= r0 && row <= r1 && col >= c0 && col <= c1; + } let Some((start, end)) = self.ordered_selection() else { return false; }; @@ -874,6 +915,10 @@ impl Grid { /// The inclusive `(lo, hi)` column span selected on absolute row `row`, if /// any part of that row is selected. pub fn selection_span_on(&self, row: usize) -> Option<(usize, usize)> { + if self.selection_block { + let (r0, r1, c0, c1) = self.block_rect()?; + return (row >= r0 && row <= r1).then_some((c0, c1)); + } let (start, end) = self.ordered_selection()?; if row < start.row || row > end.row { return None; @@ -890,6 +935,17 @@ impl Grid { /// The selected text, with trailing blanks trimmed per line and rows joined /// by newlines. `None` if there is no selection. pub fn selection_text(&self) -> Option { + if self.selection_block { + let (r0, r1, c0, c1) = self.block_rect()?; + let mut out = String::new(); + for row in r0..=r1 { + out.push_str(self.row_slice_text(row, c0, c1 + 1).trim_end()); + if row != r1 { + out.push('\n'); + } + } + return Some(out); + } let (start, end) = self.ordered_selection()?; let mut out = String::new(); for row in start.row..=end.row { @@ -900,14 +956,7 @@ impl Grid { } else { line.len() }; - let text: String = line - .get(lo..hi) - .unwrap_or(&[]) - .iter() - .filter(|c| !c.flags.contains(Flags::WIDE_CONT)) - .map(|c| c.c) - .collect(); - out.push_str(text.trim_end()); + out.push_str(self.row_slice_text(row, lo, hi).trim_end()); if row != end.row { out.push('\n'); } @@ -915,6 +964,18 @@ impl Grid { Some(out) } + /// The characters of an absolute row in `[from, to)`, skipping wide + /// continuation cells. + fn row_slice_text(&self, row: usize, from: usize, to: usize) -> String { + self.abs_row(row) + .get(from..to.min(self.abs_row(row).len())) + .unwrap_or(&[]) + .iter() + .filter(|c| !c.flags.contains(Flags::WIDE_CONT)) + .map(|c| c.c) + .collect() + } + pub fn set_bracketed_paste(&mut self, on: bool) { self.bracketed_paste = on; } @@ -1099,6 +1160,44 @@ mod tests { assert_eq!(g.selection_text().as_deref(), Some("bar")); } + #[test] + fn select_word_breaks_on_delimiters_but_keeps_paths() { + let mut g = Grid::new(32, 1); + for c in "run /usr/bin:next".chars() { + g.print(c); + } + // '/' and ':' are not delimiters, so the path selects whole. + g.select_word(0, 7); // inside "/usr/bin:next" + assert_eq!(g.selection_text().as_deref(), Some("/usr/bin:next")); + // '(' is a delimiter. + let mut g = Grid::new(16, 1); + for c in "f(arg)".chars() { + g.print(c); + } + g.select_word(0, 2); // inside "arg" + assert_eq!(g.selection_text().as_deref(), Some("arg")); + } + + #[test] + fn block_selection_is_rectangular() { + let mut g = Grid::new(8, 3); + for line in ["abcd", "efgh", "ijkl"] { + for c in line.chars() { + g.print(c); + } + g.carriage_return(); + g.line_feed(); + } + // A block from (row0,col1) to (row2,col2) takes columns 1..=2 each row. + g.start_block_selection(0, 1); + g.extend_selection(2, 2); + assert!(g.is_selected(0, 1)); + assert!(g.is_selected(2, 2)); + assert!(!g.is_selected(1, 3)); + assert!(!g.is_selected(1, 0)); + assert_eq!(g.selection_text().as_deref(), Some("bc\nfg\njk")); + } + #[test] fn scroll_region_limits_line_feed() { let mut g = Grid::new(4, 4); diff --git a/src/wayland.rs b/src/wayland.rs index 018514b..1d1221a 100644 --- a/src/wayland.rs +++ b/src/wayland.rs @@ -109,6 +109,9 @@ const MAX_BUFFERS: usize = 3; /// present anyway, so a misbehaving app cannot freeze the window. const SYNC_TIMEOUT_MS: u64 = 150; +/// Interval between autoscroll steps while a drag selection runs off an edge. +const AUTOSCROLL_MS: u64 = 40; + /// What determines one rendered row's pixels: its cells, the cursor on it, the /// selection span over it, and the blink phase. Two equal `RowSnap`s render /// identically, so a buffer holding an equal snapshot needs no repaint. @@ -217,6 +220,8 @@ pub fn run() -> anyhow::Result { selecting: false, pressed_button: None, last_report_cell: None, + autoscroll: 0, + autoscroll_timer: None, pointer_pos: (0.0, 0.0), last_click: None, serial: 0, @@ -324,6 +329,10 @@ struct App { pressed_button: Option, /// Last cell a motion report was emitted for, to suppress duplicates. last_report_cell: Option<(usize, usize)>, + /// Autoscroll direction while dragging past an edge: +1 back, -1 toward live. + autoscroll: isize, + /// Calloop token for the repeating autoscroll timer, when armed. + autoscroll_timer: Option, pointer_pos: (f64, f64), /// Last click (time ms, abs row, col, count) for double/triple detection. last_click: Option<(u32, usize, usize, u32)>, @@ -493,17 +502,21 @@ impl App { let Some(session) = self.session.as_mut() else { return; }; + let ctrl = self.modifiers.ctrl; let grid = session.term.grid_mut(); match count { 2 => grid.select_word(row, col), 3 => grid.select_line(row), + // Holding Ctrl starts a rectangular (block) selection. + _ if ctrl => grid.start_block_selection(row, col), _ => grid.start_selection(row, col), } self.selecting = true; self.needs_draw = true; } - /// Pointer motion during a drag: extend the selection head. + /// Pointer motion during a drag: extend the selection head and, if the + /// pointer has left the top/bottom edge, start autoscrolling. fn pointer_drag(&mut self) { if !self.selecting { return; @@ -515,14 +528,63 @@ impl App { session.term.grid_mut().extend_selection(row, col); self.needs_draw = true; } + self.update_autoscroll(); } - /// Left-button release: a completed selection becomes the primary selection. + /// Arm or disarm edge autoscroll based on the pointer's vertical position. + fn update_autoscroll(&mut self) { + let dir = if self.pointer_pos.1 < 0.0 { + 1 // above the top: reveal older lines + } else if self.pointer_pos.1 >= f64::from(self.height) { + -1 // below the bottom: advance toward the live screen + } else { + 0 + }; + self.autoscroll = dir; + if dir != 0 && self.autoscroll_timer.is_none() { + let timer = Timer::immediate(); + self.autoscroll_timer = self + .loop_handle + .insert_source(timer, |_, _, app: &mut App| app.autoscroll_step()) + .ok(); + } + } + + /// One autoscroll step: scroll the viewport and drag the selection head to + /// the edge cell under the pointer. Reschedules until the drag ends or the + /// pointer returns inside the window. + fn autoscroll_step(&mut self) -> TimeoutAction { + if !self.selecting || self.autoscroll == 0 { + self.autoscroll_timer = None; + return TimeoutAction::Drop; + } + if let Some(session) = self.session.as_mut() { + session.term.scroll_view(self.autoscroll); + } + let edge_y = if self.autoscroll > 0 { + 0.0 + } else { + f64::from(self.height) - 1.0 + }; + if let Some((row, col)) = self.cell_at(self.pointer_pos.0, edge_y) + && let Some(session) = self.session.as_mut() + { + session.term.grid_mut().extend_selection(row, col); + } + self.needs_draw = true; + TimeoutAction::ToDuration(Duration::from_millis(AUTOSCROLL_MS)) + } + + /// Left-button release: stop autoscrolling and publish the primary selection. fn pointer_release(&mut self, qh: &QueueHandle) { if !self.selecting { return; } self.selecting = false; + self.autoscroll = 0; + if let Some(token) = self.autoscroll_timer.take() { + self.loop_handle.remove(token); + } self.set_primary(qh); }