clone
ssh://git@git.notashelf.dev:33/notashelf/beer.gitCommit 1b4c293c998d
tarballunverified2 files changed+177-16
@@ -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<bool> { (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<String> {+ 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; }@@ -1100,6 +1161,44 @@ mod tests { } #[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); g.set_scroll_region(1, 2);@@ -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<ExitCode> { 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<u8>, /// 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<RegistrationToken>, 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();+ }++ /// 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: a completed selection becomes the primary selection.+ /// Left-button release: stop autoscrolling and publish the primary selection. fn pointer_release(&mut self, qh: &QueueHandle<App>) { 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); }