Allow ignoring soft wraps when moving to line ends (#11153)

Tarun Verghis and Kirill Bulatov created

Release Notes:

- Fixed #10888

This patch addresses behavior of
`Editor::move_to_{beginning|end}_of_line`. It adds a setting,
`stop_at_soft_wraps` when defining a keymap for the
`editor::MoveToBeginningOfLine` and `editor::MoveToEndOfLine` actions.
When `true`, it causes movement to the either end of the line (via, for
example Home or End), to go to the logical end, as opposed to the
nearest soft wrap point in the respective direction.

---------

Co-authored-by: Kirill Bulatov <kirill@zed.dev>

Change summary

crates/editor/src/actions.rs             |  37 ++++++--
crates/editor/src/editor.rs              |  11 +
crates/editor/src/editor_tests.rs        | 106 ++++++++++++++++++++++++-
crates/language/src/language_settings.rs |   5 
crates/util/src/serde.rs                 |   3 
crates/util/src/util.rs                  |   1 
6 files changed, 138 insertions(+), 25 deletions(-)

Detailed changes

crates/editor/src/actions.rs 🔗

@@ -1,5 +1,6 @@
 //! This module contains all actions supported by [`Editor`].
 use super::*;
+use util::serde::default_true;
 
 #[derive(PartialEq, Clone, Deserialize, Default)]
 pub struct SelectNext {
@@ -13,6 +14,12 @@ pub struct SelectPrevious {
     pub replace_newest: bool,
 }
 
+#[derive(PartialEq, Clone, Deserialize, Default)]
+pub struct MoveToBeginningOfLine {
+    #[serde(default = "default_true")]
+    pub(super) stop_at_soft_wraps: bool,
+}
+
 #[derive(PartialEq, Clone, Deserialize, Default)]
 pub struct SelectToBeginningOfLine {
     #[serde(default)]
@@ -31,6 +38,12 @@ pub struct MovePageDown {
     pub(super) center_cursor: bool,
 }
 
+#[derive(PartialEq, Clone, Deserialize, Default)]
+pub struct MoveToEndOfLine {
+    #[serde(default = "default_true")]
+    pub(super) stop_at_soft_wraps: bool,
+}
+
 #[derive(PartialEq, Clone, Deserialize, Default)]
 pub struct SelectToEndOfLine {
     #[serde(default)]
@@ -103,23 +116,25 @@ pub struct ExpandExcerpts {
 impl_actions!(
     editor,
     [
+        ConfirmCodeAction,
+        ConfirmCompletion,
+        ExpandExcerpts,
+        FoldAt,
+        MoveDownByLines,
+        MovePageDown,
+        MovePageUp,
+        MoveToBeginningOfLine,
+        MoveToEndOfLine,
+        MoveUpByLines,
+        SelectDownByLines,
         SelectNext,
         SelectPrevious,
         SelectToBeginningOfLine,
-        ExpandExcerpts,
-        MovePageUp,
-        MovePageDown,
         SelectToEndOfLine,
+        SelectUpByLines,
         ToggleCodeActions,
-        ConfirmCompletion,
-        ConfirmCodeAction,
         ToggleComments,
-        FoldAt,
         UnfoldAt,
-        MoveUpByLines,
-        MoveDownByLines,
-        SelectUpByLines,
-        SelectDownByLines,
     ]
 );
 
@@ -190,10 +205,8 @@ gpui::actions!(
         MoveLineUp,
         MoveRight,
         MoveToBeginning,
-        MoveToBeginningOfLine,
         MoveToEnclosingBracket,
         MoveToEnd,
-        MoveToEndOfLine,
         MoveToEndOfParagraph,
         MoveToNextSubwordEnd,
         MoveToNextWordEnd,

crates/editor/src/editor.rs 🔗

@@ -6240,13 +6240,13 @@ impl Editor {
 
     pub fn move_to_beginning_of_line(
         &mut self,
-        _: &MoveToBeginningOfLine,
+        action: &MoveToBeginningOfLine,
         cx: &mut ViewContext<Self>,
     ) {
         self.change_selections(Some(Autoscroll::fit()), cx, |s| {
             s.move_cursors_with(|map, head, _| {
                 (
-                    movement::indented_line_beginning(map, head, true),
+                    movement::indented_line_beginning(map, head, action.stop_at_soft_wraps),
                     SelectionGoal::None,
                 )
             });
@@ -6290,10 +6290,13 @@ impl Editor {
         });
     }
 
-    pub fn move_to_end_of_line(&mut self, _: &MoveToEndOfLine, cx: &mut ViewContext<Self>) {
+    pub fn move_to_end_of_line(&mut self, action: &MoveToEndOfLine, cx: &mut ViewContext<Self>) {
         self.change_selections(Some(Autoscroll::fit()), cx, |s| {
             s.move_cursors_with(|map, head, _| {
-                (movement::line_end(map, head, true), SelectionGoal::None)
+                (
+                    movement::line_end(map, head, action.stop_at_soft_wraps),
+                    SelectionGoal::None,
+                )
             });
         })
     }

crates/editor/src/editor_tests.rs 🔗

@@ -1045,6 +1045,13 @@ fn test_move_cursor_different_line_lengths(cx: &mut TestAppContext) {
 #[gpui::test]
 fn test_beginning_end_of_line(cx: &mut TestAppContext) {
     init_test(cx, |_| {});
+    let move_to_beg = MoveToBeginningOfLine {
+        stop_at_soft_wraps: true,
+    };
+
+    let move_to_end = MoveToEndOfLine {
+        stop_at_soft_wraps: true,
+    };
 
     let view = cx.add_window(|cx| {
         let buffer = MultiBuffer::build_simple("abc\n  def", cx);
@@ -1060,7 +1067,7 @@ fn test_beginning_end_of_line(cx: &mut TestAppContext) {
     });
 
     _ = view.update(cx, |view, cx| {
-        view.move_to_beginning_of_line(&MoveToBeginningOfLine, cx);
+        view.move_to_beginning_of_line(&move_to_beg, cx);
         assert_eq!(
             view.selections.display_ranges(cx),
             &[
@@ -1071,7 +1078,7 @@ fn test_beginning_end_of_line(cx: &mut TestAppContext) {
     });
 
     _ = view.update(cx, |view, cx| {
-        view.move_to_beginning_of_line(&MoveToBeginningOfLine, cx);
+        view.move_to_beginning_of_line(&move_to_beg, cx);
         assert_eq!(
             view.selections.display_ranges(cx),
             &[
@@ -1082,7 +1089,7 @@ fn test_beginning_end_of_line(cx: &mut TestAppContext) {
     });
 
     _ = view.update(cx, |view, cx| {
-        view.move_to_beginning_of_line(&MoveToBeginningOfLine, cx);
+        view.move_to_beginning_of_line(&move_to_beg, cx);
         assert_eq!(
             view.selections.display_ranges(cx),
             &[
@@ -1093,7 +1100,7 @@ fn test_beginning_end_of_line(cx: &mut TestAppContext) {
     });
 
     _ = view.update(cx, |view, cx| {
-        view.move_to_end_of_line(&MoveToEndOfLine, cx);
+        view.move_to_end_of_line(&move_to_end, cx);
         assert_eq!(
             view.selections.display_ranges(cx),
             &[
@@ -1105,7 +1112,7 @@ fn test_beginning_end_of_line(cx: &mut TestAppContext) {
 
     // Moving to the end of line again is a no-op.
     _ = view.update(cx, |view, cx| {
-        view.move_to_end_of_line(&MoveToEndOfLine, cx);
+        view.move_to_end_of_line(&move_to_end, cx);
         assert_eq!(
             view.selections.display_ranges(cx),
             &[
@@ -1205,6 +1212,95 @@ fn test_beginning_end_of_line(cx: &mut TestAppContext) {
     });
 }
 
+#[gpui::test]
+fn test_beginning_end_of_line_ignore_soft_wrap(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+    let move_to_beg = MoveToBeginningOfLine {
+        stop_at_soft_wraps: false,
+    };
+
+    let move_to_end = MoveToEndOfLine {
+        stop_at_soft_wraps: false,
+    };
+
+    let view = cx.add_window(|cx| {
+        let buffer = MultiBuffer::build_simple("thequickbrownfox\njumpedoverthelazydogs", cx);
+        build_editor(buffer, cx)
+    });
+
+    _ = view.update(cx, |view, cx| {
+        view.set_wrap_width(Some(140.0.into()), cx);
+
+        // We expect the following lines after wrapping
+        // ```
+        // thequickbrownfox
+        // jumpedoverthelazydo
+        // gs
+        // ```
+        // The final `gs` was soft-wrapped onto a new line.
+        assert_eq!(
+            "thequickbrownfox\njumpedoverthelaz\nydogs",
+            view.display_text(cx),
+        );
+
+        // First, let's assert behavior on the first line, that was not soft-wrapped.
+        // Start the cursor at the `k` on the first line
+        view.change_selections(None, cx, |s| {
+            s.select_display_ranges([DisplayPoint::new(0, 7)..DisplayPoint::new(0, 7)]);
+        });
+
+        // Moving to the beginning of the line should put us at the beginning of the line.
+        view.move_to_beginning_of_line(&move_to_beg, cx);
+        assert_eq!(
+            vec![DisplayPoint::new(0, 0)..DisplayPoint::new(0, 0),],
+            view.selections.display_ranges(cx)
+        );
+
+        // Moving to the end of the line should put us at the end of the line.
+        view.move_to_end_of_line(&move_to_end, cx);
+        assert_eq!(
+            vec![DisplayPoint::new(0, 16)..DisplayPoint::new(0, 16),],
+            view.selections.display_ranges(cx)
+        );
+
+        // Now, let's assert behavior on the second line, that ended up being soft-wrapped.
+        // Start the cursor at the last line (`y` that was wrapped to a new line)
+        view.change_selections(None, cx, |s| {
+            s.select_display_ranges([DisplayPoint::new(2, 0)..DisplayPoint::new(2, 0)]);
+        });
+
+        // Moving to the beginning of the line should put us at the start of the second line of
+        // display text, i.e., the `j`.
+        view.move_to_beginning_of_line(&move_to_beg, cx);
+        assert_eq!(
+            vec![DisplayPoint::new(1, 0)..DisplayPoint::new(1, 0),],
+            view.selections.display_ranges(cx)
+        );
+
+        // Moving to the beginning of the line again should be a no-op.
+        view.move_to_beginning_of_line(&move_to_beg, cx);
+        assert_eq!(
+            vec![DisplayPoint::new(1, 0)..DisplayPoint::new(1, 0),],
+            view.selections.display_ranges(cx)
+        );
+
+        // Moving to the end of the line should put us right after the `s` that was soft-wrapped to the
+        // next display line.
+        view.move_to_end_of_line(&move_to_end, cx);
+        assert_eq!(
+            vec![DisplayPoint::new(2, 5)..DisplayPoint::new(2, 5),],
+            view.selections.display_ranges(cx)
+        );
+
+        // Moving to the end of the line again should be a no-op.
+        view.move_to_end_of_line(&move_to_end, cx);
+        assert_eq!(
+            vec![DisplayPoint::new(2, 5)..DisplayPoint::new(2, 5),],
+            view.selections.display_ranges(cx)
+        );
+    });
+}
+
 #[gpui::test]
 fn test_prev_next_word_boundary(cx: &mut TestAppContext) {
     init_test(cx, |_| {});

crates/language/src/language_settings.rs 🔗

@@ -13,6 +13,7 @@ use schemars::{
 use serde::{Deserialize, Serialize};
 use settings::{Settings, SettingsLocation, SettingsSources};
 use std::{num::NonZeroU32, path::Path, sync::Arc};
+use util::serde::default_true;
 
 impl<'a> Into<SettingsLocation<'a>> for &'a dyn File {
     fn into(self) -> SettingsLocation<'a> {
@@ -438,10 +439,6 @@ pub struct InlayHintSettings {
     pub scroll_debounce_ms: u64,
 }
 
-fn default_true() -> bool {
-    true
-}
-
 fn edit_debounce_ms() -> u64 {
     700
 }

crates/util/src/util.rs 🔗

@@ -5,6 +5,7 @@ mod git_author;
 pub mod github;
 pub mod http;
 pub mod paths;
+pub mod serde;
 #[cfg(any(test, feature = "test-support"))]
 pub mod test;