# HG changeset patch # User Nikita Revenco <154856872+NikitaRevenco@users.noreply.github.com> # Date 1737049471 0 # Node ID 33a72d9585878ea39c71ccaa29306f1d7d271e4b # Parent a1c38f138388589e41a778dc9539074e9d7572e4 feat: add mouse click support (#12514) * feat: basic MouseClicks struct * fix: correct functions for double and triple click * refactor: use enum for mouse click type * refactor: clean up code and remove debugging statements * feat: implement double click and single click selection * refactor: move impl to a proper mod * refactor: rename variables * refactor: extract duplicated logic * fix: 4th click on the same spot will reset the selection * chore: remove comment * refactor: reverse && * chore: add documentation * refactor: use u8 for count * refactor: variable names * fix: double click not registering after the first one * fix: clicking the same char indexes in different views triggered double / triple click * docs: document the clicks field * docs: make docs more informative * test: add tests for MouseClicks * refactor: simplify internal MouseClicks code * docs: better description of MouseClicks * fix: failing tests * refactor: do not use unnecessary type hint diff -r a1c38f138388 -r 33a72d958587 helix-core/src/textobject.rs --- a/helix-core/src/textobject.rs Wed May 14 10:23:43 2025 -0400 +++ b/helix-core/src/textobject.rs Thu Jan 16 17:44:31 2025 +0000 @@ -67,6 +67,16 @@ } } +pub fn find_word_boundaries(slice: RopeSlice, pos: usize, is_long: bool) -> (usize, usize) { + let word_start = find_word_boundary(slice, pos, Direction::Backward, is_long); + let word_end = match slice.get_char(pos).map(categorize_char) { + None | Some(CharCategory::Whitespace | CharCategory::Eol) => pos, + _ => find_word_boundary(slice, pos + 1, Direction::Forward, is_long), + }; + + (word_start, word_end) +} + // count doesn't do anything yet pub fn textobject_word( slice: RopeSlice, @@ -77,11 +87,7 @@ ) -> Range { let pos = range.cursor(slice); - let word_start = find_word_boundary(slice, pos, Direction::Backward, long); - let word_end = match slice.get_char(pos).map(categorize_char) { - None | Some(CharCategory::Whitespace | CharCategory::Eol) => pos, - _ => find_word_boundary(slice, pos + 1, Direction::Forward, long), - }; + let (word_start, word_end) = find_word_boundaries(slice, pos, long); // Special case. if word_start == word_end { diff -r a1c38f138388 -r 33a72d958587 helix-term/src/ui/editor.rs --- a/helix-term/src/ui/editor.rs Wed May 14 10:23:43 2025 -0400 +++ b/helix-term/src/ui/editor.rs Thu Jan 16 17:44:31 2025 +0000 @@ -19,6 +19,7 @@ movement::Direction, syntax::{self, OverlayHighlights}, text_annotations::TextAnnotations, + textobject::find_word_boundaries, unicode::width::UnicodeWidthStr, visual_offset_from_block, Change, Position, Range, Selection, Transaction, }; @@ -27,7 +28,7 @@ document::{Mode, SCRATCH_BUFFER_NAME}, editor::{CompleteAction, CursorShapeConfig}, graphics::{Color, CursorKind, Modifier, Rect, Style}, - input::{KeyEvent, MouseButton, MouseEvent, MouseEventKind}, + input::{KeyEvent, MouseButton, MouseClick, MouseEvent, MouseEventKind}, keyboard::{KeyCode, KeyModifiers}, Document, Editor, Theme, View, }; @@ -1161,22 +1162,42 @@ if let Some((pos, view_id)) = pos_and_view(editor, row, column, true) { let prev_view_id = view!(editor).id; let doc = doc_mut!(editor, &view!(editor, view_id).doc); + let text = doc.text().slice(..); - if modifiers == KeyModifiers::ALT { - let selection = doc.selection(view_id).clone(); - doc.set_selection(view_id, selection.push(Range::point(pos))); - } else if editor.mode == Mode::Select { - // Discards non-primary selections for consistent UX with normal mode - let primary = doc.selection(view_id).primary().put_cursor( - doc.text().slice(..), - pos, - true, - ); - editor.mouse_down_range = Some(primary); - doc.set_selection(view_id, Selection::single(primary.anchor, primary.head)); - } else { - doc.set_selection(view_id, Selection::point(pos)); - } + let selection = match editor.mouse_clicks.register_click(pos, view_id) { + MouseClick::Single => { + if modifiers == KeyModifiers::ALT { + let selection = doc.selection(view_id).clone(); + selection.push(Range::point(pos)) + } else if editor.mode == Mode::Select { + // Discards non-primary selections for consistent UX with normal mode + let primary = doc.selection(view_id).primary().put_cursor( + doc.text().slice(..), + pos, + true, + ); + editor.mouse_down_range = Some(primary); + + Selection::single(primary.anchor, primary.head) + } else { + Selection::point(pos) + } + } + MouseClick::Double => { + let (word_start, word_end) = find_word_boundaries(text, pos, false); + + Selection::single(word_start, word_end) + } + MouseClick::Triple => { + let current_line = text.char_to_line(pos); + let line_start = text.line_to_char(current_line); + let line_end = text.line_to_char(current_line + 1); + + Selection::single(line_start, line_end) + } + }; + + doc.set_selection(view_id, selection); if view_id != prev_view_id { self.clear_completion(editor); diff -r a1c38f138388 -r 33a72d958587 helix-view/src/editor.rs --- a/helix-view/src/editor.rs Wed May 14 10:23:43 2025 -0400 +++ b/helix-view/src/editor.rs Thu Jan 16 17:44:31 2025 +0000 @@ -8,7 +8,7 @@ graphics::{CursorKind, Rect}, handlers::Handlers, info::Info, - input::KeyEvent, + input::{KeyEvent, MouseClicks}, register::Registers, theme::{self, Theme}, tree::{self, Tree}, @@ -1149,6 +1149,8 @@ pub mouse_down_range: Option, pub cursor_cache: CursorCache, + + pub mouse_clicks: MouseClicks, } pub type Motion = Box; @@ -1271,6 +1273,7 @@ handlers, mouse_down_range: None, cursor_cache: CursorCache::default(), + mouse_clicks: MouseClicks::new(), } } diff -r a1c38f138388 -r 33a72d958587 helix-view/src/input.rs --- a/helix-view/src/input.rs Wed May 14 10:23:43 2025 -0400 +++ b/helix-view/src/input.rs Thu Jan 16 17:44:31 2025 +0000 @@ -5,6 +5,7 @@ use std::fmt; pub use crate::keyboard::{KeyCode, KeyModifiers, MediaKeyCode, ModifierKeyCode}; +use crate::ViewId; #[derive(Debug, PartialOrd, PartialEq, Eq, Clone, Hash)] pub enum Event { @@ -59,6 +60,82 @@ /// Middle mouse button. Middle, } + +/// Tracks the character positions and views where we last saw a mouse click +#[derive(Debug)] +pub struct MouseClicks { + /// The last 2 clicks on specific characters in the editor: + /// (character index clicked, view id) + // We store the view id to ensure that if we click on + // the 3rd character in view #1 and 3rd character in view #2, + // it won't register as a double click. + clicks: [Option<(usize, ViewId)>; 2], +} + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum MouseClick { + /// A click where the pressed character is different to the character previously pressed + Single, + /// A click where the same character was pressed 2 times in a row + Double, + /// A click where the same character pressed 3 times in a row + Triple, +} + +/// A fixed-size queue of length 2, storing the most recently clicked characters +/// as well as the views for which they were clicked. +impl MouseClicks { + pub fn new() -> Self { + Self { + clicks: [None, None], + } + } + + /// Add a click to the beginning of the queue, discarding the last click + fn insert(&mut self, click: usize, view_id: ViewId) { + self.clicks[1] = self.clicks[0]; + self.clicks[0] = Some((click, view_id)); + } + + /// Registers a click for a certain character index, and returns the type of this click + pub fn register_click(&mut self, click: usize, view_id: ViewId) -> MouseClick { + let click_type = if self.is_triple_click(click, view_id) { + // Clicking 4th time on the same character should be the same as clicking for the 1st time + // So we reset the state + self.clicks = [None, None]; + + return MouseClick::Triple; + } else if self.is_double_click(click, view_id) { + MouseClick::Double + } else { + MouseClick::Single + }; + + self.insert(click, view_id); + + click_type + } + + /// If we click this character, would that be a triple click? + fn is_triple_click(&mut self, click: usize, view_id: ViewId) -> bool { + Some((click, view_id)) == self.clicks[0] && Some((click, view_id)) == self.clicks[1] + } + + /// If we click this character, would that be a double click? + fn is_double_click(&mut self, click: usize, view_id: ViewId) -> bool { + Some((click, view_id)) == self.clicks[0] + && self.clicks[1].map_or(true, |(prev_click, prev_view_id)| { + !(click == prev_click && prev_view_id == view_id) + }) + } +} + +impl Default for MouseClicks { + fn default() -> Self { + Self::new() + } +} + /// Represents a key event. // We use a newtype here because we want to customize Deserialize and Display. #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Hash)] @@ -961,4 +1038,52 @@ assert!(parse_macro("abc>123").is_err()); assert!(parse_macro("wd").is_err()); } + + #[test] + fn clicking_4th_time_resets_mouse_clicks() { + let mut mouse_clicks = MouseClicks::new(); + let view = ViewId::default(); + + assert_eq!(mouse_clicks.register_click(4, view), MouseClick::Single); + assert_eq!(mouse_clicks.register_click(4, view), MouseClick::Double); + assert_eq!(mouse_clicks.register_click(4, view), MouseClick::Triple); + + assert_eq!(mouse_clicks.register_click(4, view), MouseClick::Single); + } + + #[test] + fn clicking_different_characters_resets_mouse_clicks() { + let mut mouse_clicks = MouseClicks::new(); + let view = ViewId::default(); + + assert_eq!(mouse_clicks.register_click(4, view), MouseClick::Single); + assert_eq!(mouse_clicks.register_click(4, view), MouseClick::Double); + + assert_eq!(mouse_clicks.register_click(8, view), MouseClick::Single); + + assert_eq!(mouse_clicks.register_click(1, view), MouseClick::Single); + assert_eq!(mouse_clicks.register_click(1, view), MouseClick::Double); + assert_eq!(mouse_clicks.register_click(1, view), MouseClick::Triple); + } + + #[test] + fn switching_views_resets_mouse_clicks() { + let mut mouse_clicks = MouseClicks::new(); + let mut view_ids = slotmap::HopSlotMap::with_key(); + let view1 = view_ids.insert(()); + let view2 = view_ids.insert(()); + + assert_eq!(mouse_clicks.register_click(4, view1), MouseClick::Single); + + assert_eq!(mouse_clicks.register_click(4, view2), MouseClick::Single); + + assert_eq!(mouse_clicks.register_click(4, view1), MouseClick::Single); + + assert_eq!(mouse_clicks.register_click(4, view2), MouseClick::Single); + assert_eq!(mouse_clicks.register_click(4, view2), MouseClick::Double); + + assert_eq!(mouse_clicks.register_click(4, view1), MouseClick::Single); + assert_eq!(mouse_clicks.register_click(4, view1), MouseClick::Double); + assert_eq!(mouse_clicks.register_click(4, view1), MouseClick::Triple); + } }