forked from NotAShelf/beer
selection: block (Ctrl-drag) selection, edge autoscroll, word delimiters
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Icd505e07375273e3dd7b14f0b05e44e16a6a6964
This commit is contained in:
parent
219f0a3c94
commit
1b4c293c99
2 changed files with 177 additions and 16 deletions
127
src/grid.rs
127
src/grid.rs
|
|
@ -177,6 +177,8 @@ pub struct Grid {
|
||||||
cursor_color: Option<(u8, u8, u8)>,
|
cursor_color: Option<(u8, u8, u8)>,
|
||||||
/// Active mouse selection as (anchor, head) in absolute coordinates.
|
/// Active mouse selection as (anchor, head) in absolute coordinates.
|
||||||
selection: Option<(Point, Point)>,
|
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 mode (DECSET 2004): wrap pasted text in `ESC[200~`/`201~`.
|
||||||
bracketed_paste: bool,
|
bracketed_paste: bool,
|
||||||
/// Synchronized output (DECSET 2026): hold presentation while a frame is
|
/// 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()
|
(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 {
|
impl Grid {
|
||||||
pub fn new(cols: usize, rows: usize) -> Self {
|
pub fn new(cols: usize, rows: usize) -> Self {
|
||||||
let cols = cols.max(1);
|
let cols = cols.max(1);
|
||||||
|
|
@ -221,6 +233,7 @@ impl Grid {
|
||||||
cursor_color: None,
|
cursor_color: None,
|
||||||
app_cursor: false,
|
app_cursor: false,
|
||||||
selection: None,
|
selection: None,
|
||||||
|
selection_block: false,
|
||||||
bracketed_paste: false,
|
bracketed_paste: false,
|
||||||
sync: false,
|
sync: false,
|
||||||
mouse_protocol: MouseProtocol::Off,
|
mouse_protocol: MouseProtocol::Off,
|
||||||
|
|
@ -809,10 +822,18 @@ impl Grid {
|
||||||
self.selection = None;
|
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) {
|
pub fn start_selection(&mut self, row: usize, col: usize) {
|
||||||
let p = Point { row, col };
|
let p = Point { row, col };
|
||||||
self.selection = Some((p, p));
|
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.
|
/// 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) {
|
pub fn select_word(&mut self, row: usize, col: usize) {
|
||||||
let line = self.abs_row(row);
|
let line = self.abs_row(row);
|
||||||
let word = |c: char| !c.is_whitespace();
|
if col >= line.len() || !is_word(line[col].c) {
|
||||||
if col >= line.len() || !word(line[col].c) {
|
|
||||||
self.start_selection(row, col);
|
self.start_selection(row, col);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let mut lo = col;
|
let mut lo = col;
|
||||||
while lo > 0 && word(line[lo - 1].c) {
|
while lo > 0 && is_word(line[lo - 1].c) {
|
||||||
lo -= 1;
|
lo -= 1;
|
||||||
}
|
}
|
||||||
let mut hi = col;
|
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;
|
hi += 1;
|
||||||
}
|
}
|
||||||
self.selection = Some((Point { row, col: lo }, Point { row, col: hi }));
|
self.selection = Some((Point { row, col: lo }, Point { row, col: hi }));
|
||||||
|
self.selection_block = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Select the whole line at an absolute row.
|
/// Select the whole line at an absolute row.
|
||||||
pub fn select_line(&mut self, row: usize) {
|
pub fn select_line(&mut self, row: usize) {
|
||||||
let last = self.abs_row(row).len().saturating_sub(1);
|
let last = self.abs_row(row).len().saturating_sub(1);
|
||||||
self.selection = Some((Point { row, col: 0 }, Point { row, col: last }));
|
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.
|
/// 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.
|
/// Whether the cell at an absolute `(row, col)` falls inside the selection.
|
||||||
pub fn is_selected(&self, row: usize, col: usize) -> bool {
|
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 {
|
let Some((start, end)) = self.ordered_selection() else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
@ -874,6 +915,10 @@ impl Grid {
|
||||||
/// The inclusive `(lo, hi)` column span selected on absolute row `row`, if
|
/// The inclusive `(lo, hi)` column span selected on absolute row `row`, if
|
||||||
/// any part of that row is selected.
|
/// any part of that row is selected.
|
||||||
pub fn selection_span_on(&self, row: usize) -> Option<(usize, usize)> {
|
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()?;
|
let (start, end) = self.ordered_selection()?;
|
||||||
if row < start.row || row > end.row {
|
if row < start.row || row > end.row {
|
||||||
return None;
|
return None;
|
||||||
|
|
@ -890,6 +935,17 @@ impl Grid {
|
||||||
/// The selected text, with trailing blanks trimmed per line and rows joined
|
/// The selected text, with trailing blanks trimmed per line and rows joined
|
||||||
/// by newlines. `None` if there is no selection.
|
/// by newlines. `None` if there is no selection.
|
||||||
pub fn selection_text(&self) -> Option<String> {
|
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 (start, end) = self.ordered_selection()?;
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
for row in start.row..=end.row {
|
for row in start.row..=end.row {
|
||||||
|
|
@ -900,14 +956,7 @@ impl Grid {
|
||||||
} else {
|
} else {
|
||||||
line.len()
|
line.len()
|
||||||
};
|
};
|
||||||
let text: String = line
|
out.push_str(self.row_slice_text(row, lo, hi).trim_end());
|
||||||
.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());
|
|
||||||
if row != end.row {
|
if row != end.row {
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
}
|
}
|
||||||
|
|
@ -915,6 +964,18 @@ impl Grid {
|
||||||
Some(out)
|
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) {
|
pub fn set_bracketed_paste(&mut self, on: bool) {
|
||||||
self.bracketed_paste = on;
|
self.bracketed_paste = on;
|
||||||
}
|
}
|
||||||
|
|
@ -1099,6 +1160,44 @@ mod tests {
|
||||||
assert_eq!(g.selection_text().as_deref(), Some("bar"));
|
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]
|
#[test]
|
||||||
fn scroll_region_limits_line_feed() {
|
fn scroll_region_limits_line_feed() {
|
||||||
let mut g = Grid::new(4, 4);
|
let mut g = Grid::new(4, 4);
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,9 @@ const MAX_BUFFERS: usize = 3;
|
||||||
/// present anyway, so a misbehaving app cannot freeze the window.
|
/// present anyway, so a misbehaving app cannot freeze the window.
|
||||||
const SYNC_TIMEOUT_MS: u64 = 150;
|
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
|
/// 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
|
/// selection span over it, and the blink phase. Two equal `RowSnap`s render
|
||||||
/// identically, so a buffer holding an equal snapshot needs no repaint.
|
/// identically, so a buffer holding an equal snapshot needs no repaint.
|
||||||
|
|
@ -217,6 +220,8 @@ pub fn run() -> anyhow::Result<ExitCode> {
|
||||||
selecting: false,
|
selecting: false,
|
||||||
pressed_button: None,
|
pressed_button: None,
|
||||||
last_report_cell: None,
|
last_report_cell: None,
|
||||||
|
autoscroll: 0,
|
||||||
|
autoscroll_timer: None,
|
||||||
pointer_pos: (0.0, 0.0),
|
pointer_pos: (0.0, 0.0),
|
||||||
last_click: None,
|
last_click: None,
|
||||||
serial: 0,
|
serial: 0,
|
||||||
|
|
@ -324,6 +329,10 @@ struct App {
|
||||||
pressed_button: Option<u8>,
|
pressed_button: Option<u8>,
|
||||||
/// Last cell a motion report was emitted for, to suppress duplicates.
|
/// Last cell a motion report was emitted for, to suppress duplicates.
|
||||||
last_report_cell: Option<(usize, usize)>,
|
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),
|
pointer_pos: (f64, f64),
|
||||||
/// Last click (time ms, abs row, col, count) for double/triple detection.
|
/// Last click (time ms, abs row, col, count) for double/triple detection.
|
||||||
last_click: Option<(u32, usize, usize, u32)>,
|
last_click: Option<(u32, usize, usize, u32)>,
|
||||||
|
|
@ -493,17 +502,21 @@ impl App {
|
||||||
let Some(session) = self.session.as_mut() else {
|
let Some(session) = self.session.as_mut() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
let ctrl = self.modifiers.ctrl;
|
||||||
let grid = session.term.grid_mut();
|
let grid = session.term.grid_mut();
|
||||||
match count {
|
match count {
|
||||||
2 => grid.select_word(row, col),
|
2 => grid.select_word(row, col),
|
||||||
3 => grid.select_line(row),
|
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),
|
_ => grid.start_selection(row, col),
|
||||||
}
|
}
|
||||||
self.selecting = true;
|
self.selecting = true;
|
||||||
self.needs_draw = 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) {
|
fn pointer_drag(&mut self) {
|
||||||
if !self.selecting {
|
if !self.selecting {
|
||||||
return;
|
return;
|
||||||
|
|
@ -515,14 +528,63 @@ impl App {
|
||||||
session.term.grid_mut().extend_selection(row, col);
|
session.term.grid_mut().extend_selection(row, col);
|
||||||
self.needs_draw = true;
|
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<App>) {
|
fn pointer_release(&mut self, qh: &QueueHandle<App>) {
|
||||||
if !self.selecting {
|
if !self.selecting {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.selecting = false;
|
self.selecting = false;
|
||||||
|
self.autoscroll = 0;
|
||||||
|
if let Some(token) = self.autoscroll_timer.take() {
|
||||||
|
self.loop_handle.remove(token);
|
||||||
|
}
|
||||||
self.set_primary(qh);
|
self.set_primary(qh);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue