Initial visual mode

Keith Simmons created

Change summary

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 |  48 +++-
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, 619 insertions(+), 111 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -5555,6 +5555,7 @@ dependencies = [
  "editor",
  "gpui",
  "indoc",
+ "itertools",
  "language",
  "log",
  "project",

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": {

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<T>,

crates/text/src/selection.rs 🔗

@@ -85,6 +85,19 @@ impl<T: Copy + Ord> Selection<T> {
     }
 }
 
+impl Selection<usize> {
+    #[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<Anchor> {
     pub fn resolve<'a, D: 'a + TextDimension>(
         &'a self,

crates/util/src/test/assertions.rs 🔗

@@ -1,19 +1,62 @@
+pub enum SetEqError<T> {
+    LeftMissing(T),
+    RightMissing(T),
+}
+
+impl<T> SetEqError<T> {
+    pub fn map<R, F: FnOnce(T) -> R>(self, update: F) -> SetEqError<R> {
+        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);
+            },
+            _ => {}
+        }
     }};
 }

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<usize>) {
     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(marked_text: &str) -> (String, Vec<Range<usize>>) {
-    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
+pub fn marked_text_ranges_by(
+    marked_text: &str,
+    delimiters: Vec<(char, char)>,
+) -> (String, HashMap<(char, char), Vec<Range<usize>>>) {
+    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()
-        .zip(closes)
-        .map(|(open, close)| {
-            assert!(close >= open, "marked ranges must be disjoint");
-            open..close
+        .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::<Vec<Range<usize>>>();
+            ((start_marker, end_marker), ranges)
         })
         .collect();
-    (unmarked_text, ranges)
+
+    (unmarked_text, range_lookup)
+}
+
+pub fn marked_text_ranges(marked_text: &str) -> (String, Vec<Range<usize>>) {
+    let (unmarked_text, mut ranges) = marked_text_ranges_by(marked_text, vec![('[', ']')]);
+    (
+        unmarked_text,
+        ranges.remove(&('[', ']')).unwrap_or_else(Vec::new),
+    )
 }

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]

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
         }

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!();"},
         );
     }
 

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(),

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

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<DisplayPoint> {
-        self.editor.update(self.cx, |editor, cx| {
-            let snapshot = editor.snapshot(cx);
-            editor
-                .newest_selection::<Point>(cx)
-                .map(|point| point.to_display_point(&snapshot.display_snapshot))
-        })
-    }
-
     pub fn mode(&mut self) -> Mode {
         self.cx.read(|cx| cx.global::<Vim>().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<Selection<usize>>) {
+        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<usize>,
+        expected_reverse_selections: Vec<Range<usize>>,
+        expected_forward_selections: Vec<Range<usize>>,
+        asserted_text: Option<String>,
+    ) {
+        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::<usize>(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<String>> =
+            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<usize>,
+        reverse_selections: &Vec<Range<usize>>,
+        forward_selections: &Vec<Range<usize>>,
+    ) -> 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<const COUNT: usize>(
@@ -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,
         )

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<Workspace>) {
+    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<Workspace>) {
+    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"},
+        );
+    }
+}