From 8361de45dc20e428c538f784898e6c47646b6e8d Mon Sep 17 00:00:00 2001 From: Dmitry Sharshakov Date: Fri, 30 Jul 2021 10:52:00 +0300 Subject: [PATCH] Mouse selection support (#509) * Initial mouse selection support Signed-off-by: Dmitry Sharshakov * Disable mouse event capture if editor crashes Signed-off-by: Dmitry Sharshakov * Translate screen coordinates to view position Signed-off-by: Dmitry Sharshakov * Select full lines by dragging on line numbers Signed-off-by: Dmitry Sharshakov * editor: don't register dragging as a jump Signed-off-by: Dmitry Sharshakov * Count graphemes correctly Signed-off-by: Dmitry Sharshakov * Do not select lines when dragging on the line number bar Signed-off-by: Dmitry Sharshakov * Split out verify_screen_coords Signed-off-by: Dmitry Sharshakov * Do not iterate over the graphemes twice Signed-off-by: Dmitry Sharshakov * Switch view by clicking on it Signed-off-by: Dmitry Sharshakov * Add disable-mouse config option Signed-off-by: Dmitry Sharshakov * Support multiple selections with mouse Signed-off-by: Dmitry Sharshakov * Remove unnecessary check Signed-off-by: Dmitry Sharshakov * Refactor using match expression Co-authored-by: Gokul Soumya Signed-off-by: Dmitry Sharshakov * Rename local variable Signed-off-by: Dmitry Sharshakov * Rename mouse option Signed-off-by: Dmitry Sharshakov * Refactor code Signed-off-by: Dmitry Sharshakov * Fix dragging selection Signed-off-by: Dmitry Sharshakov * Fix crash when clicking past last line Signed-off-by: Dmitry Sharshakov * Count characters better Signed-off-by: Dmitry Sharshakov * Remove comparison not needed anymore Signed-off-by: Dmitry Sharshakov * Validate coordinates before resolving position Signed-off-by: Dmitry Sharshakov * Tidy up references to editor tree Signed-off-by: Dmitry Sharshakov * Better way to determine line end and avoid overflow Signed-off-by: Dmitry Sharshakov * Fix for last line Signed-off-by: Dmitry Sharshakov * Add unit tests for text_pos_at_screen_coords Signed-off-by: Dmitry Sharshakov Co-authored-by: Gokul Soumya --- helix-term/src/application.rs | 7 +- helix-term/src/config.rs | 14 ++++ helix-term/src/ui/editor.rs | 68 +++++++++++++++- helix-view/src/view.rs | 147 ++++++++++++++++++++++++++++++++++ 4 files changed, 233 insertions(+), 3 deletions(-) diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index c55d4c983..5f3506714 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -15,7 +15,7 @@ use std::{ use anyhow::Error; use crossterm::{ - event::{Event, EventStream}, + event::{DisableMouseCapture, EnableMouseCapture, Event, EventStream}, execute, terminal, }; @@ -449,6 +449,9 @@ impl Application { let mut stdout = stdout(); execute!(stdout, terminal::EnterAlternateScreen)?; + if self.config.terminal.mouse { + execute!(stdout, EnableMouseCapture)?; + } // Exit the alternate screen and disable raw mode before panicking let hook = std::panic::take_hook(); @@ -456,6 +459,7 @@ impl Application { // We can't handle errors properly inside this closure. And it's // probably not a good idea to `unwrap()` inside a panic handler. // So we just ignore the `Result`s. + let _ = execute!(std::io::stdout(), DisableMouseCapture); let _ = execute!(std::io::stdout(), terminal::LeaveAlternateScreen); let _ = terminal::disable_raw_mode(); hook(info); @@ -468,6 +472,7 @@ impl Application { // reset cursor shape write!(stdout, "\x1B[2 q")?; + execute!(stdout, DisableMouseCapture)?; execute!(stdout, terminal::LeaveAlternateScreen)?; terminal::disable_raw_mode()?; diff --git a/helix-term/src/config.rs b/helix-term/src/config.rs index f3f0ba531..38cd3bfbd 100644 --- a/helix-term/src/config.rs +++ b/helix-term/src/config.rs @@ -9,6 +9,8 @@ pub struct Config { pub lsp: LspConfig, #[serde(default)] pub keys: Keymaps, + #[serde(default)] + pub terminal: TerminalConfig, } #[derive(Debug, Default, Clone, PartialEq, Deserialize)] @@ -17,6 +19,18 @@ pub struct LspConfig { pub display_messages: bool, } +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct TerminalConfig { + pub mouse: bool, +} + +impl Default for TerminalConfig { + fn default() -> Self { + Self { mouse: true } + } +} + #[test] fn parsing_keymaps_config_file() { use crate::keymap; diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index d5c907b8e..ec5687bd7 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -12,7 +12,7 @@ use helix_core::{ syntax::{self, HighlightEvent}, unicode::segmentation::UnicodeSegmentation, unicode::width::UnicodeWidthStr, - LineEnding, Position, Range, + LineEnding, Position, Range, Selection, }; use helix_view::{ document::Mode, @@ -24,7 +24,7 @@ use helix_view::{ }; use std::borrow::Cow; -use crossterm::event::Event; +use crossterm::event::{Event, MouseButton, MouseEvent, MouseEventKind}; use tui::buffer::Buffer as Surface; pub struct EditorView { @@ -805,6 +805,70 @@ impl Component for EditorView { EventResult::Consumed(callback) } + Event::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + row, + column, + modifiers, + .. + }) => { + let editor = &mut cx.editor; + + let result = editor.tree.views().find_map(|(view, _focus)| { + view.pos_at_screen_coords( + &editor.documents[view.doc], + row as usize, + column as usize, + ) + .map(|pos| (pos, view.id)) + }); + + if let Some((pos, id)) = result { + let doc = &mut editor.documents[editor.tree.get(id).doc]; + let jump = (doc.id(), doc.selection(id).clone()); + editor.tree.get_mut(id).jumps.push(jump); + + if modifiers == crossterm::event::KeyModifiers::ALT { + let selection = doc.selection(id).clone(); + doc.set_selection(id, selection.push(Range::point(pos))); + } else { + doc.set_selection(id, Selection::point(pos)); + } + + editor.tree.focus = id; + + return EventResult::Consumed(None); + } + + EventResult::Ignored + } + + Event::Mouse(MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + row, + column, + .. + }) => { + let (view, doc) = current!(cx.editor); + + let pos = view.pos_at_screen_coords(doc, row as usize, column as usize); + + if pos == None { + return EventResult::Ignored; + } + + let selection = doc.selection(view.id).clone(); + let primary_anchor = selection.primary().anchor; + let new_selection = selection.transform(|range| -> Range { + if range.anchor == primary_anchor { + return Range::new(primary_anchor, pos.unwrap()); + } + range + }); + + doc.set_selection(view.id, new_selection); + EventResult::Consumed(None) + } Event::Mouse(_) => EventResult::Ignored, } } diff --git a/helix-view/src/view.rs b/helix-view/src/view.rs index 6b0c3c2af..d61fbe4ae 100644 --- a/helix-view/src/view.rs +++ b/helix-view/src/view.rs @@ -4,6 +4,7 @@ use crate::{graphics::Rect, Document, DocumentId, ViewId}; use helix_core::{ coords_at_pos, graphemes::{grapheme_width, RopeGraphemes}, + line_ending::line_end_char_index, Position, RopeSlice, Selection, }; @@ -165,6 +166,74 @@ impl View { Some(Position::new(row, col)) } + /// Verifies whether a screen position is inside the view + /// Returns true when position is inside the view + pub fn verify_screen_coords(&self, row: usize, column: usize) -> bool { + // 2 for status + if row < self.area.y as usize || row > self.area.y as usize + self.area.height as usize - 2 + { + return false; + } + + // TODO: not ideal + const OFFSET: usize = 7; // 1 diagnostic + 5 linenr + 1 gutter + + if column < self.area.x as usize + OFFSET + || column > self.area.x as usize + self.area.width as usize + { + return false; + } + true + } + + pub fn text_pos_at_screen_coords( + &self, + text: &RopeSlice, + row: usize, + column: usize, + tab_width: usize, + ) -> Option { + if !self.verify_screen_coords(row, column) { + return None; + } + + let line_number = row - self.area.y as usize + self.first_line; + + if line_number > text.len_lines() - 1 { + return Some(text.len_chars()); + } + + let mut pos = text.line_to_char(line_number); + + let current_line = text.line(line_number); + + // TODO: not ideal + const OFFSET: usize = 7; // 1 diagnostic + 5 linenr + 1 gutter + + let target = column - OFFSET - self.area.x as usize + self.first_col; + let mut selected = 0; + + for grapheme in RopeGraphemes::new(current_line) { + if selected >= target { + break; + } + if grapheme == "\t" { + selected += tab_width; + } else { + let width = grapheme_width(&Cow::from(grapheme)); + selected += width; + } + pos += grapheme.chars().count(); + } + + Some(pos.min(line_end_char_index(&text.slice(..), line_number))) + } + + /// Translates a screen position to position in the text document. + /// Returns a usize typed position in bounds of the text if found in this view, None if out of view. + pub fn pos_at_screen_coords(&self, doc: &Document, row: usize, column: usize) -> Option { + self.text_pos_at_screen_coords(&doc.text().slice(..), row, column, doc.tab_width()) + } // pub fn traverse(&self, text: RopeSlice, start: usize, end: usize, fun: F) // where // F: Fn(usize, usize), @@ -186,3 +255,81 @@ impl View { // } // } } + +#[cfg(test)] +mod tests { + use super::*; + use helix_core::Rope; + + #[test] + fn test_text_pos_at_screen_coords() { + let mut view = View::new(DocumentId::default()); + view.area = Rect::new(40, 40, 40, 40); + let text = Rope::from_str("abc\n\tdef"); + + assert_eq!( + view.text_pos_at_screen_coords(&text.slice(..), 40, 2, 4), + None + ); + + assert_eq!( + view.text_pos_at_screen_coords(&text.slice(..), 40, 41, 4), + None + ); + + assert_eq!( + view.text_pos_at_screen_coords(&text.slice(..), 0, 2, 4), + None + ); + + assert_eq!( + view.text_pos_at_screen_coords(&text.slice(..), 0, 49, 4), + None + ); + + assert_eq!( + view.text_pos_at_screen_coords(&text.slice(..), 0, 41, 4), + None + ); + + assert_eq!( + view.text_pos_at_screen_coords(&text.slice(..), 40, 81, 4), + None + ); + + assert_eq!( + view.text_pos_at_screen_coords(&text.slice(..), 78, 41, 4), + None + ); + + assert_eq!( + view.text_pos_at_screen_coords(&text.slice(..), 40, 40 + 7 + 3, 4), + Some(3) + ); + + assert_eq!( + view.text_pos_at_screen_coords(&text.slice(..), 40, 80, 4), + Some(3) + ); + + assert_eq!( + view.text_pos_at_screen_coords(&text.slice(..), 41, 40 + 7 + 1, 4), + Some(5) + ); + + assert_eq!( + view.text_pos_at_screen_coords(&text.slice(..), 41, 40 + 7 + 4, 4), + Some(5) + ); + + assert_eq!( + view.text_pos_at_screen_coords(&text.slice(..), 41, 40 + 7 + 7, 4), + Some(8) + ); + + assert_eq!( + view.text_pos_at_screen_coords(&text.slice(..), 41, 80, 4), + Some(8) + ); + } +}