diff --git a/helix-core/src/textobject.rs b/helix-core/src/textobject.rs index 008228f43..16ee8663d 100644 --- a/helix-core/src/textobject.rs +++ b/helix-core/src/textobject.rs @@ -67,6 +67,16 @@ impl Display for TextObject { } } +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 @@ pub fn textobject_word( ) -> 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 --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index 9343d55d4..308edcdbb 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -19,6 +19,7 @@ use helix_core::{ 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 @@ use helix_view::{ 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, }; @@ -1128,22 +1129,42 @@ impl EditorView { 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 --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 89f053741..7478b87ba 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -8,7 +8,7 @@ use crate::{ graphics::{CursorKind, Rect}, handlers::Handlers, info::Info, - input::KeyEvent, + input::{KeyEvent, MouseClicks}, register::Registers, theme::{self, Theme}, tree::{self, Tree}, @@ -1133,6 +1133,8 @@ pub struct Editor { pub mouse_down_range: Option, pub cursor_cache: CursorCache, + + pub mouse_clicks: MouseClicks, } pub type Motion = Box; @@ -1255,6 +1257,7 @@ impl Editor { handlers, mouse_down_range: None, cursor_cache: CursorCache::default(), + mouse_clicks: MouseClicks::new(), } } diff --git a/helix-view/src/input.rs b/helix-view/src/input.rs index d359db703..dc05ea78f 100644 --- a/helix-view/src/input.rs +++ b/helix-view/src/input.rs @@ -5,6 +5,7 @@ use serde::de::{self, Deserialize, Deserializer}; 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 @@ pub enum MouseButton { /// 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 @@ mod test { 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); + } }