vim: Add support for moving to first, middle and last visible lines (`H`, `L`, `M`) (#6919)

Vishal Bhavsar created

This change implements the vim
[motion](https://github.com/vim/vim/blob/master/runtime/doc/motion.txt)
commands to move the cursor to the top, middle and bottom of the visible
view. This feature is requested in
https://github.com/zed-industries/zed/issues/4941.

This change takes inspiration from
[crates/vim/src/normal/scroll.rs](https://github.com/zed-industries/zed/blob/main/crates/vim/src/normal/scroll.rs).

A note on the behavior of these commands: Because
`NeovimBackedTestContext` requires compatibility with nvim, the current
implementation causes slightly non-standard behavior: it causes the
editor to scroll a few lines. The standard behavior causes no scrolling.
It is easy enough to account for the margin by adding
`VERTICAL_SCROLL_MARGIN`. However, doing so will cause test failures due
to the disparity between nvim and zed states. Perhaps
`NeovimBackedTestContext` should have a switch to be more tolerant for
such cases.

Release Notes:

- Added support for moving to top, middle and bottom of the screen in
vim mode (`H`, `M`, and `L`)
([#4941](https://github.com/zed-industries/zed/issues/4941)).

Change summary

assets/keymaps/vim.json                      |   3 
crates/editor/src/display_map.rs             |   2 
crates/editor/src/editor.rs                  |   2 
crates/editor/src/movement.rs                |   4 
crates/editor/src/scroll.rs                  |   2 
crates/vim/src/motion.rs                     | 284 ++++++++++++++++++++++
crates/vim/test_data/test_window_bottom.json |  15 +
crates/vim/test_data/test_window_middle.json |  17 +
crates/vim/test_data/test_window_top.json    |   9 
9 files changed, 337 insertions(+), 1 deletion(-)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -341,6 +341,9 @@
       "shift-s": "vim::SubstituteLine",
       "> >": "editor::Indent",
       "< <": "editor::Outdent",
+      "shift-h": "vim::WindowTop",
+      "shift-m": "vim::WindowMiddle",
+      "shift-l": "vim::WindowBottom",
       "ctrl-pagedown": "pane::ActivateNextItem",
       "ctrl-pageup": "pane::ActivatePrevItem"
     }

crates/editor/src/display_map.rs 🔗

@@ -586,6 +586,8 @@ impl DisplaySnapshot {
             text_system,
             editor_style,
             rem_size,
+            anchor: _,
+            visible_rows: _,
         }: &TextLayoutDetails,
     ) -> Arc<LineLayout> {
         let mut runs = Vec::new();

crates/editor/src/editor.rs 🔗

@@ -3052,6 +3052,8 @@ impl Editor {
             text_system: cx.text_system().clone(),
             editor_style: self.style.clone().unwrap(),
             rem_size: cx.rem_size(),
+            anchor: self.scroll_manager.anchor().anchor,
+            visible_rows: self.visible_line_count(),
         }
     }
 

crates/editor/src/movement.rs 🔗

@@ -8,6 +8,8 @@ use language::Point;
 
 use std::{ops::Range, sync::Arc};
 
+use multi_buffer::Anchor;
+
 /// Defines search strategy for items in `movement` module.
 /// `FindRange::SingeLine` only looks for a match on a single line at a time, whereas
 /// `FindRange::MultiLine` keeps going until the end of a string.
@@ -23,6 +25,8 @@ pub struct TextLayoutDetails {
     pub(crate) text_system: Arc<TextSystem>,
     pub(crate) editor_style: EditorStyle,
     pub(crate) rem_size: Pixels,
+    pub anchor: Anchor,
+    pub visible_rows: Option<f32>,
 }
 
 /// Returns a column to the left of the current point, wrapping

crates/editor/src/scroll.rs 🔗

@@ -299,7 +299,7 @@ impl ScrollManager {
 }
 
 impl Editor {
-    pub fn vertical_scroll_margin(&mut self) -> usize {
+    pub fn vertical_scroll_margin(&self) -> usize {
         self.scroll_manager.vertical_scroll_margin as usize
     }
 

crates/vim/src/motion.rs 🔗

@@ -41,6 +41,9 @@ pub enum Motion {
     StartOfLineDownward,
     EndOfLineDownward,
     GoToColumn,
+    WindowTop,
+    WindowMiddle,
+    WindowBottom,
 }
 
 #[derive(Clone, Deserialize, PartialEq)]
@@ -136,6 +139,9 @@ actions!(
         StartOfLineDownward,
         EndOfLineDownward,
         GoToColumn,
+        WindowTop,
+        WindowMiddle,
+        WindowBottom,
     ]
 );
 
@@ -231,6 +237,13 @@ pub fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
     workspace.register_action(|_: &mut Workspace, action: &RepeatFind, cx: _| {
         repeat_motion(action.backwards, cx)
     });
+    workspace.register_action(|_: &mut Workspace, &WindowTop, cx: _| motion(Motion::WindowTop, cx));
+    workspace.register_action(|_: &mut Workspace, &WindowMiddle, cx: _| {
+        motion(Motion::WindowMiddle, cx)
+    });
+    workspace.register_action(|_: &mut Workspace, &WindowBottom, cx: _| {
+        motion(Motion::WindowBottom, cx)
+    });
 }
 
 pub(crate) fn motion(motion: Motion, cx: &mut WindowContext) {
@@ -295,6 +308,9 @@ impl Motion {
             | NextLineStart
             | StartOfLineDownward
             | StartOfParagraph
+            | WindowTop
+            | WindowMiddle
+            | WindowBottom
             | EndOfParagraph => true,
             EndOfLine { .. }
             | NextWordEnd { .. }
@@ -336,6 +352,9 @@ impl Motion {
             | PreviousWordStart { .. }
             | FirstNonWhitespace { .. }
             | FindBackward { .. }
+            | WindowTop
+            | WindowMiddle
+            | WindowBottom
             | NextLineStart => false,
         }
     }
@@ -353,6 +372,9 @@ impl Motion {
             | NextWordEnd { .. }
             | Matching
             | FindForward { .. }
+            | WindowTop
+            | WindowMiddle
+            | WindowBottom
             | NextLineStart => true,
             Left
             | Backspace
@@ -449,6 +471,9 @@ impl Motion {
             StartOfLineDownward => (next_line_start(map, point, times - 1), SelectionGoal::None),
             EndOfLineDownward => (next_line_end(map, point, times), SelectionGoal::None),
             GoToColumn => (go_to_column(map, point, times), SelectionGoal::None),
+            WindowTop => window_top(map, point, &text_layout_details),
+            WindowMiddle => window_middle(map, point, &text_layout_details),
+            WindowBottom => window_bottom(map, point, &text_layout_details),
         };
 
         (new_point != point || infallible).then_some((new_point, goal))
@@ -955,6 +980,51 @@ pub(crate) fn next_line_end(
     end_of_line(map, false, point)
 }
 
+fn window_top(
+    map: &DisplaySnapshot,
+    point: DisplayPoint,
+    text_layout_details: &TextLayoutDetails,
+) -> (DisplayPoint, SelectionGoal) {
+    let first_visible_line = text_layout_details.anchor.to_display_point(map);
+    let new_col = point.column().min(map.line_len(first_visible_line.row()));
+    let new_point = DisplayPoint::new(first_visible_line.row(), new_col);
+    (map.clip_point(new_point, Bias::Left), SelectionGoal::None)
+}
+
+fn window_middle(
+    map: &DisplaySnapshot,
+    point: DisplayPoint,
+    text_layout_details: &TextLayoutDetails,
+) -> (DisplayPoint, SelectionGoal) {
+    if let Some(visible_rows) = text_layout_details.visible_rows {
+        let first_visible_line = text_layout_details.anchor.to_display_point(map);
+        let max_rows = (visible_rows as u32).min(map.max_buffer_row());
+        let new_row = first_visible_line.row() + (max_rows.div_euclid(2));
+        let new_col = point.column().min(map.line_len(new_row));
+        let new_point = DisplayPoint::new(new_row, new_col);
+        (map.clip_point(new_point, Bias::Left), SelectionGoal::None)
+    } else {
+        (point, SelectionGoal::None)
+    }
+}
+
+fn window_bottom(
+    map: &DisplaySnapshot,
+    point: DisplayPoint,
+    text_layout_details: &TextLayoutDetails,
+) -> (DisplayPoint, SelectionGoal) {
+    if let Some(visible_rows) = text_layout_details.visible_rows {
+        let first_visible_line = text_layout_details.anchor.to_display_point(map);
+        let bottom_row = first_visible_line.row() + (visible_rows) as u32;
+        let bottom_row_capped = bottom_row.min(map.max_buffer_row());
+        let new_col = point.column().min(map.line_len(bottom_row_capped));
+        let new_point = DisplayPoint::new(bottom_row_capped, new_col);
+        (map.clip_point(new_point, Bias::Left), SelectionGoal::None)
+    } else {
+        (point, SelectionGoal::None)
+    }
+}
+
 #[cfg(test)]
 mod test {
 
@@ -1107,4 +1177,218 @@ mod test {
         cx.simulate_shared_keystrokes(["enter"]).await;
         cx.assert_shared_state("one\n  ˇtwo\nthree").await;
     }
+
+    #[gpui::test]
+    async fn test_window_top(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+        let initial_state = indoc! {r"abc
+          def
+          paragraph
+          the second
+          third ˇand
+          final"};
+
+        cx.set_shared_state(initial_state).await;
+        cx.simulate_shared_keystrokes(["shift-h"]).await;
+        cx.assert_shared_state(indoc! {r"abˇc
+          def
+          paragraph
+          the second
+          third and
+          final"})
+            .await;
+
+        // clip point
+        cx.set_shared_state(indoc! {r"
+          1 2 3
+          4 5 6
+          7 8 ˇ9
+          "})
+            .await;
+        cx.simulate_shared_keystrokes(["shift-h"]).await;
+        cx.assert_shared_state(indoc! {r"
+          1 2 ˇ3
+          4 5 6
+          7 8 9
+          "})
+            .await;
+
+        cx.set_shared_state(indoc! {r"
+          1 2 3
+          4 5 6
+          ˇ7 8 9
+          "})
+            .await;
+        cx.simulate_shared_keystrokes(["shift-h"]).await;
+        cx.assert_shared_state(indoc! {r"
+          ˇ1 2 3
+          4 5 6
+          7 8 9
+          "})
+            .await;
+    }
+
+    #[gpui::test]
+    async fn test_window_middle(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+        let initial_state = indoc! {r"abˇc
+          def
+          paragraph
+          the second
+          third and
+          final"};
+
+        cx.set_shared_state(initial_state).await;
+        cx.simulate_shared_keystrokes(["shift-m"]).await;
+        cx.assert_shared_state(indoc! {r"abc
+          def
+          paˇragraph
+          the second
+          third and
+          final"})
+            .await;
+
+        cx.set_shared_state(indoc! {r"
+          1 2 3
+          4 5 6
+          7 8 ˇ9
+          "})
+            .await;
+        cx.simulate_shared_keystrokes(["shift-m"]).await;
+        cx.assert_shared_state(indoc! {r"
+          1 2 3
+          4 5 ˇ6
+          7 8 9
+          "})
+            .await;
+        cx.set_shared_state(indoc! {r"
+          1 2 3
+          4 5 6
+          ˇ7 8 9
+          "})
+            .await;
+        cx.simulate_shared_keystrokes(["shift-m"]).await;
+        cx.assert_shared_state(indoc! {r"
+          1 2 3
+          ˇ4 5 6
+          7 8 9
+          "})
+            .await;
+        cx.set_shared_state(indoc! {r"
+          ˇ1 2 3
+          4 5 6
+          7 8 9
+          "})
+            .await;
+        cx.simulate_shared_keystrokes(["shift-m"]).await;
+        cx.assert_shared_state(indoc! {r"
+          1 2 3
+          ˇ4 5 6
+          7 8 9
+          "})
+            .await;
+        cx.set_shared_state(indoc! {r"
+          1 2 3
+          ˇ4 5 6
+          7 8 9
+          "})
+            .await;
+        cx.simulate_shared_keystrokes(["shift-m"]).await;
+        cx.assert_shared_state(indoc! {r"
+          1 2 3
+          ˇ4 5 6
+          7 8 9
+          "})
+            .await;
+        cx.set_shared_state(indoc! {r"
+          1 2 3
+          4 5 ˇ6
+          7 8 9
+          "})
+            .await;
+        cx.simulate_shared_keystrokes(["shift-m"]).await;
+        cx.assert_shared_state(indoc! {r"
+          1 2 3
+          4 5 ˇ6
+          7 8 9
+          "})
+            .await;
+    }
+
+    #[gpui::test]
+    async fn test_window_bottom(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+        let initial_state = indoc! {r"abc
+          deˇf
+          paragraph
+          the second
+          third and
+          final"};
+
+        cx.set_shared_state(initial_state).await;
+        cx.simulate_shared_keystrokes(["shift-l"]).await;
+        cx.assert_shared_state(indoc! {r"abc
+          def
+          paragraph
+          the second
+          third and
+          fiˇnal"})
+            .await;
+
+        cx.set_shared_state(indoc! {r"
+          1 2 3
+          4 5 ˇ6
+          7 8 9
+          "})
+            .await;
+        cx.simulate_shared_keystrokes(["shift-l"]).await;
+        cx.assert_shared_state(indoc! {r"
+          1 2 3
+          4 5 6
+          7 8 9
+          ˇ"})
+            .await;
+
+        cx.set_shared_state(indoc! {r"
+          1 2 3
+          ˇ4 5 6
+          7 8 9
+          "})
+            .await;
+        cx.simulate_shared_keystrokes(["shift-l"]).await;
+        cx.assert_shared_state(indoc! {r"
+          1 2 3
+          4 5 6
+          7 8 9
+          ˇ"})
+            .await;
+
+        cx.set_shared_state(indoc! {r"
+          1 2 ˇ3
+          4 5 6
+          7 8 9
+          "})
+            .await;
+        cx.simulate_shared_keystrokes(["shift-l"]).await;
+        cx.assert_shared_state(indoc! {r"
+          1 2 3
+          4 5 6
+          7 8 9
+          ˇ"})
+            .await;
+
+        cx.set_shared_state(indoc! {r"
+          ˇ1 2 3
+          4 5 6
+          7 8 9
+          "})
+            .await;
+        cx.simulate_shared_keystrokes(["shift-l"]).await;
+        cx.assert_shared_state(indoc! {r"
+          1 2 3
+          4 5 6
+          7 8 9
+          ˇ"})
+            .await;
+    }
 }

crates/vim/test_data/test_window_bottom.json 🔗

@@ -0,0 +1,15 @@
+{"Put":{"state":"abc\ndeˇf\nparagraph\nthe second\nthird and\nfinal"}}
+{"Key":"shift-l"}
+{"Get":{"state":"abc\ndef\nparagraph\nthe second\nthird and\nfiˇnal","mode":"Normal"}}
+{"Put":{"state":"1 2 3\n4 5 ˇ6\n7 8 9\n"}}
+{"Key":"shift-l"}
+{"Get":{"state":"1 2 3\n4 5 6\n7 8 9\nˇ","mode":"Normal"}}
+{"Put":{"state":"1 2 3\nˇ4 5 6\n7 8 9\n"}}
+{"Key":"shift-l"}
+{"Get":{"state":"1 2 3\n4 5 6\n7 8 9\nˇ","mode":"Normal"}}
+{"Put":{"state":"1 2 ˇ3\n4 5 6\n7 8 9\n"}}
+{"Key":"shift-l"}
+{"Get":{"state":"1 2 3\n4 5 6\n7 8 9\nˇ","mode":"Normal"}}
+{"Put":{"state":"ˇ1 2 3\n4 5 6\n7 8 9\n"}}
+{"Key":"shift-l"}
+{"Get":{"state":"1 2 3\n4 5 6\n7 8 9\nˇ","mode":"Normal"}}

crates/vim/test_data/test_window_middle.json 🔗

@@ -0,0 +1,17 @@
+{"Put":{"state":"abˇc\ndef\nparagraph\nthe second\nthird and\nfinal"}}
+{"Key":"shift-m"}
+{"Get":{"state":"abc\ndef\npaˇragraph\nthe second\nthird and\nfinal","mode":"Normal"}}
+{"Put":{"state":"1 2 3\n4 5 6\n7 8 ˇ9\n"}}
+{"Key":"shift-m"}
+{"Get":{"state":"1 2 3\n4 5 ˇ6\n7 8 9\n","mode":"Normal"}}
+{"Put":{"state":"1 2 3\n4 5 6\nˇ7 8 9\n"}}
+{"Key":"shift-m"}
+{"Get":{"state":"1 2 3\nˇ4 5 6\n7 8 9\n","mode":"Normal"}}
+{"Put":{"state":"ˇ1 2 3\n4 5 6\n7 8 9\n"}}
+{"Key":"shift-m"}
+{"Get":{"state":"1 2 3\nˇ4 5 6\n7 8 9\n","mode":"Normal"}}
+{"Key":"shift-m"}
+{"Get":{"state":"1 2 3\nˇ4 5 6\n7 8 9\n","mode":"Normal"}}
+{"Put":{"state":"1 2 3\n4 5 ˇ6\n7 8 9\n"}}
+{"Key":"shift-m"}
+{"Get":{"state":"1 2 3\n4 5 ˇ6\n7 8 9\n","mode":"Normal"}}

crates/vim/test_data/test_window_top.json 🔗

@@ -0,0 +1,9 @@
+{"Put":{"state":"abc\ndef\nparagraph\nthe second\nthird ˇand\nfinal"}}
+{"Key":"shift-h"}
+{"Get":{"state":"abˇc\ndef\nparagraph\nthe second\nthird and\nfinal","mode":"Normal"}}
+{"Put":{"state":"1 2 3\n4 5 6\n7 8 ˇ9\n"}}
+{"Key":"shift-h"}
+{"Get":{"state":"1 2 ˇ3\n4 5 6\n7 8 9\n","mode":"Normal"}}
+{"Put":{"state":"1 2 3\n4 5 6\nˇ7 8 9\n"}}
+{"Key":"shift-h"}
+{"Get":{"state":"ˇ1 2 3\n4 5 6\n7 8 9\n","mode":"Normal"}}