Add paragraph based vertical movements

Julia created

Change summary

assets/keymaps/default.json   |  4 +
crates/editor/src/editor.rs   | 82 +++++++++++++++++++++++++++++++++++++
crates/editor/src/movement.rs | 38 +++++++++++++++++
3 files changed, 124 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);
@@ -4762,6 +4770,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/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