Add stop_at_indent for Editor::DeleteToBeginningOfLine (#25688)

Peter Tripp and Felix Packard created

Added test_beginning_of_line_stop_at_indent editor test

- Follow-up to: https://github.com/zed-industries/zed/pull/25428
- Replaces: https://github.com/zed-industries/zed/pull/25346

This is all authored by @felixpackard in #25346
I just updated it to use `stop_at_indent` instead of
`stop_at_first_char`.

Release Notes:

- Added support for `stop_at_indent` to
`Editor::DeleteToBeginningOfLine` (thanks
[@felixpackard](https://github.com/felixpackard))

Co-authored-by: Felix Packard <felix@rigr.gg>

Change summary

crates/editor/src/actions.rs      |   9 ++
crates/editor/src/editor.rs       |   4 
crates/editor/src/editor_tests.rs | 115 ++++++++++++++++++++++++++++++++
3 files changed, 123 insertions(+), 5 deletions(-)

Detailed changes

crates/editor/src/actions.rs 🔗

@@ -35,6 +35,13 @@ pub struct SelectToBeginningOfLine {
     pub stop_at_indent: bool,
 }
 
+#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)]
+#[serde(deny_unknown_fields)]
+pub struct DeleteToBeginningOfLine {
+    #[serde(default)]
+    pub(super) stop_at_indent: bool,
+}
+
 #[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)]
 #[serde(deny_unknown_fields)]
 pub struct MovePageUp {
@@ -226,6 +233,7 @@ impl_actions!(
         ComposeCompletion,
         ConfirmCodeAction,
         ConfirmCompletion,
+        DeleteToBeginningOfLine,
         DeleteToNextWordEnd,
         DeleteToPreviousWordStart,
         ExpandExcerpts,
@@ -292,7 +300,6 @@ gpui::actions!(
         CutToEndOfLine,
         Delete,
         DeleteLine,
-        DeleteToBeginningOfLine,
         DeleteToEndOfLine,
         DeleteToNextSubwordEnd,
         DeleteToPreviousSubwordStart,

crates/editor/src/editor.rs 🔗

@@ -9516,7 +9516,7 @@ impl Editor {
 
     pub fn delete_to_beginning_of_line(
         &mut self,
-        _: &DeleteToBeginningOfLine,
+        action: &DeleteToBeginningOfLine,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
@@ -9530,7 +9530,7 @@ impl Editor {
             this.select_to_beginning_of_line(
                 &SelectToBeginningOfLine {
                     stop_at_soft_wraps: false,
-                    stop_at_indent: false,
+                    stop_at_indent: action.stop_at_indent,
                 },
                 window,
                 cx,

crates/editor/src/editor_tests.rs 🔗

@@ -1514,6 +1514,10 @@ fn test_beginning_end_of_line(cx: &mut TestAppContext) {
         stop_at_indent: true,
     };
 
+    let delete_to_beg = DeleteToBeginningOfLine {
+        stop_at_indent: false,
+    };
+
     let move_to_end = MoveToEndOfLine {
         stop_at_soft_wraps: true,
     };
@@ -1672,7 +1676,7 @@ fn test_beginning_end_of_line(cx: &mut TestAppContext) {
     });
 
     _ = editor.update(cx, |editor, window, cx| {
-        editor.delete_to_beginning_of_line(&DeleteToBeginningOfLine, window, cx);
+        editor.delete_to_beginning_of_line(&delete_to_beg, window, cx);
         assert_eq!(editor.display_text(cx), "\n");
         assert_eq!(
             editor.selections.display_ranges(cx),
@@ -1778,6 +1782,107 @@ fn test_beginning_end_of_line_ignore_soft_wrap(cx: &mut TestAppContext) {
     });
 }
 
+#[gpui::test]
+fn test_beginning_of_line_stop_at_indent(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let move_to_beg = MoveToBeginningOfLine {
+        stop_at_soft_wraps: true,
+        stop_at_indent: true,
+    };
+
+    let select_to_beg = SelectToBeginningOfLine {
+        stop_at_soft_wraps: true,
+        stop_at_indent: true,
+    };
+
+    let delete_to_beg = DeleteToBeginningOfLine {
+        stop_at_indent: true,
+    };
+
+    let move_to_end = MoveToEndOfLine {
+        stop_at_soft_wraps: false,
+    };
+
+    let editor = cx.add_window(|window, cx| {
+        let buffer = MultiBuffer::build_simple("abc\n  def", cx);
+        build_editor(buffer, window, cx)
+    });
+
+    _ = editor.update(cx, |editor, window, cx| {
+        editor.change_selections(None, window, cx, |s| {
+            s.select_display_ranges([
+                DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(0), 1),
+                DisplayPoint::new(DisplayRow(1), 4)..DisplayPoint::new(DisplayRow(1), 4),
+            ]);
+        });
+
+        // Moving to the beginning of the line should put the first cursor at the beginning of the line,
+        // and the second cursor at the first non-whitespace character in the line.
+        editor.move_to_beginning_of_line(&move_to_beg, window, cx);
+        assert_eq!(
+            editor.selections.display_ranges(cx),
+            &[
+                DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0),
+                DisplayPoint::new(DisplayRow(1), 2)..DisplayPoint::new(DisplayRow(1), 2),
+            ]
+        );
+
+        // Moving to the beginning of the line again should be a no-op for the first cursor,
+        // and should move the second cursor to the beginning of the line.
+        editor.move_to_beginning_of_line(&move_to_beg, 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),
+            ]
+        );
+
+        // Moving to the beginning of the line again should still be a no-op for the first cursor,
+        // and should move the second cursor back to the first non-whitespace character in the line.
+        editor.move_to_beginning_of_line(&move_to_beg, window, cx);
+        assert_eq!(
+            editor.selections.display_ranges(cx),
+            &[
+                DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0),
+                DisplayPoint::new(DisplayRow(1), 2)..DisplayPoint::new(DisplayRow(1), 2),
+            ]
+        );
+
+        // Selecting to the beginning of the line should select to the beginning of the line for the first cursor,
+        // and to the first non-whitespace character in the line for the second cursor.
+        editor.move_to_end_of_line(&move_to_end, window, cx);
+        editor.move_left(&MoveLeft, window, cx);
+        editor.select_to_beginning_of_line(&select_to_beg, window, cx);
+        assert_eq!(
+            editor.selections.display_ranges(cx),
+            &[
+                DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 0),
+                DisplayPoint::new(DisplayRow(1), 4)..DisplayPoint::new(DisplayRow(1), 2),
+            ]
+        );
+
+        // Selecting to the beginning of the line again should be a no-op for the first cursor,
+        // and should select to the beginning of the line for the second cursor.
+        editor.select_to_beginning_of_line(&select_to_beg, window, cx);
+        assert_eq!(
+            editor.selections.display_ranges(cx),
+            &[
+                DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 0),
+                DisplayPoint::new(DisplayRow(1), 4)..DisplayPoint::new(DisplayRow(1), 0),
+            ]
+        );
+
+        // Deleting to the beginning of the line should delete to the beginning of the line for the first cursor,
+        // and should delete to the first non-whitespace character in the line for the second cursor.
+        editor.move_to_end_of_line(&move_to_end, window, cx);
+        editor.move_left(&MoveLeft, window, cx);
+        editor.delete_to_beginning_of_line(&delete_to_beg, window, cx);
+        assert_eq!(editor.text(cx), "c\n  f");
+    });
+}
+
 #[gpui::test]
 fn test_prev_next_word_boundary(cx: &mut TestAppContext) {
     init_test(cx, |_| {});
@@ -2295,7 +2400,13 @@ async fn test_delete_to_beginning_of_line(cx: &mut TestAppContext) {
     let mut cx = EditorTestContext::new(cx).await;
     cx.set_state("one «two threeˇ» four");
     cx.update_editor(|editor, window, cx| {
-        editor.delete_to_beginning_of_line(&DeleteToBeginningOfLine, window, cx);
+        editor.delete_to_beginning_of_line(
+            &DeleteToBeginningOfLine {
+                stop_at_indent: false,
+            },
+            window,
+            cx,
+        );
         assert_eq!(editor.text(cx), " four");
     });
 }