From 37c921f972cf95c81230ba7a66e9622c9ffb3214 Mon Sep 17 00:00:00 2001 From: Keith Simmons Date: Tue, 3 May 2022 10:29:57 -0700 Subject: [PATCH] Initial visual mode --- Cargo.lock | 1 + assets/keymaps/vim.json | 14 +- crates/editor/src/display_map.rs | 14 +- crates/text/src/selection.rs | 13 ++ crates/util/src/test/assertions.rs | 57 +++++- crates/util/src/test/marked_text.rs | 52 ++++-- crates/vim/Cargo.toml | 1 + crates/vim/src/motion.rs | 2 + crates/vim/src/normal.rs | 57 +++--- crates/vim/src/state.rs | 3 + crates/vim/src/vim.rs | 3 + crates/vim/src/vim_test_context.rs | 240 ++++++++++++++++++------ crates/vim/src/visual.rs | 277 ++++++++++++++++++++++++++++ 13 files changed, 621 insertions(+), 113 deletions(-) create mode 100644 crates/vim/src/visual.rs diff --git a/Cargo.lock b/Cargo.lock index 23a87887a5..cff2b604dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5555,6 +5555,7 @@ dependencies = [ "editor", "gpui", "indoc", + "itertools", "language", "log", "project", diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 67775c6a67..fc13d2927c 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -68,7 +68,11 @@ "shift-X": "vim::DeleteLeft", "shift-^": "vim::FirstNonWhitespace", "o": "vim::InsertLineBelow", - "shift-O": "vim::InsertLineAbove" + "shift-O": "vim::InsertLineAbove", + "v": [ + "vim::SwitchMode", + "Visual" + ] } }, { @@ -100,6 +104,14 @@ "d": "vim::CurrentLine" } }, + { + "context": "Editor && vim_mode == visual", + "bindings": { + "c": "vim::VisualChange", + "d": "vim::VisualDelete", + "x": "vim::VisualDelete" + } + }, { "context": "Editor && vim_mode == insert", "bindings": { diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index f004533118..3de44e0315 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -355,13 +355,21 @@ impl DisplaySnapshot { pub fn clip_point(&self, point: DisplayPoint, bias: Bias) -> DisplayPoint { let mut clipped = self.blocks_snapshot.clip_point(point.0, bias); - if self.clip_at_line_ends && clipped.column == self.line_len(clipped.row) { - clipped.column = clipped.column.saturating_sub(1); - clipped = self.blocks_snapshot.clip_point(clipped, Bias::Left); + if self.clip_at_line_ends { + clipped = self.clip_at_line_end(DisplayPoint(clipped)).0 } DisplayPoint(clipped) } + pub fn clip_at_line_end(&self, point: DisplayPoint) -> DisplayPoint { + let mut point = point.0; + if point.column == self.line_len(point.row) { + point.column = point.column.saturating_sub(1); + point = self.blocks_snapshot.clip_point(point, Bias::Left); + } + DisplayPoint(point) + } + pub fn folds_in_range<'a, T>( &'a self, range: Range, diff --git a/crates/text/src/selection.rs b/crates/text/src/selection.rs index 23b0d2b3b0..8dcc3fc7f1 100644 --- a/crates/text/src/selection.rs +++ b/crates/text/src/selection.rs @@ -85,6 +85,19 @@ impl Selection { } } +impl Selection { + #[cfg(feature = "test-support")] + pub fn from_offset(offset: usize) -> Self { + Selection { + id: 0, + start: offset, + end: offset, + goal: SelectionGoal::None, + reversed: false, + } + } +} + impl Selection { pub fn resolve<'a, D: 'a + TextDimension>( &'a self, diff --git a/crates/util/src/test/assertions.rs b/crates/util/src/test/assertions.rs index 8402941445..eccb5e41a6 100644 --- a/crates/util/src/test/assertions.rs +++ b/crates/util/src/test/assertions.rs @@ -1,19 +1,62 @@ +pub enum SetEqError { + LeftMissing(T), + RightMissing(T), +} + +impl SetEqError { + pub fn map R>(self, update: F) -> SetEqError { + match self { + SetEqError::LeftMissing(missing) => SetEqError::LeftMissing(update(missing)), + SetEqError::RightMissing(missing) => SetEqError::RightMissing(update(missing)), + } + } +} + #[macro_export] -macro_rules! assert_set_eq { +macro_rules! set_eq { ($left:expr,$right:expr) => {{ + use util::test::*; + let left = $left; let right = $right; - for left_value in left.iter() { - if !right.contains(left_value) { - panic!("assertion failed: `(left == right)`\n left: {:?}\nright: {:?}\nright does not contain {:?}", left, right, left_value); + let mut result = Ok(()); + for right_value in right.iter() { + if !left.contains(right_value) { + result = Err(SetEqError::LeftMissing(right_value.clone())); + break; } } - for right_value in right.iter() { - if !left.contains(right_value) { - panic!("assertion failed: `(left == right)`\n left: {:?}\nright: {:?}\nleft does not contain {:?}", left, right, right_value); + if result.is_ok() { + for left_value in left.iter() { + if !right.contains(left_value) { + result = Err(SetEqError::RightMissing(left_value.clone())); + } } } + + result + }}; +} + +#[macro_export] +macro_rules! assert_set_eq { + ($left:expr,$right:expr) => {{ + use util::test::*; + use util::set_eq; + + let left = $left; + let right = $right; + + match set_eq!(left, right) { + Err(SetEqError::LeftMissing(missing)) => { + panic!("assertion failed: `(left == right)`\n left: {:?}\nright: {:?}\nright does not contain {:?}", left, right, missing); + }, + Err(SetEqError::RightMissing(missing)) => { + panic!("assertion failed: `(left == right)`\n left: {:?}\nright: {:?}\nleft does not contain {:?}", left, right, missing); + }, + _ => {} + } }}; } diff --git a/crates/util/src/test/marked_text.rs b/crates/util/src/test/marked_text.rs index 3af056f13e..e0f549b7df 100644 --- a/crates/util/src/test/marked_text.rs +++ b/crates/util/src/test/marked_text.rs @@ -21,22 +21,44 @@ pub fn marked_text_by( pub fn marked_text(marked_text: &str) -> (String, Vec) { let (unmarked_text, mut markers) = marked_text_by(marked_text, vec!['|']); - (unmarked_text, markers.remove(&'|').unwrap_or_else(Vec::new)) + (unmarked_text, markers.remove(&'|').unwrap_or_default()) +} + +pub fn marked_text_ranges_by( + marked_text: &str, + delimiters: Vec<(char, char)>, +) -> (String, HashMap<(char, char), Vec>>) { + let all_markers = delimiters + .iter() + .flat_map(|(start, end)| [*start, *end]) + .collect(); + let (unmarked_text, mut markers) = marked_text_by(marked_text, all_markers); + let range_lookup = delimiters + .into_iter() + .map(|(start_marker, end_marker)| { + let starts = markers.remove(&start_marker).unwrap_or_default(); + let ends = markers.remove(&end_marker).unwrap_or_default(); + assert_eq!(starts.len(), ends.len(), "marked ranges are unbalanced"); + + let ranges = starts + .into_iter() + .zip(ends) + .map(|(start, end)| { + assert!(end >= start, "marked ranges must be disjoint"); + start..end + }) + .collect::>>(); + ((start_marker, end_marker), ranges) + }) + .collect(); + + (unmarked_text, range_lookup) } pub fn marked_text_ranges(marked_text: &str) -> (String, Vec>) { - let (unmarked_text, mut markers) = marked_text_by(marked_text, vec!['[', ']']); - let opens = markers.remove(&'[').unwrap_or_default(); - let closes = markers.remove(&']').unwrap_or_default(); - assert_eq!(opens.len(), closes.len(), "marked ranges are unbalanced"); - - let ranges = opens - .into_iter() - .zip(closes) - .map(|(open, close)| { - assert!(close >= open, "marked ranges must be disjoint"); - open..close - }) - .collect(); - (unmarked_text, ranges) + let (unmarked_text, mut ranges) = marked_text_ranges_by(marked_text, vec![('[', ']')]); + ( + unmarked_text, + ranges.remove(&('[', ']')).unwrap_or_else(Vec::new), + ) } diff --git a/crates/vim/Cargo.toml b/crates/vim/Cargo.toml index 8e74898f4b..ad4bd8871c 100644 --- a/crates/vim/Cargo.toml +++ b/crates/vim/Cargo.toml @@ -16,6 +16,7 @@ language = { path = "../language" } serde = { version = "1", features = ["derive"] } settings = { path = "../settings" } workspace = { path = "../workspace" } +itertools = "0.10" log = { version = "0.4.16", features = ["kv_unstable_serde"] } [dev-dependencies] diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index ba4ccaf610..a38d10c8f8 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -11,6 +11,7 @@ use workspace::Workspace; use crate::{ normal::normal_motion, state::{Mode, Operator}, + visual::visual_motion, Vim, }; @@ -110,6 +111,7 @@ fn motion(motion: Motion, cx: &mut MutableAppContext) { }); match Vim::read(cx).state.mode { Mode::Normal => normal_motion(motion, cx), + Mode::Visual => visual_motion(motion, cx), Mode::Insert => { // Shouldn't execute a motion in insert mode. Ignoring } diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index 679f50bd26..63d5ab4ccb 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -136,7 +136,7 @@ fn insert_line_above(_: &mut Workspace, _: &InsertLineAbove, cx: &mut ViewContex new_text.push('\n'); (start_of_line..start_of_line, new_text) }); - editor.edit(edits, cx); + editor.edit_with_autoindent(edits, cx); editor.move_cursors(cx, |map, mut cursor, _| { *cursor.row_mut() -= 1; *cursor.column_mut() = map.line_len(cursor.row()); @@ -169,7 +169,7 @@ fn insert_line_below(_: &mut Workspace, _: &InsertLineBelow, cx: &mut ViewContex editor.move_cursors(cx, |map, cursor, goal| { Motion::EndOfLine.move_point(map, cursor, goal) }); - editor.edit(edits, cx); + editor.edit_with_autoindent(edits, cx); }); }); }); @@ -178,6 +178,7 @@ fn insert_line_below(_: &mut Workspace, _: &InsertLineBelow, cx: &mut ViewContex #[cfg(test)] mod test { use indoc::indoc; + use language::Selection; use util::test::marked_text; use crate::{ @@ -420,7 +421,7 @@ mod test { for cursor_offset in cursor_offsets { cx.simulate_keystroke("w"); - cx.assert_newest_selection_head_offset(cursor_offset); + cx.assert_editor_selections(vec![Selection::from_offset(cursor_offset)]); } // Reset and test ignoring punctuation @@ -442,7 +443,7 @@ mod test { for cursor_offset in cursor_offsets { cx.simulate_keystroke("shift-W"); - cx.assert_newest_selection_head_offset(cursor_offset); + cx.assert_editor_selections(vec![Selection::from_offset(cursor_offset)]); } } @@ -467,7 +468,7 @@ mod test { for cursor_offset in cursor_offsets { cx.simulate_keystroke("e"); - cx.assert_newest_selection_head_offset(cursor_offset); + cx.assert_editor_selections(vec![Selection::from_offset(cursor_offset)]); } // Reset and test ignoring punctuation @@ -488,7 +489,7 @@ mod test { ); for cursor_offset in cursor_offsets { cx.simulate_keystroke("shift-E"); - cx.assert_newest_selection_head_offset(cursor_offset); + cx.assert_editor_selections(vec![Selection::from_offset(cursor_offset)]); } } @@ -513,7 +514,7 @@ mod test { for cursor_offset in cursor_offsets.into_iter().rev() { cx.simulate_keystroke("b"); - cx.assert_newest_selection_head_offset(cursor_offset); + cx.assert_editor_selections(vec![Selection::from_offset(cursor_offset)]); } // Reset and test ignoring punctuation @@ -534,7 +535,7 @@ mod test { ); for cursor_offset in cursor_offsets.into_iter().rev() { cx.simulate_keystroke("shift-B"); - cx.assert_newest_selection_head_offset(cursor_offset); + cx.assert_editor_selections(vec![Selection::from_offset(cursor_offset)]); } } @@ -821,25 +822,21 @@ mod test { ); cx.assert( indoc! {" - fn test() { - println!(|); - }"}, + fn test() + println!(|);"}, indoc! {" - fn test() { + fn test() println!(); - | - }"}, + |"}, ); cx.assert( indoc! {" - fn test(|) { - println!(); - }"}, + fn test(|) + println!();"}, indoc! {" - fn test() { + fn test() | - println!(); - }"}, + println!();"}, ); } @@ -906,25 +903,21 @@ mod test { ); cx.assert( indoc! {" - fn test() { - println!(|); - }"}, + fn test() + println!(|);"}, indoc! {" - fn test() { + fn test() | - println!(); - }"}, + println!();"}, ); cx.assert( indoc! {" - fn test(|) { - println!(); - }"}, + fn test(|) + println!();"}, indoc! {" | - fn test() { - println!(); - }"}, + fn test() + println!();"}, ); } diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index 73769eafbc..b4d5cbe9c7 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -6,6 +6,7 @@ use serde::Deserialize; pub enum Mode { Normal, Insert, + Visual, } impl Default for Mode { @@ -36,6 +37,7 @@ impl VimState { pub fn cursor_shape(&self) -> CursorShape { match self.mode { Mode::Normal => CursorShape::Block, + Mode::Visual => CursorShape::Block, Mode::Insert => CursorShape::Bar, } } @@ -50,6 +52,7 @@ impl VimState { "vim_mode".to_string(), match self.mode { Mode::Normal => "normal", + Mode::Visual => "visual", Mode::Insert => "insert", } .to_string(), diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index 527cfa318c..f0731edd49 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -6,6 +6,7 @@ mod insert; mod motion; mod normal; mod state; +mod visual; use collections::HashMap; use editor::{CursorShape, Editor}; @@ -27,6 +28,7 @@ impl_actions!(vim, [SwitchMode, PushOperator]); pub fn init(cx: &mut MutableAppContext) { editor_events::init(cx); normal::init(cx); + visual::init(cx); insert::init(cx); motion::init(cx); @@ -116,6 +118,7 @@ impl Vim { fn sync_editor_options(&self, cx: &mut MutableAppContext) { let state = &self.state; + let cursor_shape = state.cursor_shape(); for editor in self.editors.values() { if let Some(editor) = editor.upgrade(cx) { diff --git a/crates/vim/src/vim_test_context.rs b/crates/vim/src/vim_test_context.rs index 400a8e467a..26ec6da84b 100644 --- a/crates/vim/src/vim_test_context.rs +++ b/crates/vim/src/vim_test_context.rs @@ -1,9 +1,16 @@ -use std::ops::Deref; +use std::ops::{Deref, Range}; -use editor::{display_map::ToDisplayPoint, Bias, DisplayPoint}; +use collections::BTreeMap; +use itertools::{Either, Itertools}; + +use editor::display_map::ToDisplayPoint; use gpui::{json::json, keymap::Keystroke, ViewHandle}; -use language::{Point, Selection}; -use util::test::marked_text; +use indoc::indoc; +use language::Selection; +use util::{ + set_eq, + test::{marked_text, marked_text_ranges_by, SetEqError}, +}; use workspace::{WorkspaceHandle, WorkspaceParams}; use crate::{state::Operator, *}; @@ -83,15 +90,6 @@ impl<'a> VimTestContext<'a> { }) } - pub fn newest_selection(&mut self) -> Selection { - self.editor.update(self.cx, |editor, cx| { - let snapshot = editor.snapshot(cx); - editor - .newest_selection::(cx) - .map(|point| point.to_display_point(&snapshot.display_snapshot)) - }) - } - pub fn mode(&mut self) -> Mode { self.cx.read(|cx| cx.global::().state.mode) } @@ -134,51 +132,183 @@ impl<'a> VimTestContext<'a> { }) } - pub fn assert_newest_selection_head_offset(&mut self, expected_offset: usize) { - let actual_head = self.newest_selection().head(); - let (actual_offset, expected_head) = self.editor.update(self.cx, |editor, cx| { - let snapshot = editor.snapshot(cx); - ( - actual_head.to_offset(&snapshot, Bias::Left), - expected_offset.to_display_point(&snapshot), - ) - }); - let mut actual_position_text = self.editor_text(); - let mut expected_position_text = actual_position_text.clone(); - actual_position_text.insert(actual_offset, '|'); - expected_position_text.insert(expected_offset, '|'); - assert_eq!( - actual_head, expected_head, - "\nActual Position: {}\nExpected Position: {}", - actual_position_text, expected_position_text - ) - } - + // Asserts the editor state via a marked string. + // `|` characters represent empty selections + // `[` to `}` represents a non empty selection with the head at `}` + // `{` to `]` represents a non empty selection with the head at `{` pub fn assert_editor_state(&mut self, text: &str) { - let (unmarked_text, markers) = marked_text(&text); + let (text_with_ranges, expected_empty_selections) = marked_text(&text); + let (unmarked_text, mut selection_ranges) = + marked_text_ranges_by(&text_with_ranges, vec![('[', '}'), ('{', ']')]); let editor_text = self.editor_text(); assert_eq!( editor_text, unmarked_text, "Unmarked text doesn't match editor text" ); - let expected_offset = markers[0]; - let actual_head = self.newest_selection().head(); - let (actual_offset, expected_head) = self.editor.update(self.cx, |editor, cx| { - let snapshot = editor.snapshot(cx); - ( - actual_head.to_offset(&snapshot, Bias::Left), - expected_offset.to_display_point(&snapshot), + + let expected_reverse_selections = selection_ranges.remove(&('{', ']')).unwrap_or_default(); + let expected_forward_selections = selection_ranges.remove(&('[', '}')).unwrap_or_default(); + + self.assert_selections( + expected_empty_selections, + expected_reverse_selections, + expected_forward_selections, + Some(text.to_string()), + ) + } + + pub fn assert_editor_selections(&mut self, expected_selections: Vec>) { + let (expected_empty_selections, expected_non_empty_selections): (Vec<_>, Vec<_>) = + expected_selections.into_iter().partition_map(|selection| { + if selection.is_empty() { + Either::Left(selection.head()) + } else { + Either::Right(selection) + } + }); + + let (expected_reverse_selections, expected_forward_selections): (Vec<_>, Vec<_>) = + expected_non_empty_selections + .into_iter() + .partition_map(|selection| { + let range = selection.start..selection.end; + if selection.reversed { + Either::Left(range) + } else { + Either::Right(range) + } + }); + + self.assert_selections( + expected_empty_selections, + expected_reverse_selections, + expected_forward_selections, + None, + ) + } + + fn assert_selections( + &mut self, + expected_empty_selections: Vec, + expected_reverse_selections: Vec>, + expected_forward_selections: Vec>, + asserted_text: Option, + ) { + let (empty_selections, reverse_selections, forward_selections) = + self.editor.read_with(self.cx, |editor, cx| { + let (empty_selections, non_empty_selections): (Vec<_>, Vec<_>) = editor + .local_selections::(cx) + .into_iter() + .partition_map(|selection| { + if selection.is_empty() { + Either::Left(selection.head()) + } else { + Either::Right(selection) + } + }); + + let (reverse_selections, forward_selections): (Vec<_>, Vec<_>) = + non_empty_selections.into_iter().partition_map(|selection| { + let range = selection.start..selection.end; + if selection.reversed { + Either::Left(range) + } else { + Either::Right(range) + } + }); + (empty_selections, reverse_selections, forward_selections) + }); + + let asserted_selections = asserted_text.unwrap_or_else(|| { + self.insert_markers( + &expected_empty_selections, + &expected_reverse_selections, + &expected_forward_selections, ) }); - let mut actual_position_text = self.editor_text(); - let mut expected_position_text = actual_position_text.clone(); - actual_position_text.insert(actual_offset, '|'); - expected_position_text.insert(expected_offset, '|'); - assert_eq!( - actual_head, expected_head, - "\nActual Position: {}\nExpected Position: {}", - actual_position_text, expected_position_text - ) + let actual_selections = + self.insert_markers(&empty_selections, &reverse_selections, &forward_selections); + + let unmarked_text = self.editor_text(); + let all_eq: Result<(), SetEqError> = + set_eq!(expected_empty_selections, empty_selections) + .map_err(|err| { + err.map(|missing| { + let mut error_text = unmarked_text.clone(); + error_text.insert(missing, '|'); + error_text + }) + }) + .and_then(|_| { + set_eq!(expected_reverse_selections, reverse_selections).map_err(|err| { + err.map(|missing| { + let mut error_text = unmarked_text.clone(); + error_text.insert(missing.start, '{'); + error_text.insert(missing.end, ']'); + error_text + }) + }) + }) + .and_then(|_| { + set_eq!(expected_forward_selections, forward_selections).map_err(|err| { + err.map(|missing| { + let mut error_text = unmarked_text.clone(); + error_text.insert(missing.start, '['); + error_text.insert(missing.end, '}'); + error_text + }) + }) + }); + + match all_eq { + Err(SetEqError::LeftMissing(location_text)) => { + panic!( + indoc! {" + Editor has extra selection + Extra Selection Location: {} + Asserted selections: {} + Actual selections: {}"}, + location_text, asserted_selections, actual_selections, + ); + } + Err(SetEqError::RightMissing(location_text)) => { + panic!( + indoc! {" + Editor is missing empty selection + Missing Selection Location: {} + Asserted selections: {} + Actual selections: {}"}, + location_text, asserted_selections, actual_selections, + ); + } + _ => {} + } + } + + fn insert_markers( + &mut self, + empty_selections: &Vec, + reverse_selections: &Vec>, + forward_selections: &Vec>, + ) -> String { + let mut editor_text_with_selections = self.editor_text(); + let mut selection_marks = BTreeMap::new(); + for offset in empty_selections { + selection_marks.insert(offset, '|'); + } + for range in reverse_selections { + selection_marks.insert(&range.start, '{'); + selection_marks.insert(&range.end, ']'); + } + for range in forward_selections { + selection_marks.insert(&range.start, '['); + selection_marks.insert(&range.end, '}'); + } + for (offset, mark) in selection_marks.into_iter().rev() { + editor_text_with_selections.insert(*offset, mark); + } + + editor_text_with_selections } pub fn assert_binding( @@ -216,21 +346,21 @@ impl<'a> Deref for VimTestContext<'a> { pub struct VimBindingTestContext<'a, const COUNT: usize> { cx: VimTestContext<'a>, keystrokes_under_test: [&'static str; COUNT], - initial_mode: Mode, + mode_before: Mode, mode_after: Mode, } impl<'a, const COUNT: usize> VimBindingTestContext<'a, COUNT> { pub fn new( keystrokes_under_test: [&'static str; COUNT], - initial_mode: Mode, + mode_before: Mode, mode_after: Mode, cx: VimTestContext<'a>, ) -> Self { Self { cx, keystrokes_under_test, - initial_mode, + mode_before, mode_after, } } @@ -242,7 +372,7 @@ impl<'a, const COUNT: usize> VimBindingTestContext<'a, COUNT> { VimBindingTestContext { keystrokes_under_test, cx: self.cx, - initial_mode: self.initial_mode, + mode_before: self.mode_before, mode_after: self.mode_after, } } @@ -256,7 +386,7 @@ impl<'a, const COUNT: usize> VimBindingTestContext<'a, COUNT> { self.cx.assert_binding( self.keystrokes_under_test, initial_state, - self.initial_mode, + self.mode_before, state_after, self.mode_after, ) diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs new file mode 100644 index 0000000000..3380fb823d --- /dev/null +++ b/crates/vim/src/visual.rs @@ -0,0 +1,277 @@ +use editor::Bias; +use gpui::{actions, MutableAppContext, ViewContext}; +use workspace::Workspace; + +use crate::{motion::Motion, state::Mode, Vim}; + +actions!(vim, [VisualDelete, VisualChange]); + +pub fn init(cx: &mut MutableAppContext) { + cx.add_action(change); + cx.add_action(delete); +} + +pub fn visual_motion(motion: Motion, cx: &mut MutableAppContext) { + Vim::update(cx, |vim, cx| { + vim.update_active_editor(cx, |editor, cx| { + editor.move_selections(cx, |map, selection| { + let (new_head, goal) = motion.move_point(map, selection.head(), selection.goal); + let new_head = map.clip_at_line_end(new_head); + let was_reversed = selection.reversed; + selection.set_head(new_head, goal); + + if was_reversed && !selection.reversed { + // Head was at the start of the selection, and now is at the end. We need to move the start + // back by one if possible in order to compensate for this change. + *selection.start.column_mut() = selection.start.column().saturating_sub(1); + selection.start = map.clip_point(selection.start, Bias::Left); + } else if !was_reversed && selection.reversed { + // Head was at the end of the selection, and now is at the start. We need to move the end + // forward by one if possible in order to compensate for this change. + *selection.end.column_mut() = selection.end.column() + 1; + selection.end = map.clip_point(selection.end, Bias::Left); + } + }); + }); + }); +} + +pub fn change(_: &mut Workspace, _: &VisualChange, cx: &mut ViewContext) { + Vim::update(cx, |vim, cx| { + vim.update_active_editor(cx, |editor, cx| { + editor.set_clip_at_line_ends(false, cx); + editor.move_selections(cx, |map, selection| { + if !selection.reversed { + // Head was at the end of the selection, and now is at the start. We need to move the end + // forward by one if possible in order to compensate for this change. + *selection.end.column_mut() = selection.end.column() + 1; + selection.end = map.clip_point(selection.end, Bias::Left); + } + }); + editor.insert("", cx); + }); + vim.switch_mode(Mode::Insert, cx); + }); +} + +pub fn delete(_: &mut Workspace, _: &VisualDelete, cx: &mut ViewContext) { + Vim::update(cx, |vim, cx| { + vim.update_active_editor(cx, |editor, cx| { + editor.set_clip_at_line_ends(false, cx); + editor.move_selections(cx, |map, selection| { + if !selection.reversed { + // Head was at the end of the selection, and now is at the start. We need to move the end + // forward by one if possible in order to compensate for this change. + *selection.end.column_mut() = selection.end.column() + 1; + selection.end = map.clip_point(selection.end, Bias::Left); + } + }); + editor.insert("", cx); + }); + vim.switch_mode(Mode::Normal, cx); + }); +} + +#[cfg(test)] +mod test { + use indoc::indoc; + + use crate::{state::Mode, vim_test_context::VimTestContext}; + + #[gpui::test] + async fn test_enter_visual_mode(cx: &mut gpui::TestAppContext) { + let cx = VimTestContext::new(cx, true).await; + let mut cx = cx.binding(["v", "w", "j"]).mode_after(Mode::Visual); + cx.assert( + indoc! {" + The |quick brown + fox jumps over + the lazy dog"}, + indoc! {" + The [quick brown + fox jumps }over + the lazy dog"}, + ); + cx.assert( + indoc! {" + The quick brown + fox jumps over + the |lazy dog"}, + indoc! {" + The quick brown + fox jumps over + the [lazy }dog"}, + ); + cx.assert( + indoc! {" + The quick brown + fox jumps |over + the lazy dog"}, + indoc! {" + The quick brown + fox jumps [over + }the lazy dog"}, + ); + let mut cx = cx.binding(["v", "b", "k"]).mode_after(Mode::Visual); + cx.assert( + indoc! {" + The |quick brown + fox jumps over + the lazy dog"}, + indoc! {" + {The q]uick brown + fox jumps over + the lazy dog"}, + ); + cx.assert( + indoc! {" + The quick brown + fox jumps over + the |lazy dog"}, + indoc! {" + The quick brown + {fox jumps over + the l]azy dog"}, + ); + cx.assert( + indoc! {" + The quick brown + fox jumps |over + the lazy dog"}, + indoc! {" + The {quick brown + fox jumps o]ver + the lazy dog"}, + ); + } + + #[gpui::test] + async fn test_visual_delete(cx: &mut gpui::TestAppContext) { + let cx = VimTestContext::new(cx, true).await; + let mut cx = cx.binding(["v", "w", "x"]); + cx.assert("The quick |brown", "The quick| "); + let mut cx = cx.binding(["v", "w", "j", "x"]); + cx.assert( + indoc! {" + The |quick brown + fox jumps over + the lazy dog"}, + indoc! {" + The |ver + the lazy dog"}, + ); + cx.assert( + indoc! {" + The quick brown + fox jumps over + the |lazy dog"}, + indoc! {" + The quick brown + fox jumps over + the |og"}, + ); + cx.assert( + indoc! {" + The quick brown + fox jumps |over + the lazy dog"}, + indoc! {" + The quick brown + fox jumps |he lazy dog"}, + ); + let mut cx = cx.binding(["v", "b", "k", "x"]); + cx.assert( + indoc! {" + The |quick brown + fox jumps over + the lazy dog"}, + indoc! {" + |uick brown + fox jumps over + the lazy dog"}, + ); + cx.assert( + indoc! {" + The quick brown + fox jumps over + the |lazy dog"}, + indoc! {" + The quick brown + |azy dog"}, + ); + cx.assert( + indoc! {" + The quick brown + fox jumps |over + the lazy dog"}, + indoc! {" + The |ver + the lazy dog"}, + ); + } + + #[gpui::test] + async fn test_visual_change(cx: &mut gpui::TestAppContext) { + let cx = VimTestContext::new(cx, true).await; + let mut cx = cx.binding(["v", "w", "x"]).mode_after(Mode::Insert); + cx.assert("The quick |brown", "The quick |"); + let mut cx = cx.binding(["v", "w", "j", "c"]).mode_after(Mode::Insert); + cx.assert( + indoc! {" + The |quick brown + fox jumps over + the lazy dog"}, + indoc! {" + The |ver + the lazy dog"}, + ); + cx.assert( + indoc! {" + The quick brown + fox jumps over + the |lazy dog"}, + indoc! {" + The quick brown + fox jumps over + the |og"}, + ); + cx.assert( + indoc! {" + The quick brown + fox jumps |over + the lazy dog"}, + indoc! {" + The quick brown + fox jumps |he lazy dog"}, + ); + let mut cx = cx.binding(["v", "b", "k", "c"]).mode_after(Mode::Insert); + cx.assert( + indoc! {" + The |quick brown + fox jumps over + the lazy dog"}, + indoc! {" + |uick brown + fox jumps over + the lazy dog"}, + ); + cx.assert( + indoc! {" + The quick brown + fox jumps over + the |lazy dog"}, + indoc! {" + The quick brown + |azy dog"}, + ); + cx.assert( + indoc! {" + The quick brown + fox jumps |over + the lazy dog"}, + indoc! {" + The |ver + the lazy dog"}, + ); + } +}