brewery
notashelf /
1b4c293c998db6084dc7baefef9d45eef3f9cc93

beer

public

A terminal worth pouring time into

Star0tarball
clone
ssh://git@git.notashelf.dev:33/notashelf/beer.git

Commit 1b4c293c998d

tarball

NotAShelf <raf@notashelf.dev> · 2026-06-26 07:21 UTC

unverified2 files changed+177-16
selection: block (Ctrl-drag) selection, edge autoscroll, word delimiters

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Icd505e07375273e3dd7b14f0b05e44e16a6a6964
diff --git a/src/grid.rs b/src/grid.rsindex 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<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);diff --git a/src/wayland.rs b/src/wayland.rsindex 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<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);     }