Add paragraph based vertical movements (#2502)

Julia created

Very selfish patch I worked on yesterday, I kept saying I wanted these
and finally decided to just add them. Feedback on the keybindings
welcome

Release Notes:

* Added `MoveToStartOfParagraph` and `MoveToEndOfParagraph` movements
for paragraph based vertical navigation

Change summary

assets/keymaps/default.json       |   4 +
crates/editor/src/editor.rs       |  82 ++++++++++++++++++++++++
crates/editor/src/editor_tests.rs | 112 +++++++++++++++++++++++++++++++++
crates/editor/src/movement.rs     |  38 +++++++++++
4 files changed, 236 insertions(+)

Detailed changes

assets/keymaps/default.json 🔗

@@ -67,10 +67,12 @@
       "cmd-z": "editor::Undo",
       "cmd-shift-z": "editor::Redo",
       "up": "editor::MoveUp",
+      "ctrl-up": "editor::MoveToStartOfParagraph",
       "pageup": "editor::PageUp",
       "shift-pageup": "editor::MovePageUp",
       "home": "editor::MoveToBeginningOfLine",
       "down": "editor::MoveDown",
+      "ctrl-down": "editor::MoveToEndOfParagraph",
       "pagedown": "editor::PageDown",
       "shift-pagedown": "editor::MovePageDown",
       "end": "editor::MoveToEndOfLine",
@@ -103,6 +105,8 @@
       "alt-shift-b": "editor::SelectToPreviousWordStart",
       "alt-shift-right": "editor::SelectToNextWordEnd",
       "alt-shift-f": "editor::SelectToNextWordEnd",
+      "ctrl-shift-up": "editor::SelectToStartOfParagraph",
+      "ctrl-shift-down": "editor::SelectToEndOfParagraph",
       "cmd-shift-up": "editor::SelectToBeginning",
       "cmd-shift-down": "editor::SelectToEnd",
       "cmd-a": "editor::SelectAll",

crates/editor/src/editor.rs 🔗

@@ -216,6 +216,8 @@ actions!(
         MoveToNextSubwordEnd,
         MoveToBeginningOfLine,
         MoveToEndOfLine,
+        MoveToStartOfParagraph,
+        MoveToEndOfParagraph,
         MoveToBeginning,
         MoveToEnd,
         SelectUp,
@@ -226,6 +228,8 @@ actions!(
         SelectToPreviousSubwordStart,
         SelectToNextWordEnd,
         SelectToNextSubwordEnd,
+        SelectToStartOfParagraph,
+        SelectToEndOfParagraph,
         SelectToBeginning,
         SelectToEnd,
         SelectAll,
@@ -337,6 +341,8 @@ pub fn init(cx: &mut AppContext) {
     cx.add_action(Editor::move_to_next_subword_end);
     cx.add_action(Editor::move_to_beginning_of_line);
     cx.add_action(Editor::move_to_end_of_line);
+    cx.add_action(Editor::move_to_start_of_paragraph);
+    cx.add_action(Editor::move_to_end_of_paragraph);
     cx.add_action(Editor::move_to_beginning);
     cx.add_action(Editor::move_to_end);
     cx.add_action(Editor::select_up);
@@ -349,6 +355,8 @@ pub fn init(cx: &mut AppContext) {
     cx.add_action(Editor::select_to_next_subword_end);
     cx.add_action(Editor::select_to_beginning_of_line);
     cx.add_action(Editor::select_to_end_of_line);
+    cx.add_action(Editor::select_to_start_of_paragraph);
+    cx.add_action(Editor::select_to_end_of_paragraph);
     cx.add_action(Editor::select_to_beginning);
     cx.add_action(Editor::select_to_end);
     cx.add_action(Editor::select_all);
@@ -4759,6 +4767,80 @@ impl Editor {
         });
     }
 
+    pub fn move_to_start_of_paragraph(
+        &mut self,
+        _: &MoveToStartOfParagraph,
+        cx: &mut ViewContext<Self>,
+    ) {
+        if matches!(self.mode, EditorMode::SingleLine) {
+            cx.propagate_action();
+            return;
+        }
+
+        self.change_selections(Some(Autoscroll::fit()), cx, |s| {
+            s.move_with(|map, selection| {
+                selection.collapse_to(
+                    movement::start_of_paragraph(map, selection.head()),
+                    SelectionGoal::None,
+                )
+            });
+        })
+    }
+
+    pub fn move_to_end_of_paragraph(
+        &mut self,
+        _: &MoveToEndOfParagraph,
+        cx: &mut ViewContext<Self>,
+    ) {
+        if matches!(self.mode, EditorMode::SingleLine) {
+            cx.propagate_action();
+            return;
+        }
+
+        self.change_selections(Some(Autoscroll::fit()), cx, |s| {
+            s.move_with(|map, selection| {
+                selection.collapse_to(
+                    movement::end_of_paragraph(map, selection.head()),
+                    SelectionGoal::None,
+                )
+            });
+        })
+    }
+
+    pub fn select_to_start_of_paragraph(
+        &mut self,
+        _: &SelectToStartOfParagraph,
+        cx: &mut ViewContext<Self>,
+    ) {
+        if matches!(self.mode, EditorMode::SingleLine) {
+            cx.propagate_action();
+            return;
+        }
+
+        self.change_selections(Some(Autoscroll::fit()), cx, |s| {
+            s.move_heads_with(|map, head, _| {
+                (movement::start_of_paragraph(map, head), SelectionGoal::None)
+            });
+        })
+    }
+
+    pub fn select_to_end_of_paragraph(
+        &mut self,
+        _: &SelectToEndOfParagraph,
+        cx: &mut ViewContext<Self>,
+    ) {
+        if matches!(self.mode, EditorMode::SingleLine) {
+            cx.propagate_action();
+            return;
+        }
+
+        self.change_selections(Some(Autoscroll::fit()), cx, |s| {
+            s.move_heads_with(|map, head, _| {
+                (movement::end_of_paragraph(map, head), SelectionGoal::None)
+            });
+        })
+    }
+
     pub fn move_to_beginning(&mut self, _: &MoveToBeginning, cx: &mut ViewContext<Self>) {
         if matches!(self.mode, EditorMode::SingleLine) {
             cx.propagate_action();

crates/editor/src/editor_tests.rs 🔗

@@ -1243,6 +1243,118 @@ fn test_prev_next_word_bounds_with_soft_wrap(cx: &mut TestAppContext) {
     });
 }
 
+#[gpui::test]
+async fn test_move_start_of_paragraph_end_of_paragraph(cx: &mut gpui::TestAppContext) {
+    init_test(cx, |_| {});
+    let mut cx = EditorTestContext::new(cx);
+
+    let line_height = cx.editor(|editor, cx| editor.style(cx).text.line_height(cx.font_cache()));
+    cx.simulate_window_resize(cx.window_id, vec2f(100., 4. * line_height));
+
+    cx.set_state(
+        &r#"ˇone
+        two
+
+        three
+        fourˇ
+        five
+
+        six"#
+            .unindent(),
+    );
+
+    cx.update_editor(|editor, cx| editor.move_to_end_of_paragraph(&MoveToEndOfParagraph, cx));
+    cx.assert_editor_state(
+        &r#"one
+        two
+        ˇ
+        three
+        four
+        five
+        ˇ
+        six"#
+            .unindent(),
+    );
+
+    cx.update_editor(|editor, cx| editor.move_to_end_of_paragraph(&MoveToEndOfParagraph, cx));
+    cx.assert_editor_state(
+        &r#"one
+        two
+
+        three
+        four
+        five
+        ˇ
+        sixˇ"#
+            .unindent(),
+    );
+
+    cx.update_editor(|editor, cx| editor.move_to_end_of_paragraph(&MoveToEndOfParagraph, cx));
+    cx.assert_editor_state(
+        &r#"ˇone
+        two
+
+        three
+        four
+        five
+
+        sixˇ"#
+            .unindent(),
+    );
+
+    cx.update_editor(|editor, cx| editor.move_to_end_of_paragraph(&MoveToEndOfParagraph, cx));
+    cx.assert_editor_state(
+        &r#"ˇone
+        two
+        ˇ
+        three
+        four
+        five
+
+        six"#
+            .unindent(),
+    );
+
+    cx.update_editor(|editor, cx| editor.move_to_start_of_paragraph(&MoveToStartOfParagraph, cx));
+    cx.assert_editor_state(
+        &r#"ˇone
+        two
+
+        three
+        four
+        five
+
+        sixˇ"#
+            .unindent(),
+    );
+
+    cx.update_editor(|editor, cx| editor.move_to_start_of_paragraph(&MoveToStartOfParagraph, cx));
+    cx.assert_editor_state(
+        &r#"one
+        two
+
+        three
+        four
+        five
+        ˇ
+        sixˇ"#
+            .unindent(),
+    );
+
+    cx.update_editor(|editor, cx| editor.move_to_start_of_paragraph(&MoveToStartOfParagraph, cx));
+    cx.assert_editor_state(
+        &r#"one
+        two
+        ˇ
+        three
+        four
+        five
+        ˇ
+        six"#
+            .unindent(),
+    );
+}
+
 #[gpui::test]
 async fn test_move_page_up_page_down(cx: &mut gpui::TestAppContext) {
     init_test(cx, |_| {});

crates/editor/src/movement.rs 🔗

@@ -193,6 +193,44 @@ pub fn next_subword_end(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPo
     })
 }
 
+pub fn start_of_paragraph(map: &DisplaySnapshot, display_point: DisplayPoint) -> DisplayPoint {
+    let point = display_point.to_point(map);
+    if point.row == 0 {
+        return map.max_point();
+    }
+
+    let mut found_non_blank_line = false;
+    for row in (0..point.row + 1).rev() {
+        let blank = map.buffer_snapshot.is_line_blank(row);
+        if found_non_blank_line && blank {
+            return Point::new(row, 0).to_display_point(map);
+        }
+
+        found_non_blank_line |= !blank;
+    }
+
+    DisplayPoint::zero()
+}
+
+pub fn end_of_paragraph(map: &DisplaySnapshot, display_point: DisplayPoint) -> DisplayPoint {
+    let point = display_point.to_point(map);
+    if point.row == map.max_buffer_row() {
+        return DisplayPoint::zero();
+    }
+
+    let mut found_non_blank_line = false;
+    for row in point.row..map.max_buffer_row() + 1 {
+        let blank = map.buffer_snapshot.is_line_blank(row);
+        if found_non_blank_line && blank {
+            return Point::new(row, 0).to_display_point(map);
+        }
+
+        found_non_blank_line |= !blank;
+    }
+
+    map.max_point()
+}
+
 /// Scans for a boundary preceding the given start point `from` until a boundary is found, indicated by the
 /// given predicate returning true. The predicate is called with the character to the left and right
 /// of the candidate boundary location, and will be called with `\n` characters indicating the start