vim: Add `g M` motion to go to the middle of a line (#30227)

Alex Shen and Conrad Irwin created

Adds the "g M" vim motion to go to the middle of the line.

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>

Change summary

assets/keymaps/vim.json                                               |   1 
crates/vim/src/motion.rs                                              | 111 
crates/vim/test_data/test_forced_motion_delete_to_middle_of_line.json |  34 
3 files changed, 146 insertions(+)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -152,6 +152,7 @@
       "g end": ["vim::EndOfLine", { "display_lines": true }],
       "g 0": ["vim::StartOfLine", { "display_lines": true }],
       "g home": ["vim::StartOfLine", { "display_lines": true }],
+      "g shift-m": ["vim::MiddleOfLine", { "display_lines": true }],
       "g ^": ["vim::FirstNonWhitespace", { "display_lines": true }],
       "g v": "vim::RestoreVisualSelection",
       "g ]": "editor::GoToDiagnostic",

crates/vim/src/motion.rs 🔗

@@ -84,6 +84,9 @@ pub enum Motion {
     StartOfLine {
         display_lines: bool,
     },
+    MiddleOfLine {
+        display_lines: bool,
+    },
     EndOfLine {
         display_lines: bool,
     },
@@ -265,6 +268,13 @@ pub struct StartOfLine {
     pub(crate) display_lines: bool,
 }
 
+#[derive(Clone, Deserialize, JsonSchema, PartialEq)]
+#[serde(deny_unknown_fields)]
+struct MiddleOfLine {
+    #[serde(default)]
+    display_lines: bool,
+}
+
 #[derive(Clone, Deserialize, JsonSchema, PartialEq)]
 #[serde(deny_unknown_fields)]
 struct UnmatchedForward {
@@ -283,6 +293,7 @@ impl_actions!(
     vim,
     [
         StartOfLine,
+        MiddleOfLine,
         EndOfLine,
         FirstNonWhitespace,
         Down,
@@ -409,6 +420,15 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
             cx,
         )
     });
+    Vim::action(editor, cx, |vim, action: &MiddleOfLine, window, cx| {
+        vim.motion(
+            Motion::MiddleOfLine {
+                display_lines: action.display_lines,
+            },
+            window,
+            cx,
+        )
+    });
     Vim::action(editor, cx, |vim, action: &EndOfLine, window, cx| {
         vim.motion(
             Motion::EndOfLine {
@@ -737,6 +757,7 @@ impl Motion {
             | SentenceBackward
             | SentenceForward
             | GoToColumn
+            | MiddleOfLine { .. }
             | UnmatchedForward { .. }
             | UnmatchedBackward { .. }
             | NextWordStart { .. }
@@ -769,6 +790,7 @@ impl Motion {
             Down { .. }
             | Up { .. }
             | EndOfLine { .. }
+            | MiddleOfLine { .. }
             | Matching
             | UnmatchedForward { .. }
             | UnmatchedBackward { .. }
@@ -894,6 +916,10 @@ impl Motion {
                 start_of_line(map, *display_lines, point),
                 SelectionGoal::None,
             ),
+            MiddleOfLine { display_lines } => (
+                middle_of_line(map, *display_lines, point, maybe_times),
+                SelectionGoal::None,
+            ),
             EndOfLine { display_lines } => (
                 end_of_line(map, *display_lines, point, times),
                 SelectionGoal::None,
@@ -1944,6 +1970,36 @@ pub(crate) fn start_of_line(
     }
 }
 
+pub(crate) fn middle_of_line(
+    map: &DisplaySnapshot,
+    display_lines: bool,
+    point: DisplayPoint,
+    times: Option<usize>,
+) -> DisplayPoint {
+    let percent = if let Some(times) = times.filter(|&t| t <= 100) {
+        times as f64 / 100.
+    } else {
+        0.5
+    };
+    if display_lines {
+        map.clip_point(
+            DisplayPoint::new(
+                point.row(),
+                (map.line_len(point.row()) as f64 * percent) as u32,
+            ),
+            Bias::Left,
+        )
+    } else {
+        let mut buffer_point = point.to_point(map);
+        buffer_point.column = (map
+            .buffer_snapshot
+            .line_len(MultiBufferRow(buffer_point.row)) as f64
+            * percent) as u32;
+
+        map.clip_point(buffer_point.to_display_point(map), Bias::Left)
+    }
+}
+
 pub(crate) fn end_of_line(
     map: &DisplaySnapshot,
     display_lines: bool,
@@ -3906,6 +3962,61 @@ mod test {
         assert_eq!(cx.cx.forced_motion(), false);
     }
 
+    #[gpui::test]
+    async fn test_forced_motion_delete_to_middle_of_line(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.set_shared_state(indoc! {"
+             ˇthe quick brown fox
+             jumped over the lazy dog"})
+            .await;
+        cx.simulate_shared_keystrokes("d v g shift-m").await;
+        cx.shared_state().await.assert_eq(indoc! {"
+             ˇbrown fox
+             jumped over the lazy dog"});
+        assert_eq!(cx.cx.forced_motion(), false);
+
+        cx.set_shared_state(indoc! {"
+            the quick bˇrown fox
+            jumped over the lazy dog"})
+            .await;
+        cx.simulate_shared_keystrokes("d v g shift-m").await;
+        cx.shared_state().await.assert_eq(indoc! {"
+            the quickˇown fox
+            jumped over the lazy dog"});
+        assert_eq!(cx.cx.forced_motion(), false);
+
+        cx.set_shared_state(indoc! {"
+            the quick brown foˇx
+            jumped over the lazy dog"})
+            .await;
+        cx.simulate_shared_keystrokes("d v g shift-m").await;
+        cx.shared_state().await.assert_eq(indoc! {"
+            the quicˇk
+            jumped over the lazy dog"});
+        assert_eq!(cx.cx.forced_motion(), false);
+
+        cx.set_shared_state(indoc! {"
+            ˇthe quick brown fox
+            jumped over the lazy dog"})
+            .await;
+        cx.simulate_shared_keystrokes("d v 7 5 g shift-m").await;
+        cx.shared_state().await.assert_eq(indoc! {"
+            ˇ fox
+            jumped over the lazy dog"});
+        assert_eq!(cx.cx.forced_motion(), false);
+
+        cx.set_shared_state(indoc! {"
+            ˇthe quick brown fox
+            jumped over the lazy dog"})
+            .await;
+        cx.simulate_shared_keystrokes("d v 2 3 g shift-m").await;
+        cx.shared_state().await.assert_eq(indoc! {"
+            ˇuick brown fox
+            jumped over the lazy dog"});
+        assert_eq!(cx.cx.forced_motion(), false);
+    }
+
     #[gpui::test]
     async fn test_forced_motion_delete_to_end_of_line(cx: &mut gpui::TestAppContext) {
         let mut cx = NeovimBackedTestContext::new(cx).await;

crates/vim/test_data/test_forced_motion_delete_to_middle_of_line.json 🔗

@@ -0,0 +1,34 @@
+{"Put":{"state":"ˇthe quick brown fox\njumped over the lazy dog"}}
+{"Key":"d"}
+{"Key":"v"}
+{"Key":"g"}
+{"Key":"shift-m"}
+{"Get":{"state":"ˇbrown fox\njumped over the lazy dog","mode":"Normal"}}
+{"Put":{"state":"the quick bˇrown fox\njumped over the lazy dog"}}
+{"Key":"d"}
+{"Key":"v"}
+{"Key":"g"}
+{"Key":"shift-m"}
+{"Get":{"state":"the quickˇown fox\njumped over the lazy dog","mode":"Normal"}}
+{"Put":{"state":"the quick brown foˇx\njumped over the lazy dog"}}
+{"Key":"d"}
+{"Key":"v"}
+{"Key":"g"}
+{"Key":"shift-m"}
+{"Get":{"state":"the quicˇk\njumped over the lazy dog","mode":"Normal"}}
+{"Put":{"state":"ˇthe quick brown fox\njumped over the lazy dog"}}
+{"Key":"d"}
+{"Key":"v"}
+{"Key":"7"}
+{"Key":"5"}
+{"Key":"g"}
+{"Key":"shift-m"}
+{"Get":{"state":"ˇ fox\njumped over the lazy dog","mode":"Normal"}}
+{"Put":{"state":"ˇthe quick brown fox\njumped over the lazy dog"}}
+{"Key":"d"}
+{"Key":"v"}
+{"Key":"2"}
+{"Key":"3"}
+{"Key":"g"}
+{"Key":"shift-m"}
+{"Get":{"state":"ˇuick brown fox\njumped over the lazy dog","mode":"Normal"}}