Correct other end visual block functionality (#27678)

Peter Finn and KyleBarton created

Closes #27385

Builds on #27604 so that `vim::OtherEnd` works in visual block mode.
This is accomplished by reversing the order of active selections in the
buffer when the user hit `o`, so that the cursor moves diagonally across
the selection. The current behavior is preserved for `shift-o`, which is
how the cursors behave in vim.

We'll close #27604 since this encapsulates that change, but if you'd
prefer to take only the visual block motion component, we'll keep the
branch for #27604 open.

Test case: growing a box down and to the right, other ending, followed
by growing and shrinking the box:


https://github.com/user-attachments/assets/1df544e1-efce-4354-b354-bbfec007a7df

Test case: growing a box up and to the left, other ending, followed by
growing and shrinking the box:


https://github.com/user-attachments/assets/2f6d7729-c63a-4486-960b-23474c2e507a



Release Notes:
- Improved visual block mode when cursor is at beginning of selection
- Improved visual block mode so that `o` and `shift-o` reach parity with
vim

---------

Co-authored-by: KyleBarton <kjbarton4@gmail.com>

Change summary

assets/keymaps/vim.json                                          |   2 
crates/editor/src/selections_collection.rs                       |  19 
crates/vim/README.md                                             |   2 
crates/vim/src/visual.rs                                         | 108 +
crates/vim/test_data/test_visual_block_mode_down_right.json      |   9 
crates/vim/test_data/test_visual_block_mode_other_end.json       |  11 
crates/vim/test_data/test_visual_block_mode_shift_other_end.json |  11 
crates/vim/test_data/test_visual_block_mode_up_left.json         |   9 
8 files changed, 165 insertions(+), 6 deletions(-)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -258,7 +258,7 @@
       "u": "vim::ConvertToLowerCase",
       "shift-u": "vim::ConvertToUpperCase",
       "shift-o": "vim::OtherEnd",
-      "o": "vim::OtherEnd",
+      "o": "vim::OtherEndRowAware",
       "d": "vim::VisualDelete",
       "x": "vim::VisualDelete",
       "shift-d": "vim::VisualDeleteLine",

crates/editor/src/selections_collection.rs 🔗

@@ -655,6 +655,25 @@ impl<'a> MutableSelectionsCollection<'a> {
             .collect();
         self.select(selections);
     }
+    pub fn reverse_selections(&mut self) {
+        let map = &self.display_map();
+        let mut new_selections: Vec<Selection<Point>> = Vec::new();
+        let disjoint = self.disjoint.clone();
+        for selection in disjoint
+            .iter()
+            .sorted_by(|first, second| Ord::cmp(&second.id, &first.id))
+            .collect::<Vec<&Selection<Anchor>>>()
+        {
+            new_selections.push(Selection {
+                id: self.new_selection_id(),
+                start: selection.start.to_display_point(map).to_point(map),
+                end: selection.end.to_display_point(map).to_point(map),
+                reversed: selection.reversed,
+                goal: selection.goal,
+            });
+        }
+        self.select(new_selections);
+    }
 
     pub fn move_with(
         &mut self,

crates/vim/README.md 🔗

@@ -28,7 +28,7 @@ but while developing this test you'll need to run it with the neovim flag enable
 cargo test -p vim --features neovim test_visual_star_hash
 ```
 
-This will run your keystrokes against a headless neovim and cache the results in the test_data directory.
+This will run your keystrokes against a headless neovim and cache the results in the test_data directory. Note that neovim must be installed and reachable on your $PATH in order to run the feature.
 
 
 ## Testing zed-only behavior

crates/vim/src/visual.rs 🔗

@@ -32,6 +32,7 @@ actions!(
         VisualYank,
         VisualYankLine,
         OtherEnd,
+        OtherEndRowAware,
         SelectNext,
         SelectPrevious,
         SelectNextMatch,
@@ -55,6 +56,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
         vim.toggle_mode(Mode::VisualBlock, window, cx)
     });
     Vim::action(editor, cx, Vim::other_end);
+    Vim::action(editor, cx, Vim::other_end_row_aware);
     Vim::action(editor, cx, Vim::visual_insert_end_of_line);
     Vim::action(editor, cx, Vim::visual_insert_first_non_white_space);
     Vim::action(editor, cx, |vim, _: &VisualDelete, window, cx| {
@@ -265,7 +267,16 @@ impl Vim {
                 head = movement::saturating_left(map, head);
             }
 
-            let Some((new_head, _)) = move_selection(map, head, goal) else {
+            let reverse_aware_goal = if was_reversed {
+                SelectionGoal::HorizontalRange {
+                    start: end,
+                    end: start,
+                }
+            } else {
+                goal
+            };
+
+            let Some((new_head, _)) = move_selection(map, head, reverse_aware_goal) else {
                 return;
             };
             head = new_head;
@@ -321,7 +332,9 @@ impl Vim {
                         id: s.new_selection_id(),
                         start: start.to_point(map),
                         end: end.to_point(map),
-                        reversed: is_reversed,
+                        reversed: is_reversed &&
+                                    // For neovim parity: cursor is not reversed when column is a single character
+                                    end.column() - start.column() > 1,
                         goal,
                     };
 
@@ -336,7 +349,6 @@ impl Vim {
                     row.0 += 1
                 }
             }
-
             s.select(selections);
         })
     }
@@ -462,7 +474,26 @@ impl Vim {
             editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
                 s.move_with(|_, selection| {
                     selection.reversed = !selection.reversed;
-                })
+                });
+            })
+        });
+    }
+
+    pub fn other_end_row_aware(
+        &mut self,
+        _: &OtherEndRowAware,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let mode = self.mode;
+        self.update_editor(window, cx, |_, editor, window, cx| {
+            editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
+                s.move_with(|_, selection| {
+                    selection.reversed = !selection.reversed;
+                });
+                if mode == Mode::VisualBlock {
+                    s.reverse_selections();
+                }
             })
         });
     }
@@ -1214,6 +1245,75 @@ mod test {
             "
         });
     }
+    #[gpui::test]
+    async fn test_visual_block_mode_down_right(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+        cx.set_shared_state(indoc! {"
+            The ˇquick brown
+            fox jumps over
+            the lazy dog"})
+            .await;
+        cx.simulate_shared_keystrokes("ctrl-v l l l l l j").await;
+        cx.shared_state().await.assert_eq(indoc! {"
+            The «quick ˇ»brown
+            fox «jumps ˇ»over
+            the lazy dog"});
+    }
+
+    #[gpui::test]
+    async fn test_visual_block_mode_up_left(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+        cx.set_shared_state(indoc! {"
+            The quick brown
+            fox jumpsˇ over
+            the lazy dog"})
+            .await;
+        cx.simulate_shared_keystrokes("ctrl-v h h h h h k").await;
+        cx.shared_state().await.assert_eq(indoc! {"
+            The «ˇquick »brown
+            fox «ˇjumps »over
+            the lazy dog"});
+    }
+
+    #[gpui::test]
+    async fn test_visual_block_mode_other_end(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+        cx.set_shared_state(indoc! {"
+            The quick brown
+            fox jˇumps over
+            the lazy dog"})
+            .await;
+        cx.simulate_shared_keystrokes("ctrl-v l l l l j").await;
+        cx.shared_state().await.assert_eq(indoc! {"
+            The quick brown
+            fox j«umps ˇ»over
+            the l«azy dˇ»og"});
+        cx.simulate_shared_keystrokes("o k").await;
+        cx.shared_state().await.assert_eq(indoc! {"
+            The q«ˇuick »brown
+            fox j«ˇumps »over
+            the l«ˇazy d»og"});
+    }
+
+    #[gpui::test]
+    async fn test_visual_block_mode_shift_other_end(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+        cx.set_shared_state(indoc! {"
+            The quick brown
+            fox jˇumps over
+            the lazy dog"})
+            .await;
+        cx.simulate_shared_keystrokes("ctrl-v l l l l j").await;
+        cx.shared_state().await.assert_eq(indoc! {"
+            The quick brown
+            fox j«umps ˇ»over
+            the l«azy dˇ»og"});
+        cx.simulate_shared_keystrokes("shift-o k").await;
+        cx.shared_state().await.assert_eq(indoc! {"
+            The quick brown
+            fox j«ˇumps »over
+            the lazy dog"});
+    }
 
     #[gpui::test]
     async fn test_visual_block_insert(cx: &mut gpui::TestAppContext) {

crates/vim/test_data/test_visual_block_mode_down_right.json 🔗

@@ -0,0 +1,9 @@
+{"Put":{"state":"The ˇquick brown\nfox jumps over\nthe lazy dog"}}
+{"Key":"ctrl-v"}
+{"Key":"l"}
+{"Key":"l"}
+{"Key":"l"}
+{"Key":"l"}
+{"Key":"l"}
+{"Key":"j"}
+{"Get":{"state":"The «quick ˇ»brown\nfox «jumps ˇ»over\nthe lazy dog","mode":"VisualBlock"}}

crates/vim/test_data/test_visual_block_mode_other_end.json 🔗

@@ -0,0 +1,11 @@
+{"Put":{"state":"The quick brown\nfox jˇumps over\nthe lazy dog"}}
+{"Key":"ctrl-v"}
+{"Key":"l"}
+{"Key":"l"}
+{"Key":"l"}
+{"Key":"l"}
+{"Key":"j"}
+{"Get":{"state":"The quick brown\nfox j«umps ˇ»over\nthe l«azy dˇ»og","mode":"VisualBlock"}}
+{"Key":"o"}
+{"Key":"k"}
+{"Get":{"state":"The q«ˇuick »brown\nfox j«ˇumps »over\nthe l«ˇazy d»og","mode":"VisualBlock"}}

crates/vim/test_data/test_visual_block_mode_shift_other_end.json 🔗

@@ -0,0 +1,11 @@
+{"Put":{"state":"The quick brown\nfox jˇumps over\nthe lazy dog"}}
+{"Key":"ctrl-v"}
+{"Key":"l"}
+{"Key":"l"}
+{"Key":"l"}
+{"Key":"l"}
+{"Key":"j"}
+{"Get":{"state":"The quick brown\nfox j«umps ˇ»over\nthe l«azy dˇ»og","mode":"VisualBlock"}}
+{"Key":"shift-o"}
+{"Key":"k"}
+{"Get":{"state":"The quick brown\nfox j«ˇumps »over\nthe lazy dog","mode":"VisualBlock"}}

crates/vim/test_data/test_visual_block_mode_up_left.json 🔗

@@ -0,0 +1,9 @@
+{"Put":{"state":"The quick brown\nfox jumpsˇ over\nthe lazy dog"}}
+{"Key":"ctrl-v"}
+{"Key":"h"}
+{"Key":"h"}
+{"Key":"h"}
+{"Key":"h"}
+{"Key":"h"}
+{"Key":"k"}
+{"Get":{"state":"The «ˇquick »brown\nfox «ˇjumps »over\nthe lazy dog","mode":"VisualBlock"}}