Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Double click selects word, Triple click selects line #12514

Open
wants to merge 23 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
2491522
feat: basic MouseClicks struct
nik-rev Jan 12, 2025
897569f
fix: correct functions for double and triple click
nik-rev Jan 12, 2025
2b76e83
refactor: use enum for mouse click type
nik-rev Jan 12, 2025
30b592f
refactor: clean up code and remove debugging statements
nik-rev Jan 12, 2025
6c19d2c
feat: implement double click and single click selection
nik-rev Jan 12, 2025
b3831c0
refactor: move impl to a proper mod
nik-rev Jan 12, 2025
78bfdb6
refactor: rename variables
nik-rev Jan 12, 2025
64390df
refactor: extract duplicated logic
nik-rev Jan 12, 2025
c43074a
fix: 4th click on the same spot will reset the selection
nik-rev Jan 12, 2025
a9a1e69
chore: remove comment
nik-rev Jan 12, 2025
a60dc2d
refactor: reverse &&
nik-rev Jan 12, 2025
7add058
chore: add documentation
nik-rev Jan 12, 2025
7537b0c
refactor: use u8 for count
nik-rev Jan 13, 2025
6fbe9fe
refactor: variable names
nik-rev Jan 13, 2025
82401d8
fix: double click not registering after the first one
nik-rev Jan 13, 2025
056e2ff
fix: clicking the same char indexes in different views triggered doub…
nik-rev Jan 16, 2025
395f4bf
docs: document the clicks field
nik-rev Jan 16, 2025
7694517
docs: make docs more informative
nik-rev Jan 16, 2025
c4672ed
test: add tests for MouseClicks
nik-rev Jan 16, 2025
931b8d9
refactor: simplify internal MouseClicks code
nik-rev Jan 16, 2025
0d21faf
docs: better description of MouseClicks
nik-rev Jan 16, 2025
26c230c
fix: failing tests
nik-rev Jan 16, 2025
6576e92
refactor: do not use unnecessary type hint
nik-rev Jan 16, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions helix-core/src/textobject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,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,
Expand All @@ -78,11 +88,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 {
Expand Down
53 changes: 37 additions & 16 deletions helix-term/src/ui/editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ use helix_core::{
movement::Direction,
syntax::{self, HighlightEvent},
text_annotations::TextAnnotations,
textobject::find_word_boundaries,
unicode::width::UnicodeWidthStr,
visual_offset_from_block, Change, Position, Range, Selection, Transaction,
};
Expand All @@ -27,7 +28,7 @@ use helix_view::{
document::{Mode, SavePoint, 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,
};
Expand Down Expand Up @@ -1176,22 +1177,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(..);

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

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));
}
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);
Expand Down
5 changes: 4 additions & 1 deletion helix-view/src/editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -1101,6 +1101,8 @@ pub struct Editor {

pub mouse_down_range: Option<Range>,
pub cursor_cache: CursorCache,

pub mouse_clicks: MouseClicks,
}

pub type Motion = Box<dyn Fn(&mut Editor)>;
Expand Down Expand Up @@ -1223,6 +1225,7 @@ impl Editor {
handlers,
mouse_down_range: None,
cursor_cache: CursorCache::default(),
mouse_clicks: MouseClicks::new(),
}
}

Expand Down
125 changes: 125 additions & 0 deletions helix-view/src/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -961,4 +1038,52 @@ mod test {
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);
}
}
Loading