Add editor actions for moving and selecting to next / previous excerpt (#25299)

Michael Sloan created

Covers part of #5129 by adding `MoveToStartOfExcerpt`,
`MoveToEndOfExcerpt`, `SelectToStartOfExcerpt`, and
`SelectToEndOfExcerpt`.

No default linux bindings yet as it's unclear what to use. Currently,
`ctrl-up` / `ctrl-down` scroll up and down by one line (see #13269).
Considering changing the meaning of those.

Mac:

* Previously `cmd-up` and `cmd-down` were `editor::MoveToBeginning` and
`editor::MoveToEnd`. In singleton editors these will behave the same as
before. In multibuffers, they will now step through excerpts instead of
jumping to the beginning / end of the multibuffer.

* `cmd-home` and `cmd-end`, often typed as `cmd-fn-left` and
`cmd-fn-right` are now `editor::MoveToBeginning` and
`editor::MoveToEnd`. This is useful in multibuffers.

Release Notes:

- Mac: `cmd-up` now moves to the previous
multibuffer excerpt start, and `cmd-down` moves to the next multibuffer
excerpt end. Within normal buffers these behave the same as before, moving
to the beginning or end.

Change summary

assets/keymaps/default-macos.json | 10 ++-
crates/editor/src/actions.rs      |  4 +
crates/editor/src/editor.rs       | 92 +++++++++++++++++++++++++++++++++
crates/editor/src/element.rs      |  4 +
crates/editor/src/movement.rs     | 64 ++++++++++++++++++++++
crates/vim/src/motion.rs          | 47 +---------------
6 files changed, 174 insertions(+), 47 deletions(-)

Detailed changes

assets/keymaps/default-macos.json 🔗

@@ -97,8 +97,10 @@
       "cmd-right": "editor::MoveToEndOfLine",
       "ctrl-e": "editor::MoveToEndOfLine",
       "end": "editor::MoveToEndOfLine",
-      "cmd-up": "editor::MoveToBeginning",
-      "cmd-down": "editor::MoveToEnd",
+      "cmd-up": "editor::MoveToStartOfExcerpt",
+      "cmd-down": "editor::MoveToEndOfExcerpt",
+      "cmd-home": "editor::MoveToBeginning", // Typed via `cmd-fn-left`
+      "cmd-end": "editor::MoveToEnd", // Typed via `cmd-fn-right`
       "shift-up": "editor::SelectUp",
       "ctrl-shift-p": "editor::SelectUp",
       "shift-down": "editor::SelectDown",
@@ -111,8 +113,8 @@
       "alt-shift-right": "editor::SelectToNextWordEnd", // cursorWordRightSelect
       "ctrl-shift-up": "editor::SelectToStartOfParagraph",
       "ctrl-shift-down": "editor::SelectToEndOfParagraph",
-      "cmd-shift-up": "editor::SelectToBeginning",
-      "cmd-shift-down": "editor::SelectToEnd",
+      "cmd-shift-up": "editor::SelectToStartOfExcerpt",
+      "cmd-shift-down": "editor::SelectToEndOfExcerpt",
       "cmd-a": "editor::SelectAll",
       "cmd-l": "editor::SelectLine",
       "cmd-shift-i": "editor::Format",

crates/editor/src/actions.rs 🔗

@@ -329,6 +329,8 @@ gpui::actions!(
         MoveToPreviousSubwordStart,
         MoveToPreviousWordStart,
         MoveToStartOfParagraph,
+        MoveToStartOfExcerpt,
+        MoveToEndOfExcerpt,
         MoveUp,
         Newline,
         NewlineAbove,
@@ -364,6 +366,8 @@ gpui::actions!(
         ScrollCursorTop,
         SelectAll,
         SelectAllMatches,
+        SelectToStartOfExcerpt,
+        SelectToEndOfExcerpt,
         SelectDown,
         SelectEnclosingSymbol,
         SelectLargerSyntaxNode,

crates/editor/src/editor.rs 🔗

@@ -9033,6 +9033,98 @@ impl Editor {
         })
     }
 
+    pub fn move_to_start_of_excerpt(
+        &mut self,
+        _: &MoveToStartOfExcerpt,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if matches!(self.mode, EditorMode::SingleLine { .. }) {
+            cx.propagate();
+            return;
+        }
+
+        self.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
+            s.move_with(|map, selection| {
+                selection.collapse_to(
+                    movement::start_of_excerpt(
+                        map,
+                        selection.head(),
+                        workspace::searchable::Direction::Prev,
+                    ),
+                    SelectionGoal::None,
+                )
+            });
+        })
+    }
+
+    pub fn move_to_end_of_excerpt(
+        &mut self,
+        _: &MoveToEndOfExcerpt,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if matches!(self.mode, EditorMode::SingleLine { .. }) {
+            cx.propagate();
+            return;
+        }
+
+        self.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
+            s.move_with(|map, selection| {
+                selection.collapse_to(
+                    movement::end_of_excerpt(
+                        map,
+                        selection.head(),
+                        workspace::searchable::Direction::Next,
+                    ),
+                    SelectionGoal::None,
+                )
+            });
+        })
+    }
+
+    pub fn select_to_start_of_excerpt(
+        &mut self,
+        _: &SelectToStartOfExcerpt,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if matches!(self.mode, EditorMode::SingleLine { .. }) {
+            cx.propagate();
+            return;
+        }
+
+        self.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
+            s.move_heads_with(|map, head, _| {
+                (
+                    movement::start_of_excerpt(map, head, workspace::searchable::Direction::Prev),
+                    SelectionGoal::None,
+                )
+            });
+        })
+    }
+
+    pub fn select_to_end_of_excerpt(
+        &mut self,
+        _: &SelectToEndOfExcerpt,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if matches!(self.mode, EditorMode::SingleLine { .. }) {
+            cx.propagate();
+            return;
+        }
+
+        self.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
+            s.move_heads_with(|map, head, _| {
+                (
+                    movement::end_of_excerpt(map, head, workspace::searchable::Direction::Next),
+                    SelectionGoal::None,
+                )
+            });
+        })
+    }
+
     pub fn move_to_beginning(
         &mut self,
         _: &MoveToBeginning,

crates/editor/src/element.rs 🔗

@@ -290,6 +290,8 @@ impl EditorElement {
         register_action(editor, window, Editor::move_to_end_of_paragraph);
         register_action(editor, window, Editor::move_to_beginning);
         register_action(editor, window, Editor::move_to_end);
+        register_action(editor, window, Editor::move_to_start_of_excerpt);
+        register_action(editor, window, Editor::move_to_end_of_excerpt);
         register_action(editor, window, Editor::select_up);
         register_action(editor, window, Editor::select_down);
         register_action(editor, window, Editor::select_left);
@@ -302,6 +304,8 @@ impl EditorElement {
         register_action(editor, window, Editor::select_to_end_of_line);
         register_action(editor, window, Editor::select_to_start_of_paragraph);
         register_action(editor, window, Editor::select_to_end_of_paragraph);
+        register_action(editor, window, Editor::select_to_start_of_excerpt);
+        register_action(editor, window, Editor::select_to_end_of_excerpt);
         register_action(editor, window, Editor::select_to_beginning);
         register_action(editor, window, Editor::select_to_end);
         register_action(editor, window, Editor::select_all);

crates/editor/src/movement.rs 🔗

@@ -7,6 +7,7 @@ use gpui::{Pixels, WindowTextSystem};
 use language::Point;
 use multi_buffer::{MultiBufferRow, MultiBufferSnapshot};
 use serde::Deserialize;
+use workspace::searchable::Direction;
 
 use std::{ops::Range, sync::Arc};
 
@@ -403,6 +404,69 @@ pub fn end_of_paragraph(
     map.max_point()
 }
 
+pub fn start_of_excerpt(
+    map: &DisplaySnapshot,
+    display_point: DisplayPoint,
+    direction: Direction,
+) -> DisplayPoint {
+    let point = map.display_point_to_point(display_point, Bias::Left);
+    let Some(excerpt) = map.buffer_snapshot.excerpt_containing(point..point) else {
+        return display_point;
+    };
+    match direction {
+        Direction::Prev => {
+            let mut start = excerpt.start_anchor().to_display_point(&map);
+            if start >= display_point && start.row() > DisplayRow(0) {
+                let Some(excerpt) = map.buffer_snapshot.excerpt_before(excerpt.id()) else {
+                    return display_point;
+                };
+                start = excerpt.start_anchor().to_display_point(&map);
+            }
+            start
+        }
+        Direction::Next => {
+            let mut end = excerpt.end_anchor().to_display_point(&map);
+            *end.row_mut() += 1;
+            map.clip_point(end, Bias::Right)
+        }
+    }
+}
+
+pub fn end_of_excerpt(
+    map: &DisplaySnapshot,
+    display_point: DisplayPoint,
+    direction: Direction,
+) -> DisplayPoint {
+    let point = map.display_point_to_point(display_point, Bias::Left);
+    let Some(excerpt) = map.buffer_snapshot.excerpt_containing(point..point) else {
+        return display_point;
+    };
+    match direction {
+        Direction::Prev => {
+            let mut start = excerpt.start_anchor().to_display_point(&map);
+            if start.row() > DisplayRow(0) {
+                *start.row_mut() -= 1;
+            }
+            map.clip_point(start, Bias::Left)
+        }
+        Direction::Next => {
+            let mut end = excerpt.end_anchor().to_display_point(&map);
+            *end.column_mut() = 0;
+            if end <= display_point {
+                *end.row_mut() += 1;
+                let point_end = map.display_point_to_point(end, Bias::Right);
+                let Some(excerpt) = map.buffer_snapshot.excerpt_containing(point_end..point_end)
+                else {
+                    return display_point;
+                };
+                end = excerpt.end_anchor().to_display_point(&map);
+                *end.column_mut() = 0;
+            }
+            end
+        }
+    }
+}
+
 /// 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.

crates/vim/src/motion.rs 🔗

@@ -2699,49 +2699,10 @@ fn section_motion(
     };
 
     for _ in 0..times {
-        let point = map.display_point_to_point(display_point, Bias::Left);
-        let Some(excerpt) = map.buffer_snapshot.excerpt_containing(point..point) else {
-            return display_point;
-        };
-        let next_point = match (direction, is_start) {
-            (Direction::Prev, true) => {
-                let mut start = excerpt.start_anchor().to_display_point(&map);
-                if start >= display_point && start.row() > DisplayRow(0) {
-                    let Some(excerpt) = map.buffer_snapshot.excerpt_before(excerpt.id()) else {
-                        return display_point;
-                    };
-                    start = excerpt.start_anchor().to_display_point(&map);
-                }
-                start
-            }
-            (Direction::Prev, false) => {
-                let mut start = excerpt.start_anchor().to_display_point(&map);
-                if start.row() > DisplayRow(0) {
-                    *start.row_mut() -= 1;
-                }
-                map.clip_point(start, Bias::Left)
-            }
-            (Direction::Next, true) => {
-                let mut end = excerpt.end_anchor().to_display_point(&map);
-                *end.row_mut() += 1;
-                map.clip_point(end, Bias::Right)
-            }
-            (Direction::Next, false) => {
-                let mut end = excerpt.end_anchor().to_display_point(&map);
-                *end.column_mut() = 0;
-                if end <= display_point {
-                    *end.row_mut() += 1;
-                    let point_end = map.display_point_to_point(end, Bias::Right);
-                    let Some(excerpt) =
-                        map.buffer_snapshot.excerpt_containing(point_end..point_end)
-                    else {
-                        return display_point;
-                    };
-                    end = excerpt.end_anchor().to_display_point(&map);
-                    *end.column_mut() = 0;
-                }
-                end
-            }
+        let next_point = if is_start {
+            movement::start_of_excerpt(map, display_point, direction)
+        } else {
+            movement::end_of_excerpt(map, display_point, direction)
         };
         if next_point == display_point {
             break;