vim: Respect count for paragraphs (#33489)

Rift , Rift , and Conrad Irwin created

Closes #32462 

Release Notes:

- vim: Paragraph objects now support counts (`d2ap`, `v2ap`, etc.)

---------

Co-authored-by: Rift <no@e.mail>
Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>

Change summary

crates/vim/src/command.rs                             |  2 
crates/vim/src/indent.rs                              |  3 
crates/vim/src/normal.rs                              | 43 ++++++---
crates/vim/src/normal/change.rs                       |  3 
crates/vim/src/normal/convert.rs                      |  3 
crates/vim/src/normal/delete.rs                       |  3 
crates/vim/src/normal/paste.rs                        |  2 
crates/vim/src/normal/toggle_comments.rs              |  3 
crates/vim/src/normal/yank.rs                         |  3 
crates/vim/src/object.rs                              | 54 +++++++-----
crates/vim/src/replace.rs                             |  2 
crates/vim/src/rewrap.rs                              |  3 
crates/vim/src/surrounds.rs                           | 14 ++
crates/vim/src/test.rs                                | 40 +++++++++
crates/vim/src/visual.rs                              | 34 +++++++
crates/vim/test_data/test_paragraph_multi_delete.json | 18 ++++
crates/vim/test_data/test_v2ap.json                   |  6 +
17 files changed, 182 insertions(+), 54 deletions(-)

Detailed changes

crates/vim/src/command.rs ๐Ÿ”—

@@ -1609,7 +1609,7 @@ impl Vim {
             let snapshot = editor.snapshot(window, cx);
             let start = editor.selections.newest_display(cx);
             let range = object
-                .range(&snapshot, start.clone(), around)
+                .range(&snapshot, start.clone(), around, None)
                 .unwrap_or(start.range());
             if range.start != start.start {
                 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {

crates/vim/src/indent.rs ๐Ÿ”—

@@ -122,6 +122,7 @@ impl Vim {
         object: Object,
         around: bool,
         dir: IndentDirection,
+        times: Option<usize>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
@@ -133,7 +134,7 @@ impl Vim {
                     s.move_with(|map, selection| {
                         let anchor = map.display_point_to_anchor(selection.head(), Bias::Right);
                         original_positions.insert(selection.id, anchor);
-                        object.expand_selection(map, selection, around);
+                        object.expand_selection(map, selection, around, times);
                     });
                 });
                 match dir {

crates/vim/src/normal.rs ๐Ÿ”—

@@ -277,40 +277,51 @@ impl Vim {
         self.exit_temporary_normal(window, cx);
     }
 
-    pub fn normal_object(&mut self, object: Object, window: &mut Window, cx: &mut Context<Self>) {
+    pub fn normal_object(
+        &mut self,
+        object: Object,
+        times: Option<usize>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
         let mut waiting_operator: Option<Operator> = None;
         match self.maybe_pop_operator() {
             Some(Operator::Object { around }) => match self.maybe_pop_operator() {
-                Some(Operator::Change) => self.change_object(object, around, window, cx),
-                Some(Operator::Delete) => self.delete_object(object, around, window, cx),
-                Some(Operator::Yank) => self.yank_object(object, around, window, cx),
+                Some(Operator::Change) => self.change_object(object, around, times, window, cx),
+                Some(Operator::Delete) => self.delete_object(object, around, times, window, cx),
+                Some(Operator::Yank) => self.yank_object(object, around, times, window, cx),
                 Some(Operator::Indent) => {
-                    self.indent_object(object, around, IndentDirection::In, window, cx)
+                    self.indent_object(object, around, IndentDirection::In, times, window, cx)
                 }
                 Some(Operator::Outdent) => {
-                    self.indent_object(object, around, IndentDirection::Out, window, cx)
+                    self.indent_object(object, around, IndentDirection::Out, times, window, cx)
                 }
                 Some(Operator::AutoIndent) => {
-                    self.indent_object(object, around, IndentDirection::Auto, window, cx)
+                    self.indent_object(object, around, IndentDirection::Auto, times, window, cx)
                 }
                 Some(Operator::ShellCommand) => {
                     self.shell_command_object(object, around, window, cx);
                 }
-                Some(Operator::Rewrap) => self.rewrap_object(object, around, window, cx),
+                Some(Operator::Rewrap) => self.rewrap_object(object, around, times, window, cx),
                 Some(Operator::Lowercase) => {
-                    self.convert_object(object, around, ConvertTarget::LowerCase, window, cx)
+                    self.convert_object(object, around, ConvertTarget::LowerCase, times, window, cx)
                 }
                 Some(Operator::Uppercase) => {
-                    self.convert_object(object, around, ConvertTarget::UpperCase, window, cx)
-                }
-                Some(Operator::OppositeCase) => {
-                    self.convert_object(object, around, ConvertTarget::OppositeCase, window, cx)
+                    self.convert_object(object, around, ConvertTarget::UpperCase, times, window, cx)
                 }
+                Some(Operator::OppositeCase) => self.convert_object(
+                    object,
+                    around,
+                    ConvertTarget::OppositeCase,
+                    times,
+                    window,
+                    cx,
+                ),
                 Some(Operator::Rot13) => {
-                    self.convert_object(object, around, ConvertTarget::Rot13, window, cx)
+                    self.convert_object(object, around, ConvertTarget::Rot13, times, window, cx)
                 }
                 Some(Operator::Rot47) => {
-                    self.convert_object(object, around, ConvertTarget::Rot47, window, cx)
+                    self.convert_object(object, around, ConvertTarget::Rot47, times, window, cx)
                 }
                 Some(Operator::AddSurrounds { target: None }) => {
                     waiting_operator = Some(Operator::AddSurrounds {
@@ -318,7 +329,7 @@ impl Vim {
                     });
                 }
                 Some(Operator::ToggleComments) => {
-                    self.toggle_comments_object(object, around, window, cx)
+                    self.toggle_comments_object(object, around, times, window, cx)
                 }
                 Some(Operator::ReplaceWithRegister) => {
                     self.replace_with_register_object(object, around, window, cx)

crates/vim/src/normal/change.rs ๐Ÿ”—

@@ -105,6 +105,7 @@ impl Vim {
         &mut self,
         object: Object,
         around: bool,
+        times: Option<usize>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
@@ -115,7 +116,7 @@ impl Vim {
             editor.transact(window, cx, |editor, window, cx| {
                 editor.change_selections(Default::default(), window, cx, |s| {
                     s.move_with(|map, selection| {
-                        objects_found |= object.expand_selection(map, selection, around);
+                        objects_found |= object.expand_selection(map, selection, around, times);
                     });
                 });
                 if objects_found {

crates/vim/src/normal/convert.rs ๐Ÿ”—

@@ -82,6 +82,7 @@ impl Vim {
         object: Object,
         around: bool,
         mode: ConvertTarget,
+        times: Option<usize>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
@@ -92,7 +93,7 @@ impl Vim {
                 let mut original_positions: HashMap<_, _> = Default::default();
                 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
                     s.move_with(|map, selection| {
-                        object.expand_selection(map, selection, around);
+                        object.expand_selection(map, selection, around, times);
                         original_positions.insert(
                             selection.id,
                             map.display_point_to_anchor(selection.start, Bias::Left),

crates/vim/src/normal/delete.rs ๐Ÿ”—

@@ -91,6 +91,7 @@ impl Vim {
         &mut self,
         object: Object,
         around: bool,
+        times: Option<usize>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
@@ -103,7 +104,7 @@ impl Vim {
                 let mut should_move_to_start: HashSet<_> = Default::default();
                 editor.change_selections(Default::default(), window, cx, |s| {
                     s.move_with(|map, selection| {
-                        object.expand_selection(map, selection, around);
+                        object.expand_selection(map, selection, around, times);
                         let offset_range = selection.map(|p| p.to_offset(map, Bias::Left)).range();
                         let mut move_selection_start_to_previous_line =
                             |map: &DisplaySnapshot, selection: &mut Selection<DisplayPoint>| {

crates/vim/src/normal/paste.rs ๐Ÿ”—

@@ -240,7 +240,7 @@ impl Vim {
                 editor.set_clip_at_line_ends(false, cx);
                 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
                     s.move_with(|map, selection| {
-                        object.expand_selection(map, selection, around);
+                        object.expand_selection(map, selection, around, None);
                     });
                 });
 

crates/vim/src/normal/toggle_comments.rs ๐Ÿ”—

@@ -46,6 +46,7 @@ impl Vim {
         &mut self,
         object: Object,
         around: bool,
+        times: Option<usize>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
@@ -57,7 +58,7 @@ impl Vim {
                     s.move_with(|map, selection| {
                         let anchor = map.display_point_to_anchor(selection.head(), Bias::Right);
                         original_positions.insert(selection.id, anchor);
-                        object.expand_selection(map, selection, around);
+                        object.expand_selection(map, selection, around, times);
                     });
                 });
                 editor.toggle_comments(&Default::default(), window, cx);

crates/vim/src/normal/yank.rs ๐Ÿ”—

@@ -66,6 +66,7 @@ impl Vim {
         &mut self,
         object: Object,
         around: bool,
+        times: Option<usize>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
@@ -75,7 +76,7 @@ impl Vim {
                 let mut start_positions: HashMap<_, _> = Default::default();
                 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
                     s.move_with(|map, selection| {
-                        object.expand_selection(map, selection, around);
+                        object.expand_selection(map, selection, around, times);
                         let start_position = (selection.start, selection.goal);
                         start_positions.insert(selection.id, start_position);
                     });

crates/vim/src/object.rs ๐Ÿ”—

@@ -373,10 +373,12 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
 
 impl Vim {
     fn object(&mut self, object: Object, window: &mut Window, cx: &mut Context<Self>) {
+        let count = Self::take_count(cx);
+
         match self.mode {
-            Mode::Normal => self.normal_object(object, window, cx),
+            Mode::Normal => self.normal_object(object, count, window, cx),
             Mode::Visual | Mode::VisualLine | Mode::VisualBlock => {
-                self.visual_object(object, window, cx)
+                self.visual_object(object, count, window, cx)
             }
             Mode::Insert | Mode::Replace | Mode::HelixNormal => {
                 // Shouldn't execute a text object in insert mode. Ignoring
@@ -485,6 +487,7 @@ impl Object {
         map: &DisplaySnapshot,
         selection: Selection<DisplayPoint>,
         around: bool,
+        times: Option<usize>,
     ) -> Option<Range<DisplayPoint>> {
         let relative_to = selection.head();
         match self {
@@ -503,7 +506,8 @@ impl Object {
                 }
             }
             Object::Sentence => sentence(map, relative_to, around),
-            Object::Paragraph => paragraph(map, relative_to, around),
+            //change others later
+            Object::Paragraph => paragraph(map, relative_to, around, times.unwrap_or(1)),
             Object::Quotes => {
                 surrounding_markers(map, relative_to, around, self.is_multiline(), '\'', '\'')
             }
@@ -692,8 +696,9 @@ impl Object {
         map: &DisplaySnapshot,
         selection: &mut Selection<DisplayPoint>,
         around: bool,
+        times: Option<usize>,
     ) -> bool {
-        if let Some(range) = self.range(map, selection.clone(), around) {
+        if let Some(range) = self.range(map, selection.clone(), around, times) {
             selection.start = range.start;
             selection.end = range.end;
             true
@@ -1399,30 +1404,37 @@ fn paragraph(
     map: &DisplaySnapshot,
     relative_to: DisplayPoint,
     around: bool,
+    times: usize,
 ) -> Option<Range<DisplayPoint>> {
     let mut paragraph_start = start_of_paragraph(map, relative_to);
     let mut paragraph_end = end_of_paragraph(map, relative_to);
 
-    let paragraph_end_row = paragraph_end.row();
-    let paragraph_ends_with_eof = paragraph_end_row == map.max_point().row();
-    let point = relative_to.to_point(map);
-    let current_line_is_empty = map.buffer_snapshot.is_line_blank(MultiBufferRow(point.row));
+    for i in 0..times {
+        let paragraph_end_row = paragraph_end.row();
+        let paragraph_ends_with_eof = paragraph_end_row == map.max_point().row();
+        let point = relative_to.to_point(map);
+        let current_line_is_empty = map.buffer_snapshot.is_line_blank(MultiBufferRow(point.row));
 
-    if around {
-        if paragraph_ends_with_eof {
-            if current_line_is_empty {
-                return None;
-            }
+        if around {
+            if paragraph_ends_with_eof {
+                if current_line_is_empty {
+                    return None;
+                }
 
-            let paragraph_start_row = paragraph_start.row();
-            if paragraph_start_row.0 != 0 {
-                let previous_paragraph_last_line_start =
-                    DisplayPoint::new(paragraph_start_row - 1, 0);
-                paragraph_start = start_of_paragraph(map, previous_paragraph_last_line_start);
+                let paragraph_start_row = paragraph_start.row();
+                if paragraph_start_row.0 != 0 {
+                    let previous_paragraph_last_line_start =
+                        Point::new(paragraph_start_row.0 - 1, 0).to_display_point(map);
+                    paragraph_start = start_of_paragraph(map, previous_paragraph_last_line_start);
+                }
+            } else {
+                let mut start_row = paragraph_end_row.0 + 1;
+                if i > 0 {
+                    start_row += 1;
+                }
+                let next_paragraph_start = Point::new(start_row, 0).to_display_point(map);
+                paragraph_end = end_of_paragraph(map, next_paragraph_start);
             }
-        } else {
-            let next_paragraph_start = DisplayPoint::new(paragraph_end_row + 1, 0);
-            paragraph_end = end_of_paragraph(map, next_paragraph_start);
         }
     }
 

crates/vim/src/replace.rs ๐Ÿ”—

@@ -144,7 +144,7 @@ impl Vim {
             editor.set_clip_at_line_ends(false, cx);
             let mut selection = editor.selections.newest_display(cx);
             let snapshot = editor.snapshot(window, cx);
-            object.expand_selection(&snapshot, &mut selection, around);
+            object.expand_selection(&snapshot, &mut selection, around, None);
             let start = snapshot
                 .buffer_snapshot
                 .anchor_before(selection.start.to_point(&snapshot));

crates/vim/src/rewrap.rs ๐Ÿ”—

@@ -89,6 +89,7 @@ impl Vim {
         &mut self,
         object: Object,
         around: bool,
+        times: Option<usize>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
@@ -100,7 +101,7 @@ impl Vim {
                     s.move_with(|map, selection| {
                         let anchor = map.display_point_to_anchor(selection.head(), Bias::Right);
                         original_positions.insert(selection.id, anchor);
-                        object.expand_selection(map, selection, around);
+                        object.expand_selection(map, selection, around, times);
                     });
                 });
                 editor.rewrap_impl(

crates/vim/src/surrounds.rs ๐Ÿ”—

@@ -52,7 +52,7 @@ impl Vim {
                 for selection in &display_selections {
                     let range = match &target {
                         SurroundsType::Object(object, around) => {
-                            object.range(&display_map, selection.clone(), *around)
+                            object.range(&display_map, selection.clone(), *around, None)
                         }
                         SurroundsType::Motion(motion) => {
                             motion
@@ -150,7 +150,9 @@ impl Vim {
 
                 for selection in &display_selections {
                     let start = selection.start.to_offset(&display_map, Bias::Left);
-                    if let Some(range) = pair_object.range(&display_map, selection.clone(), true) {
+                    if let Some(range) =
+                        pair_object.range(&display_map, selection.clone(), true, None)
+                    {
                         // If the current parenthesis object is single-line,
                         // then we need to filter whether it is the current line or not
                         if !pair_object.is_multiline() {
@@ -247,7 +249,9 @@ impl Vim {
 
                     for selection in &selections {
                         let start = selection.start.to_offset(&display_map, Bias::Left);
-                        if let Some(range) = target.range(&display_map, selection.clone(), true) {
+                        if let Some(range) =
+                            target.range(&display_map, selection.clone(), true, None)
+                        {
                             if !target.is_multiline() {
                                 let is_same_row = selection.start.row() == range.start.row()
                                     && selection.end.row() == range.end.row();
@@ -348,7 +352,9 @@ impl Vim {
 
                     for selection in &selections {
                         let start = selection.start.to_offset(&display_map, Bias::Left);
-                        if let Some(range) = object.range(&display_map, selection.clone(), true) {
+                        if let Some(range) =
+                            object.range(&display_map, selection.clone(), true, None)
+                        {
                             // If the current parenthesis object is single-line,
                             // then we need to filter whether it is the current line or not
                             if object.is_multiline()

crates/vim/src/test.rs ๐Ÿ”—

@@ -2031,3 +2031,43 @@ async fn test_delete_unmatched_brace(cx: &mut gpui::TestAppContext) {
         .await
         .assert_eq("  oth(wow)\n  oth(wow)\n");
 }
+
+#[gpui::test]
+async fn test_paragraph_multi_delete(cx: &mut gpui::TestAppContext) {
+    let mut cx = NeovimBackedTestContext::new(cx).await;
+    cx.set_shared_state(indoc! {
+        "
+        Emacs is
+        ห‡a great
+
+        operating system
+
+        all it lacks
+        is a
+
+        decent text editor
+        "
+    })
+    .await;
+
+    cx.simulate_shared_keystrokes("2 d a p").await;
+    cx.shared_state().await.assert_eq(indoc! {
+        "
+        ห‡all it lacks
+        is a
+
+        decent text editor
+        "
+    });
+
+    cx.simulate_shared_keystrokes("d a p").await;
+    cx.shared_clipboard()
+        .await
+        .assert_eq("all it lacks\nis a\n\n");
+
+    //reset to initial state
+    cx.simulate_shared_keystrokes("2 u").await;
+
+    cx.simulate_shared_keystrokes("4 d a p").await;
+    cx.shared_state().await.assert_eq(indoc! {"ห‡"});
+}

crates/vim/src/visual.rs ๐Ÿ”—

@@ -364,7 +364,13 @@ impl Vim {
         })
     }
 
-    pub fn visual_object(&mut self, object: Object, window: &mut Window, cx: &mut Context<Vim>) {
+    pub fn visual_object(
+        &mut self,
+        object: Object,
+        count: Option<usize>,
+        window: &mut Window,
+        cx: &mut Context<Vim>,
+    ) {
         if let Some(Operator::Object { around }) = self.active_operator() {
             self.pop_operator(window, cx);
             let current_mode = self.mode;
@@ -390,7 +396,7 @@ impl Vim {
                             );
                         }
 
-                        if let Some(range) = object.range(map, mut_selection, around) {
+                        if let Some(range) = object.range(map, mut_selection, around, count) {
                             if !range.is_empty() {
                                 let expand_both_ways = object.always_expands_both_ways()
                                     || selection.is_empty()
@@ -402,7 +408,7 @@ impl Vim {
                                         && object.always_expands_both_ways()
                                     {
                                         if let Some(range) =
-                                            object.range(map, selection.clone(), around)
+                                            object.range(map, selection.clone(), around, count)
                                         {
                                             selection.start = range.start;
                                             selection.end = range.end;
@@ -1761,4 +1767,26 @@ mod test {
         });
         cx.shared_clipboard().await.assert_eq("quick\n");
     }
+
+    #[gpui::test]
+    async fn test_v2ap(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.set_shared_state(indoc! {
+            "The
+            quicห‡k
+
+            brown
+            fox"
+        })
+        .await;
+        cx.simulate_shared_keystrokes("v 2 a p").await;
+        cx.shared_state().await.assert_eq(indoc! {
+            "ยซThe
+            quick
+
+            brown
+            fห‡ยปox"
+        });
+    }
 }

crates/vim/test_data/test_paragraph_multi_delete.json ๐Ÿ”—

@@ -0,0 +1,18 @@
+{"Put":{"state":"Emacs is\nห‡a great\n\noperating system\n\nall it lacks\nis a\n\ndecent text editor\n"}}
+{"Key":"2"}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"p"}
+{"Get":{"state":"ห‡all it lacks\nis a\n\ndecent text editor\n","mode":"Normal"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"p"}
+{"Get":{"state":"ห‡decent text editor\n","mode":"Normal"}}
+{"ReadRegister":{"name":"\"","value":"all it lacks\nis a\n\n"}}
+{"Key":"2"}
+{"Key":"u"}
+{"Key":"4"}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"p"}
+{"Get":{"state":"ห‡","mode":"Normal"}}

crates/vim/test_data/test_v2ap.json ๐Ÿ”—

@@ -0,0 +1,6 @@
+{"Put":{"state":"The\nquicห‡k\n\nbrown\nfox"}}
+{"Key":"v"}
+{"Key":"2"}
+{"Key":"a"}
+{"Key":"p"}
+{"Get":{"state":"ยซThe\nquick\n\nbrown\nfห‡ยปox","mode":"VisualLine"}}