editor: Ignore soft wrapped lines when adding selection above or below (#40190)

Dino and Smit Barmase created

- Add `skip_soft_wrap` field to both `AddSelectionAbove` and
`AddSelectionBelow` actions. When set to `true`, which is now 
the default this will skip soft wrapped lines when extending the 
selections.
- Move the `start_of_relative_buffer_row` function from the
`vim::motion` module to the `editor::display_map::DisplaySnapshot`
implementation as a method.
- Update the default behavior for both `editor: add selection above` and
`editor: add selection below` commands in order to skip over soft
wrapped lines by default, mirroring VS Code's default behavior.
- Update existing keymaps to specify this `skip_soft_wrap` value for
both `AddSelectionAbove` and `AddSelectionBelow` actions.

Closes #16979 

Release Notes:

- Updated both the `editor: add selection above` and `editor: add
selection below` commands to ignore soft wrapped lines. If you wish to
restore the old behavior, add the following to your keymap file:
  ```
  {
    "context": "Editor",
    "bindings": {
"cmd-alt-up": ["editor::AddSelectionAbove", { "skip_soft_wrap": false
}],
"cmd-alt-down": ["editor::AddSelectionBelow", { "skip_soft_wrap": false
}]
    }
  }
  ```

---------

Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com>

Change summary

assets/keymaps/default-linux.json          |  4 
assets/keymaps/default-macos.json          |  8 +-
assets/keymaps/default-windows.json        |  4 
assets/keymaps/linux/atom.json             |  4 
assets/keymaps/linux/sublime_text.json     |  4 
assets/keymaps/macos/atom.json             |  4 
assets/keymaps/macos/sublime_text.json     |  4 
assets/keymaps/vim.json                    |  4 
crates/editor/src/actions.rs               | 22 +++++-
crates/editor/src/display_map.rs           | 20 ++++++
crates/editor/src/editor.rs                | 25 +++++-
crates/editor/src/editor_tests.rs          | 77 ++++++++++++++++++++++++
crates/editor/src/selections_collection.rs |  5 +
crates/vim/src/motion.rs                   | 33 +--------
crates/vim/src/normal.rs                   |  2 
crates/vim/src/visual.rs                   |  9 +-
crates/zed/src/zed/app_menus.rs            | 14 +++
crates/zed/src/zed/quick_action_bar.rs     | 14 +++
18 files changed, 191 insertions(+), 66 deletions(-)

Detailed changes

assets/keymaps/default-linux.json πŸ”—

@@ -491,8 +491,8 @@
     "bindings": {
       "ctrl-[": "editor::Outdent",
       "ctrl-]": "editor::Indent",
-      "shift-alt-up": "editor::AddSelectionAbove", // Insert Cursor Above
-      "shift-alt-down": "editor::AddSelectionBelow", // Insert Cursor Below
+      "shift-alt-up": ["editor::AddSelectionAbove", { "skip_soft_wrap": true }], // Insert Cursor Above
+      "shift-alt-down": ["editor::AddSelectionBelow", { "skip_soft_wrap": true }], // Insert Cursor Below
       "ctrl-shift-k": "editor::DeleteLine",
       "alt-up": "editor::MoveLineUp",
       "alt-down": "editor::MoveLineDown",

assets/keymaps/default-macos.json πŸ”—

@@ -539,10 +539,10 @@
     "bindings": {
       "cmd-[": "editor::Outdent",
       "cmd-]": "editor::Indent",
-      "cmd-ctrl-p": "editor::AddSelectionAbove", // Insert cursor above
-      "cmd-alt-up": "editor::AddSelectionAbove",
-      "cmd-ctrl-n": "editor::AddSelectionBelow", // Insert cursor below
-      "cmd-alt-down": "editor::AddSelectionBelow",
+      "cmd-ctrl-p": ["editor::AddSelectionAbove", { "skip_soft_wrap": false }], // Insert cursor above
+      "cmd-alt-up": ["editor::AddSelectionAbove", { "skip_soft_wrap": true }],
+      "cmd-ctrl-n": ["editor::AddSelectionBelow", { "skip_soft_wrap": false }], // Insert cursor below
+      "cmd-alt-down": ["editor::AddSelectionBelow", { "skip_soft_wrap": true }],
       "cmd-shift-k": "editor::DeleteLine",
       "alt-up": "editor::MoveLineUp",
       "alt-down": "editor::MoveLineDown",

assets/keymaps/default-windows.json πŸ”—

@@ -500,8 +500,8 @@
     "bindings": {
       "ctrl-[": "editor::Outdent",
       "ctrl-]": "editor::Indent",
-      "ctrl-shift-alt-up": "editor::AddSelectionAbove", // Insert Cursor Above
-      "ctrl-shift-alt-down": "editor::AddSelectionBelow", // Insert Cursor Below
+      "ctrl-shift-alt-up": ["editor::AddSelectionAbove", { "skip_soft_wrap": true }], // Insert Cursor Above
+      "ctrl-shift-alt-down": ["editor::AddSelectionBelow", { "skip_soft_wrap": true }], // Insert Cursor Below
       "ctrl-shift-k": "editor::DeleteLine",
       "alt-up": "editor::MoveLineUp",
       "alt-down": "editor::MoveLineDown",

assets/keymaps/linux/atom.json πŸ”—

@@ -24,8 +24,8 @@
       "ctrl-<": "editor::ScrollCursorCenter", // editor:scroll-to-cursor
       "f3": ["editor::SelectNext", { "replace_newest": true }], // find-and-replace:find-next
       "shift-f3": ["editor::SelectPrevious", { "replace_newest": true }], //find-and-replace:find-previous
-      "alt-shift-down": "editor::AddSelectionBelow", // editor:add-selection-below
-      "alt-shift-up": "editor::AddSelectionAbove", // editor:add-selection-above
+      "alt-shift-down": ["editor::AddSelectionBelow", { "skip_soft_wrap": true }], // editor:add-selection-below
+      "alt-shift-up": ["editor::AddSelectionAbove", { "skip_soft_wrap": true }], // editor:add-selection-above
       "ctrl-j": "editor::JoinLines", // editor:join-lines
       "ctrl-shift-d": "editor::DuplicateLineDown", // editor:duplicate-lines
       "ctrl-up": "editor::MoveLineUp", // editor:move-line-up

assets/keymaps/linux/sublime_text.json πŸ”—

@@ -28,8 +28,8 @@
   {
     "context": "Editor",
     "bindings": {
-      "ctrl-alt-up": "editor::AddSelectionAbove",
-      "ctrl-alt-down": "editor::AddSelectionBelow",
+      "ctrl-alt-up": ["editor::AddSelectionAbove", { "skip_soft_wrap": false }],
+      "ctrl-alt-down": ["editor::AddSelectionBelow", { "skip_soft_wrap": false }],
       "ctrl-shift-up": "editor::MoveLineUp",
       "ctrl-shift-down": "editor::MoveLineDown",
       "ctrl-shift-m": "editor::SelectLargerSyntaxNode",

assets/keymaps/macos/atom.json πŸ”—

@@ -25,8 +25,8 @@
       "cmd-<": "editor::ScrollCursorCenter",
       "cmd-g": ["editor::SelectNext", { "replace_newest": true }],
       "cmd-shift-g": ["editor::SelectPrevious", { "replace_newest": true }],
-      "ctrl-shift-down": "editor::AddSelectionBelow",
-      "ctrl-shift-up": "editor::AddSelectionAbove",
+      "ctrl-shift-down": ["editor::AddSelectionBelow", { "skip_soft_wrap": true }],
+      "ctrl-shift-up": ["editor::AddSelectionAbove", { "skip_soft_wrap": true }],
       "alt-enter": "editor::Newline",
       "cmd-shift-d": "editor::DuplicateLineDown",
       "ctrl-cmd-up": "editor::MoveLineUp",

assets/keymaps/macos/sublime_text.json πŸ”—

@@ -28,8 +28,8 @@
   {
     "context": "Editor",
     "bindings": {
-      "ctrl-shift-up": "editor::AddSelectionAbove",
-      "ctrl-shift-down": "editor::AddSelectionBelow",
+      "ctrl-shift-up": ["editor::AddSelectionAbove", { "skip_soft_wrap": false }],
+      "ctrl-shift-down": ["editor::AddSelectionBelow", { "skip_soft_wrap": false }],
       "cmd-ctrl-up": "editor::MoveLineUp",
       "cmd-ctrl-down": "editor::MoveLineDown",
       "cmd-shift-space": "editor::SelectAll",

assets/keymaps/vim.json πŸ”—

@@ -498,8 +498,8 @@
       "ctrl-c": "editor::ToggleComments",
       "d": "vim::HelixDelete",
       "c": "vim::Substitute",
-      "shift-c": "editor::AddSelectionBelow",
-      "alt-shift-c": "editor::AddSelectionAbove"
+      "shift-c": ["editor::AddSelectionBelow", { "skip_soft_wrap": true }],
+      "alt-shift-c": ["editor::AddSelectionAbove", { "skip_soft_wrap": true }]
     }
   },
   {

crates/editor/src/actions.rs πŸ”—

@@ -318,6 +318,24 @@ pub struct GoToPreviousDiagnostic {
     pub severity: GoToDiagnosticSeverityFilter,
 }
 
+/// Adds a cursor above the current selection.
+#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema, Action)]
+#[action(namespace = editor)]
+#[serde(deny_unknown_fields)]
+pub struct AddSelectionAbove {
+    #[serde(default = "default_true")]
+    pub skip_soft_wrap: bool,
+}
+
+/// Adds a cursor below the current selection.
+#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema, Action)]
+#[action(namespace = editor)]
+#[serde(deny_unknown_fields)]
+pub struct AddSelectionBelow {
+    #[serde(default = "default_true")]
+    pub skip_soft_wrap: bool,
+}
+
 actions!(
     debugger,
     [
@@ -345,10 +363,6 @@ actions!(
         /// Accepts a partial edit prediction.
         #[action(deprecated_aliases = ["editor::AcceptPartialCopilotSuggestion"])]
         AcceptPartialEditPrediction,
-        /// Adds a cursor above the current selection.
-        AddSelectionAbove,
-        /// Adds a cursor below the current selection.
-        AddSelectionBelow,
         /// Applies all diff hunks in the editor.
         ApplyAllDiffHunks,
         /// Applies the diff hunk at the current position.

crates/editor/src/display_map.rs πŸ”—

@@ -1401,6 +1401,26 @@ impl DisplaySnapshot {
     pub fn excerpt_header_height(&self) -> u32 {
         self.block_snapshot.excerpt_header_height
     }
+
+    /// Given a `DisplayPoint`, returns another `DisplayPoint` corresponding to
+    /// the start of the buffer row that is a given number of buffer rows away
+    /// from the provided point.
+    ///
+    /// This moves by buffer rows instead of display rows, a distinction that is
+    /// important when soft wrapping is enabled.
+    pub fn start_of_relative_buffer_row(&self, point: DisplayPoint, times: isize) -> DisplayPoint {
+        let start = self.display_point_to_fold_point(point, Bias::Left);
+        let target = start.row() as isize + times;
+        let new_row = (target.max(0) as u32).min(self.fold_snapshot().max_point().row());
+
+        self.clip_point(
+            self.fold_point_to_display_point(
+                self.fold_snapshot()
+                    .clip_point(FoldPoint::new(new_row, 0), Bias::Right),
+            ),
+            Bias::Right,
+        )
+    }
 }
 
 #[derive(Copy, Clone, Default, Eq, Ord, PartialOrd, PartialEq)]

crates/editor/src/editor.rs πŸ”—

@@ -14236,23 +14236,29 @@ impl Editor {
 
     pub fn add_selection_above(
         &mut self,
-        _: &AddSelectionAbove,
+        action: &AddSelectionAbove,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        self.add_selection(true, window, cx);
+        self.add_selection(true, action.skip_soft_wrap, window, cx);
     }
 
     pub fn add_selection_below(
         &mut self,
-        _: &AddSelectionBelow,
+        action: &AddSelectionBelow,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        self.add_selection(false, window, cx);
+        self.add_selection(false, action.skip_soft_wrap, window, cx);
     }
 
-    fn add_selection(&mut self, above: bool, window: &mut Window, cx: &mut Context<Self>) {
+    fn add_selection(
+        &mut self,
+        above: bool,
+        skip_soft_wrap: bool,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
         self.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx);
 
         let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
@@ -14339,12 +14345,19 @@ impl Editor {
                         };
 
                     let mut maybe_new_selection = None;
+                    let direction = if above { -1 } else { 1 };
+
                     while row != end_row {
-                        if above {
+                        if skip_soft_wrap {
+                            row = display_map
+                                .start_of_relative_buffer_row(DisplayPoint::new(row, 0), direction)
+                                .row();
+                        } else if above {
                             row.0 -= 1;
                         } else {
                             row.0 += 1;
                         }
+
                         if let Some(new_selection) = self.selections.build_columnar_selection(
                             &display_map,
                             row,

crates/editor/src/editor_tests.rs πŸ”—

@@ -25681,6 +25681,83 @@ async fn test_add_selection_after_moving_with_multiple_cursors(cx: &mut TestAppC
     );
 }
 
+#[gpui::test]
+async fn test_add_selection_skip_soft_wrap_option(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let mut cx = EditorTestContext::new(cx).await;
+
+    cx.set_state(indoc!(
+        r#"Λ‡This is a very long line that will be wrapped when soft wrapping is enabled
+           Second line here"#
+    ));
+
+    cx.update_editor(|editor, window, cx| {
+        // Enable soft wrapping with a narrow width to force soft wrapping and
+        // confirm that more than 2 rows are being displayed.
+        editor.set_wrap_width(Some(100.0.into()), cx);
+        assert!(editor.display_text(cx).lines().count() > 2);
+
+        editor.add_selection_below(
+            &AddSelectionBelow {
+                skip_soft_wrap: true,
+            },
+            window,
+            cx,
+        );
+
+        assert_eq!(
+            editor.selections.display_ranges(cx),
+            &[
+                DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0),
+                DisplayPoint::new(DisplayRow(8), 0)..DisplayPoint::new(DisplayRow(8), 0),
+            ]
+        );
+
+        editor.add_selection_above(
+            &AddSelectionAbove {
+                skip_soft_wrap: true,
+            },
+            window,
+            cx,
+        );
+
+        assert_eq!(
+            editor.selections.display_ranges(cx),
+            &[DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)]
+        );
+
+        editor.add_selection_below(
+            &AddSelectionBelow {
+                skip_soft_wrap: false,
+            },
+            window,
+            cx,
+        );
+
+        assert_eq!(
+            editor.selections.display_ranges(cx),
+            &[
+                DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0),
+                DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0),
+            ]
+        );
+
+        editor.add_selection_above(
+            &AddSelectionAbove {
+                skip_soft_wrap: false,
+            },
+            window,
+            cx,
+        );
+
+        assert_eq!(
+            editor.selections.display_ranges(cx),
+            &[DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)]
+        );
+    });
+}
+
 #[gpui::test(iterations = 10)]
 async fn test_document_colors(cx: &mut TestAppContext) {
     let expected_color = Rgba {

crates/editor/src/selections_collection.rs πŸ”—

@@ -392,6 +392,11 @@ impl SelectionsCollection {
             .collect()
     }
 
+    /// Attempts to build a selection in the provided `DisplayRow` within the
+    /// same range as the provided range of `Pixels`.
+    /// Returns `None` if the range is not empty but it starts past the line's
+    /// length, meaning that the line isn't long enough to be contained within
+    /// part of the provided range.
     pub fn build_columnar_selection(
         &mut self,
         display_map: &DisplaySnapshot,

crates/vim/src/motion.rs πŸ”—

@@ -1525,29 +1525,6 @@ fn wrapping_right_single(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayP
     }
 }
 
-/// Given a point, returns the start of the buffer row that is a given number of
-/// buffer rows away from the current position.
-///
-/// This moves by buffer rows instead of display rows, a distinction that is
-/// important when soft wrapping is enabled.
-pub(crate) fn start_of_relative_buffer_row(
-    map: &DisplaySnapshot,
-    point: DisplayPoint,
-    times: isize,
-) -> DisplayPoint {
-    let start = map.display_point_to_fold_point(point, Bias::Left);
-    let target = start.row() as isize + times;
-    let new_row = (target.max(0) as u32).min(map.fold_snapshot().max_point().row());
-
-    map.clip_point(
-        map.fold_point_to_display_point(
-            map.fold_snapshot()
-                .clip_point(FoldPoint::new(new_row, 0), Bias::Right),
-        ),
-        Bias::Right,
-    )
-}
-
 fn up_down_buffer_rows(
     map: &DisplaySnapshot,
     mut point: DisplayPoint,
@@ -2127,7 +2104,7 @@ pub(crate) fn end_of_line(
     times: usize,
 ) -> DisplayPoint {
     if times > 1 {
-        point = start_of_relative_buffer_row(map, point, times as isize - 1);
+        point = map.start_of_relative_buffer_row(point, times as isize - 1);
     }
     if display_lines {
         map.clip_point(
@@ -2732,17 +2709,17 @@ fn sneak_backward(
 }
 
 fn next_line_start(map: &DisplaySnapshot, point: DisplayPoint, times: usize) -> DisplayPoint {
-    let correct_line = start_of_relative_buffer_row(map, point, times as isize);
+    let correct_line = map.start_of_relative_buffer_row(point, times as isize);
     first_non_whitespace(map, false, correct_line)
 }
 
 fn previous_line_start(map: &DisplaySnapshot, point: DisplayPoint, times: usize) -> DisplayPoint {
-    let correct_line = start_of_relative_buffer_row(map, point, -(times as isize));
+    let correct_line = map.start_of_relative_buffer_row(point, -(times as isize));
     first_non_whitespace(map, false, correct_line)
 }
 
 fn go_to_column(map: &DisplaySnapshot, point: DisplayPoint, times: usize) -> DisplayPoint {
-    let correct_line = start_of_relative_buffer_row(map, point, 0);
+    let correct_line = map.start_of_relative_buffer_row(point, 0);
     right(map, correct_line, times.saturating_sub(1))
 }
 
@@ -2752,7 +2729,7 @@ pub(crate) fn next_line_end(
     times: usize,
 ) -> DisplayPoint {
     if times > 1 {
-        point = start_of_relative_buffer_row(map, point, times as isize - 1);
+        point = map.start_of_relative_buffer_row(point, times as isize - 1);
     }
     end_of_line(map, false, point, 1)
 }

crates/vim/src/normal.rs πŸ”—

@@ -679,7 +679,7 @@ impl Vim {
                 editor.edit_with_autoindent(edits, cx);
                 editor.change_selections(Default::default(), window, cx, |s| {
                     s.move_cursors_with(|map, cursor, _| {
-                        let previous_line = motion::start_of_relative_buffer_row(map, cursor, -1);
+                        let previous_line = map.start_of_relative_buffer_row(cursor, -1);
                         let insert_point = motion::end_of_line(map, false, previous_line, 1);
                         (insert_point, SelectionGoal::None)
                     });

crates/vim/src/visual.rs πŸ”—

@@ -15,10 +15,7 @@ use workspace::searchable::Direction;
 
 use crate::{
     Vim,
-    motion::{
-        Motion, MotionKind, first_non_whitespace, next_line_end, start_of_line,
-        start_of_relative_buffer_row,
-    },
+    motion::{Motion, MotionKind, first_non_whitespace, next_line_end, start_of_line},
     object::Object,
     state::{Mark, Mode, Operator},
 };
@@ -406,7 +403,9 @@ impl Vim {
                 // Move to the next or previous buffer row, ensuring that
                 // wrapped lines are handled correctly.
                 let direction = if tail.row() > head.row() { -1 } else { 1 };
-                row = start_of_relative_buffer_row(map, DisplayPoint::new(row, 0), direction).row();
+                row = map
+                    .start_of_relative_buffer_row(DisplayPoint::new(row, 0), direction)
+                    .row();
             }
 
             s.select(selections);

crates/zed/src/zed/app_menus.rs πŸ”—

@@ -185,8 +185,18 @@ pub fn app_menus(cx: &mut App) -> Vec<Menu> {
                     editor::actions::SelectPreviousSyntaxNode,
                 ),
                 MenuItem::separator(),
-                MenuItem::action("Add Cursor Above", editor::actions::AddSelectionAbove),
-                MenuItem::action("Add Cursor Below", editor::actions::AddSelectionBelow),
+                MenuItem::action(
+                    "Add Cursor Above",
+                    editor::actions::AddSelectionAbove {
+                        skip_soft_wrap: true,
+                    },
+                ),
+                MenuItem::action(
+                    "Add Cursor Below",
+                    editor::actions::AddSelectionBelow {
+                        skip_soft_wrap: true,
+                    },
+                ),
                 MenuItem::action(
                     "Select Next Occurrence",
                     editor::actions::SelectNext {

crates/zed/src/zed/quick_action_bar.rs πŸ”—

@@ -266,8 +266,18 @@ impl Render for QuickActionBar {
                             )
                             .action("Expand Selection", Box::new(SelectLargerSyntaxNode))
                             .action("Shrink Selection", Box::new(SelectSmallerSyntaxNode))
-                            .action("Add Cursor Above", Box::new(AddSelectionAbove))
-                            .action("Add Cursor Below", Box::new(AddSelectionBelow))
+                            .action(
+                                "Add Cursor Above",
+                                Box::new(AddSelectionAbove {
+                                    skip_soft_wrap: true,
+                                }),
+                            )
+                            .action(
+                                "Add Cursor Below",
+                                Box::new(AddSelectionBelow {
+                                    skip_soft_wrap: true,
+                                }),
+                            )
                             .separator()
                             .action("Go to Symbol", Box::new(ToggleOutline))
                             .action("Go to Line/Column", Box::new(ToggleGoToLine))