Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]

### Added
- double-click selects and highlights the word under the cursor and copies it to the clipboard
- activity indicator: tabs with output while not focused now show the activity dot in the tab bar; the marker clears as soon as the tab is focused
- OSC 133 shell integration with prompt and exit-code status-bar indicators, gated by `general.shell_integration`
- OSC 777 desktop notifications gated by `general.desktop_notifications`
Expand Down
28 changes: 26 additions & 2 deletions src/app_event.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use std::time::{Duration, Instant};

use arboard::Clipboard;
use winit::event::{ElementState, MouseButton, MouseScrollDelta};
use winit::event_loop::ActiveEventLoop;
Expand Down Expand Up @@ -471,7 +473,6 @@ impl App {
event: winit::event::KeyEvent,
event_loop: &ActiveEventLoop,
) {
use std::time::Instant;
self.state.cursor_blink = true;
self.state.blink_last = Instant::now();

Expand Down Expand Up @@ -703,11 +704,34 @@ impl App {
}
}

/// True when this left-press lands within ~400 ms and ~4 px of the previous
/// one (a double-click). Records the press position for the next comparison,
/// clearing it after a positive match so a third click is not treated as
/// another double-click.
fn is_double_click(&mut self, mx: f64, my: f64) -> bool {
let now = Instant::now();
let is_double = self
.state
.last_click
.map(|(t, x, y)| {
now.duration_since(t) < Duration::from_millis(400)
&& (x - mx).abs() < 4.0
&& (y - my).abs() < 4.0
})
.unwrap_or(false);
self.state.last_click = if is_double { None } else { Some((now, mx, my)) };
is_double
}

fn handle_selection_click(&mut self, state: ElementState) {
match state {
ElementState::Pressed => {
if let Some((mx, my)) = self.state.mouse_pos {
self.start_mouse_selection(mx, my);
if self.is_double_click(mx, my) {
self.select_word_at(mx, my);
} else {
self.start_mouse_selection(mx, my);
}
}
}
ElementState::Released => {
Expand Down
4 changes: 4 additions & 0 deletions src/app_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ pub struct AppState {
pub clipboard: Option<Clipboard>,
pub mouse_pos: Option<(f64, f64)>,
pub mouse_selecting: bool,
/// Time and pixel position of the last left-button press, for double-click
/// detection (word selection).
pub last_click: Option<(Instant, f64, f64)>,
pub search_matches: Vec<(usize, usize, usize)>,
pub search_current: usize,
pub search_history: Vec<String>,
Expand Down Expand Up @@ -118,6 +121,7 @@ impl AppState {
clipboard: Clipboard::new().ok(),
mouse_pos: None,
mouse_selecting: false,
last_click: None,
search_matches: Vec::new(),
search_current: 0,
search_history: Vec::new(),
Expand Down
43 changes: 43 additions & 0 deletions src/input/motion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,24 @@ pub fn word_end(grid: &Grid, scroll_offset: usize, col: usize, row: usize) -> (u
(c, r)
}

/// Inclusive column span `[start, end]` of the word under `(col, row)` in the
/// viewport, used for double-click word selection. If the character under the
/// cursor is not a word character, returns `(col, col)`.
pub fn word_bounds(grid: &Grid, scroll_offset: usize, col: usize, row: usize) -> (usize, usize) {
if !is_word(char_at(grid, scroll_offset, row, col)) {
return (col, col);
}
let mut start = col;
while start > 0 && is_word(char_at(grid, scroll_offset, row, start - 1)) {
start -= 1;
}
let mut end = col;
while end + 1 < grid.cols && is_word(char_at(grid, scroll_offset, row, end + 1)) {
end += 1;
}
(start, end)
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -182,6 +200,31 @@ mod tests {
assert_eq!(c, 6); // 'w' in "world"
}

#[test]
fn word_bounds_spans_whole_word_from_middle() {
let g = make_grid("hello world");
assert_eq!(word_bounds(&g, 0, 8, 0), (6, 10)); // inside "world" → whole word
}

#[test]
fn word_bounds_spans_first_word_from_edge() {
let g = make_grid("hello world");
assert_eq!(word_bounds(&g, 0, 0, 0), (0, 4)); // "hello"
assert_eq!(word_bounds(&g, 0, 4, 0), (0, 4)); // last char of "hello"
}

#[test]
fn word_bounds_on_space_selects_single_cell() {
let g = make_grid("hello world");
assert_eq!(word_bounds(&g, 0, 5, 0), (5, 5)); // the space
}

#[test]
fn word_bounds_treats_underscore_as_word() {
let g = make_grid("foo_bar baz");
assert_eq!(word_bounds(&g, 0, 3, 0), (0, 6)); // "foo_bar"
}

#[test]
fn word_forward_from_space_skips_to_word() {
let g = make_grid("hello world");
Expand Down
44 changes: 44 additions & 0 deletions src/input/mouse_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,50 @@ impl App {
}
}

/// Select the word under the pixel (double-click): leave it highlighted in
/// anchored Visual mode so the user sees what was picked, and copy it to the
/// clipboard. A blank cell selects nothing. `Esc`/click returns to Insert.
pub(crate) fn select_word_at(&mut self, px: f64, py: f64) {
let Some(pane_id) = self.pane_at_pixel(px, py) else {
return;
};
self.tab_mut().active = pane_id;
let Some((col, row)) = self.pixel_to_cell(pane_id, px, py) else {
return;
};
// Resolve the word span and its text in one grid read; a blank cell
// yields empty text and is treated as "nothing to select".
let selection = {
let tab = self.tab();
tab.panes.get(&pane_id).and_then(|entry| {
entry.pane.grid_read().and_then(|grid| {
let (start, end) = crate::input::motion::word_bounds(
&grid,
entry.pane.scroll_offset,
col,
row,
);
let text = grid.selected_text(start, row, end, row, entry.pane.scroll_offset);
(!text.is_empty()).then_some((start, end))
})
})
};
if let Some((start, end)) = selection {
self.state.mode = InputMode::Visual {
start_col: start,
start_row: row,
cur_col: end,
cur_row: row,
anchored: true,
};
self.copy_selection_to_clipboard(start, row, end, row);
}
self.state.mouse_selecting = false;
if let Some(w) = &self.window {
w.request_redraw();
}
}

pub(crate) fn update_mouse_selection(&mut self, px: f64, py: f64) {
if let InputMode::Visual {
start_col,
Expand Down