Mercurial > forks > helix
changeset 6834:33a72d958587 draft tip
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
author | Nikita Revenco <154856872+NikitaRevenco@users.noreply.github.com> |
---|---|
date | Thu, 16 Jan 2025 17:44:31 +0000 |
parents | a1c38f138388 |
children | |
files | helix-core/src/textobject.rs helix-term/src/ui/editor.rs helix-view/src/editor.rs helix-view/src/input.rs |
diffstat | 4 files changed, 177 insertions(+), 22 deletions(-) [+] |
line wrap: on
line diff
--- 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 {
--- 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);
--- 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<Range>, pub cursor_cache: CursorCache, + + pub mouse_clicks: MouseClicks, } pub type Motion = Box<dyn Fn(&mut Editor)>; @@ -1271,6 +1273,7 @@ handlers, mouse_down_range: None, cursor_cache: CursorCache::default(), + mouse_clicks: MouseClicks::new(), } }
--- 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<foo>").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); + } }