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);
+    }
 }