Skip to content

Commit 9e09527

Browse files
authored
feat(editor): add neovim-style mouse selection (#363)
1 parent 12d9b0c commit 9e09527

7 files changed

Lines changed: 1644 additions & 115 deletions

File tree

default_config.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,7 @@ Esc = { EnterMode = "Normal" }
504504
Esc = { EnterMode = "Normal" }
505505
":" = { EnterMode = "Command" }
506506
"y" = [ "Yank", { EnterMode = "Normal" } ]
507+
"d" = [ "Delete", { EnterMode = "Normal" } ]
507508
"x" = [ "Delete", { EnterMode = "Normal" } ]
508509
"c" = "ChangeSelection"
509510
"p" = [ "Paste", { EnterMode = "Normal" } ]

docs/VIM_COMPATIBILITY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ the corresponding integration tests.
4949
| Visual character | **supported** | Motions, supported text objects, yank/delete/change/paste, and Unicode selections. |
5050
| Visual line | **supported** | Linewise yank/delete/change/paste, including whole-document and interior replacements. |
5151
| Visual block | **supported** | Block delete/change/insert, one-transaction replay, undo/redo, and dot-repeat for block insert. |
52+
| Mouse selection | **supported** | Left-click positions the cursor; dragging enters characterwise Visual mode, and release retains the selection for motions, `y`, `d`, `c`, Escape, and `gv`. Double-click selects words or matching delimiters, triple-click selects lines, and quadruple-click selects a block; dragging extends the chosen shape. Shift-left adjusts the nearest selection endpoint, and Alt-left creates a block from the existing cursor. Insert-origin selection returns to Insert when it ends. Viewport-edge dragging scrolls the originating window. Modifier gestures require the terminal to deliver those modifiers. Terminal-native selection and Neovim's right-click popup behavior are outside this support. |
5253
| Multi-cursor selections | **supported** | In Normal mode, `Ctrl-n` selects successive whole-word occurrences; from a single-line characterwise Visual selection, it uses the exact selected text and immediately adds the next literal occurrence. `Ctrl-Up` / `Ctrl-Down` add vertical cursors while preserving display columns. `n` / `N` navigate matches, `q` skips, and `Q` removes the active selection. `Tab` toggles extend mode; Shift-arrows and `h`, `l`, `w`, `e`, `0`, and `$` extend complete Unicode graphemes, and `o` reverses each anchor. `c`, `i`, `a`, `d`, `x`, `y`, `p`, and `P` apply across selections. Insert, change, delete, and paste are grouped into one undoable edit. |
5354
| Restore Visual selection | **supported** | `gv` restores the previous buffer-local Visual area with its character, line, or block shape and original direction. In Visual mode it exchanges the current and previous areas. Selection metadata survives session recovery, while `<` and `>` continue to track edits. |
5455
| Visual indent | **supported** | `[count]>` and `[count]<` shift every covered line right or left by `count × shiftwidth` in one undoable transaction for character, line, and block selections. Empty lines remain empty, indentation saturates at column zero, `.` repeats the shift over the same number of lines from the current line, and `gv` restores the shifted range. |

src/editor.rs

Lines changed: 74 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ mod inline_notifications;
4444
mod keyboard_shortcuts;
4545
mod learning;
4646
mod lsp_coordinator;
47+
mod mouse;
4748
mod multi_cursor;
4849
#[cfg(test)]
4950
mod navigation_perf_tests;
@@ -2464,6 +2465,18 @@ impl PickerCallback {
24642465
/// serializes their execution through its event loop and canonical edit
24652466
/// transaction boundary.
24662467
pub enum Action {
2468+
/// Starts an editor-owned pointer gesture in terminal-cell coordinates.
2469+
MousePress {
2470+
column: u16,
2471+
row: u16,
2472+
modifiers: KeyModifiers,
2473+
},
2474+
/// Extends or releases the editor's captured pointer gesture.
2475+
MouseDrag {
2476+
column: u16,
2477+
row: u16,
2478+
finish: bool,
2479+
},
24672480
Quit(bool),
24682481
Save,
24692482
/// Overwrites the backing file after an explicit `:w!` or conflict confirmation.
@@ -3800,6 +3813,9 @@ pub struct Editor {
38003813
/// Current selection rectangle
38013814
selection: Option<Rect>,
38023815

3816+
/// Pointer capture and the originating mode of a mouse-created selection.
3817+
mouse_selection: mouse::MouseSelectionState,
3818+
38033819
/// Named registers for storing text (like vim registers)
38043820
registers: HashMap<char, Content>,
38053821

@@ -4143,6 +4159,8 @@ impl DetachedEditorCore {
41434159
}
41444160

41454161
pub fn client_disconnected(&mut self) {
4162+
self.editor.mouse_selection.gesture = None;
4163+
self.editor.mouse_selection.last_click = None;
41464164
self.editor.notification_client_attached = false;
41474165
self.editor.notification_exposure = None;
41484166
}
@@ -5325,6 +5343,7 @@ impl Editor {
53255343
repeater: None,
53265344
selection_start: None,
53275345
selection: None,
5346+
mouse_selection: mouse::MouseSelectionState::default(),
53285347
registers: HashMap::new(),
53295348
clipboard,
53305349
diagnostics: HashMap::new(),
@@ -6522,6 +6541,8 @@ impl Editor {
65226541
}
65236542

65246543
fn resize_terminal_surface(&mut self, width: u16, height: u16, buffer: &mut RenderBuffer) {
6544+
self.mouse_selection.gesture = None;
6545+
self.mouse_selection.last_click = None;
65256546
self.size = (width, height);
65266547
self.divider_drag = None;
65276548
self.pane_resize_mode = None;
@@ -7590,7 +7611,7 @@ impl Editor {
75907611
}
75917612

75927613
fn max_cursor_x_for_line_length(&self, line_length: usize) -> usize {
7593-
if self.is_insert() {
7614+
if self.is_insert() || self.mouse_visual_allows_line_end() {
75947615
line_length
75957616
} else {
75967617
line_length.saturating_sub(1)
@@ -10495,6 +10516,7 @@ impl Editor {
1049510516
buffer: &mut RenderBuffer,
1049610517
runtime: &mut Runtime,
1049710518
) -> anyhow::Result<()> {
10519+
self.service_mouse_selection(buffer, runtime).await?;
1049810520
self.service_open_file_changes(buffer, runtime).await?;
1049910521
if self.agent_lsp.has_work() {
1050010522
self.service_agent_lsp(buffer, runtime).await?;
@@ -15472,6 +15494,11 @@ impl Editor {
1547215494
ev: &event::Event,
1547315495
runtime: Option<&Runtime>,
1547415496
) -> anyhow::Result<Option<KeyAction>> {
15497+
self.validate_mouse_selection_owner();
15498+
if matches!(ev, Event::Key(_) | Event::Paste(_)) {
15499+
self.mouse_selection.gesture = None;
15500+
self.mouse_selection.last_click = None;
15501+
}
1547515502
if let Some(action) = self.handle_keyboard_shortcuts_event(ev, runtime) {
1547615503
return Ok(Some(action));
1547715504
}
@@ -15603,6 +15630,9 @@ impl Editor {
1560315630
}
1560415631

1560515632
if let Event::Mouse(mouse) = ev {
15633+
if let Some(action) = self.handle_captured_mouse_selection(mouse) {
15634+
return Ok(Some(action));
15635+
}
1560615636
if let Some(action) = self.handle_divider_mouse_event(mouse) {
1560715637
return Ok(Some(action));
1560815638
}
@@ -15771,6 +15801,8 @@ impl Editor {
1577115801
) -> anyhow::Result<bool> {
1577215802
match ev {
1577315803
Event::FocusLost => {
15804+
self.mouse_selection.gesture = None;
15805+
self.mouse_selection.last_click = None;
1577415806
let divider_was_active = self.divider_drag.take().is_some();
1577515807
let resize_mode_was_active = self.pane_resize_mode.take().is_some();
1577615808
self.suppress_reactivation_click = false;
@@ -20044,6 +20076,9 @@ impl Editor {
2004420076
return Ok(true);
2004520077
}
2004620078
let old_mode = self.mode;
20079+
let insert_return_cursor = self
20080+
.take_mouse_insert_return(new_mode)
20081+
.then(|| self.cursor_snapshot());
2004720082
if !matches!(new_mode, Mode::Insert) {
2004820083
self.snippet_session = None;
2004920084
}
@@ -20127,6 +20162,16 @@ impl Editor {
2012720162
}
2012820163

2012920164
self.draw_statusline(buffer);
20165+
if let Some(cursor) = insert_return_cursor {
20166+
// Normal-mode rendering clamps the virtual line-ending cell. The
20167+
// resumed insertion and its undo transaction retain the mouse's
20168+
// actual endpoint, including insertion immediately after the line.
20169+
self.vtop = cursor.vtop;
20170+
self.cy = cursor.y.saturating_sub(cursor.vtop);
20171+
self.cx = cursor.x;
20172+
self.refresh_cursor_goal();
20173+
return self.execute_enter_mode(Mode::Insert, buffer, runtime).await;
20174+
}
2013020175
Ok(false)
2013120176
})
2013220177
}
@@ -20261,6 +20306,22 @@ impl Editor {
2026120306
.and_then(|dialog| dialog.composer_handle());
2026220307

2026320308
match action {
20309+
Action::MousePress {
20310+
column,
20311+
row,
20312+
modifiers,
20313+
} => {
20314+
self.execute_mouse_press(*column, *row, *modifiers, buffer, runtime)
20315+
.await?;
20316+
}
20317+
Action::MouseDrag {
20318+
column,
20319+
row,
20320+
finish,
20321+
} => {
20322+
self.execute_mouse_drag(*column, *row, *finish, buffer, runtime)
20323+
.await?;
20324+
}
2026420325
Action::Quit(force) => {
2026520326
let scratch_command = self
2026620327
.scratch_buffers
@@ -26106,113 +26167,11 @@ impl Editor {
2610626167
} = mev;
2610726168
match kind {
2610826169
MouseEventKind::Down(MouseButton::Left) => {
26109-
let click_x = *column as usize;
26110-
let click_y = *row as usize;
26111-
26112-
// Check if click is in a window
26113-
if let Some((window_id, window)) =
26114-
self.window_manager.window_at_position(click_x, click_y)
26115-
{
26116-
// Clone window data to avoid borrowing issues
26117-
let window = window.clone();
26118-
let window_buffer_index = window.buffer_index;
26119-
let window_vtop = window.vtop;
26120-
26121-
// Switch to the clicked window if it's not already active
26122-
self.set_active_window(window_id);
26123-
26124-
let local_y = click_y.saturating_sub(window.position.y);
26125-
if local_y < self.window_content_top(&window) {
26126-
let local_x = click_x.saturating_sub(window.position.x);
26127-
if let Some(rendered) = self
26128-
.window_bar_manager
26129-
.render(window.id, window.inner_width())
26130-
{
26131-
if let Some(region) =
26132-
rendered.hit_regions.iter().find(|region| {
26133-
local_x >= region.start_column
26134-
&& local_x < region.end_column
26135-
})
26136-
{
26137-
return Some(KeyAction::Single(Action::NotifyPlugins(
26138-
format!("window_bar:action:{}", rendered.bar_id),
26139-
json!({
26140-
"window_id": window.id.0,
26141-
"segment_id": region.segment_id,
26142-
"action": region.action,
26143-
}),
26144-
)));
26145-
}
26146-
}
26147-
return Some(KeyAction::None);
26148-
}
26149-
26150-
// Convert terminal coordinates to window-local coordinates
26151-
if let Some((local_x, local_y)) =
26152-
window.terminal_to_local(click_x, click_y)
26153-
{
26154-
let local_y = local_y - self.window_content_top(&window);
26155-
// Adjust for the clicked window's gutter, not the active buffer's.
26156-
let gutter_width = self.gutter_width_for_window(&window);
26157-
let content_x = local_x.saturating_sub(gutter_width + 1);
26158-
let layout = self.layout_for_window(&window);
26159-
let (buffer_x, buffer_y) = if let Some(segment) =
26160-
layout.row(local_y)
26161-
{
26162-
// Clicks inside the break-indent area
26163-
// snap to the row's first character.
26164-
let display_col = segment.start_col
26165-
+ content_x.saturating_sub(segment.visual_offset);
26166-
let line = self.buffer_manager[window_buffer_index]
26167-
.get(segment.line)
26168-
.unwrap_or_default();
26169-
(
26170-
column_to_grapheme_with_tabs(
26171-
line.trim_end_matches('\n'),
26172-
display_col,
26173-
self.tab_width_for_buffer_index(window_buffer_index),
26174-
),
26175-
segment.line,
26176-
)
26177-
} else if let Some(comment) = layout.inline_comment_row(local_y) {
26178-
if let Some(action) =
26179-
self.inline_comment_click_action(comment, content_x)
26180-
{
26181-
return Some(KeyAction::Single(action));
26182-
}
26183-
if let Some(group) =
26184-
self.inline_job_on_comment_line(comment.line)
26185-
{
26186-
return Some(KeyAction::Single(Action::OpenInlineJob(
26187-
group,
26188-
)));
26189-
}
26190-
(0, comment.line)
26191-
} else {
26192-
(content_x, window_vtop + local_y)
26193-
};
26194-
26195-
// Ensure y is within buffer bounds
26196-
let window_buffer = &self.buffer_manager[window_buffer_index];
26197-
let y = if buffer_y >= window_buffer.len() {
26198-
window_buffer.len().saturating_sub(1)
26199-
} else {
26200-
buffer_y
26201-
};
26202-
26203-
return Some(KeyAction::Single(Action::SetCursor(buffer_x, y)));
26204-
}
26205-
}
26206-
26207-
// Fallback to global click handling if not in a window
26208-
let x = (*column as usize).saturating_sub(self.gutter_width() + 1);
26209-
let mut y = *row as usize + self.vtop;
26210-
26211-
if y >= self.current_buffer().len() {
26212-
y = self.current_buffer().len().saturating_sub(1);
26213-
}
26214-
26215-
Some(KeyAction::Single(Action::SetCursor(x, y)))
26170+
Some(KeyAction::Single(Action::MousePress {
26171+
column: *column,
26172+
row: *row,
26173+
modifiers: mev.modifiers,
26174+
}))
2621626175
}
2621726176
MouseEventKind::ScrollUp => {
2621826177
let click_x = *column as usize;
@@ -26494,6 +26453,7 @@ impl Editor {
2649426453
};
2649526454

2649626455
self.mode = selection.mode;
26456+
self.restore_mouse_visual_line_end(start, end);
2649726457
self.selection_start = Some(anchor);
2649826458
self.set_selection(start, end);
2649926459
self.move_to_text_position(if selection.anchor_at_start {
@@ -28533,10 +28493,13 @@ impl Editor {
2853328493
}
2853428494

2853528495
fn visual_selection_end_position(&self, column: usize, line: usize) -> TextPosition {
28536-
let is_empty_line_with_ending = self.current_buffer().get(line).is_some_and(|contents| {
28537-
trim_line_ending(&contents).is_empty() && contents.ends_with('\n')
28496+
let includes_line_ending = self.current_buffer().get(line).is_some_and(|contents| {
28497+
let text = trim_line_ending(&contents);
28498+
contents.ends_with('\n')
28499+
&& (text.is_empty()
28500+
|| line < self.last_navigable_line() && column >= grapheme_len(text))
2853828501
});
28539-
if is_empty_line_with_ending {
28502+
if includes_line_ending {
2854028503
TextPosition::new(line + 1, 0)
2854128504
} else {
2854228505
TextPosition::new(line, self.grapheme_to_char_on_line(column + 1, line))

0 commit comments

Comments
 (0)