Make word deletions less greedy (#37352)

Closes https://github.com/zed-industries/zed/issues/37144

Adjusts `editor::DeleteToPreviousWordStart`,
`editor::DeleteToNextWordEnd`, `editor::DeleteToNextSubwordEnd` and
`editor::DeleteToPreviousSubwordStart` actions to

* take whitespace sequences with length >= 2 into account and stop after
removing them (whilst movement would also include the word after such
sequences)

* take current language's brackets into account and stop after removing
the text before them

The latter is configurable and can be disabled with `"ignore_brackets":
true` parameter in the action.

Release Notes:

- Improved word deletions to consider whitespace sequences and brackets
by default
This commit is contained in:
Kirill Bulatov
2025-09-03 17:48:17 +03:00
committed by GitHub
parent 7327ef662b
commit 0cbacb8500
14 changed files with 509 additions and 48 deletions
+359 -25
View File
@@ -2476,51 +2476,379 @@ async fn test_delete_to_beginning_of_line(cx: &mut TestAppContext) {
}
#[gpui::test]
fn test_delete_to_word_boundary(cx: &mut TestAppContext) {
async fn test_delete_to_word_boundary(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let editor = cx.add_window(|window, cx| {
let buffer = MultiBuffer::build_simple("one two three four", cx);
build_editor(buffer, window, cx)
});
let mut cx = EditorTestContext::new(cx).await;
_ = editor.update(cx, |editor, window, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_display_ranges([
// an empty selection - the preceding word fragment is deleted
DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 2),
// characters selected - they are deleted
DisplayPoint::new(DisplayRow(0), 9)..DisplayPoint::new(DisplayRow(0), 12),
])
});
// For an empty selection, the preceding word fragment is deleted.
// For non-empty selections, only selected characters are deleted.
cx.set_state("onˇe two t«hreˇ»e four");
cx.update_editor(|editor, window, cx| {
editor.delete_to_previous_word_start(
&DeleteToPreviousWordStart {
ignore_newlines: false,
ignore_brackets: false,
},
window,
cx,
);
assert_eq!(editor.buffer.read(cx).read(cx).text(), "e two te four");
});
cx.assert_editor_state("ˇe two tˇe four");
_ = editor.update(cx, |editor, window, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_display_ranges([
// an empty selection - the following word fragment is deleted
DisplayPoint::new(DisplayRow(0), 3)..DisplayPoint::new(DisplayRow(0), 3),
// characters selected - they are deleted
DisplayPoint::new(DisplayRow(0), 9)..DisplayPoint::new(DisplayRow(0), 10),
])
});
cx.set_state("e tˇwo te «fˇ»our");
cx.update_editor(|editor, window, cx| {
editor.delete_to_next_word_end(
&DeleteToNextWordEnd {
ignore_newlines: false,
ignore_brackets: false,
},
window,
cx,
);
assert_eq!(editor.buffer.read(cx).read(cx).text(), "e t te our");
});
cx.assert_editor_state("e tˇ te ˇour");
}
#[gpui::test]
async fn test_delete_whitespaces(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorTestContext::new(cx).await;
cx.set_state("here is some text ˇwith a space");
cx.update_editor(|editor, window, cx| {
editor.delete_to_previous_word_start(
&DeleteToPreviousWordStart {
ignore_newlines: false,
ignore_brackets: true,
},
window,
cx,
);
});
// Continuous whitespace sequences are removed entirely, words behind them are not affected by the deletion action.
cx.assert_editor_state("here is some textˇwith a space");
cx.set_state("here is some text ˇwith a space");
cx.update_editor(|editor, window, cx| {
editor.delete_to_previous_word_start(
&DeleteToPreviousWordStart {
ignore_newlines: false,
ignore_brackets: false,
},
window,
cx,
);
});
cx.assert_editor_state("here is some textˇwith a space");
cx.set_state("here is some textˇ with a space");
cx.update_editor(|editor, window, cx| {
editor.delete_to_next_word_end(
&DeleteToNextWordEnd {
ignore_newlines: false,
ignore_brackets: true,
},
window,
cx,
);
});
// Same happens in the other direction.
cx.assert_editor_state("here is some textˇwith a space");
cx.set_state("here is some textˇ with a space");
cx.update_editor(|editor, window, cx| {
editor.delete_to_next_word_end(
&DeleteToNextWordEnd {
ignore_newlines: false,
ignore_brackets: false,
},
window,
cx,
);
});
cx.assert_editor_state("here is some textˇwith a space");
cx.set_state("here is some textˇ with a space");
cx.update_editor(|editor, window, cx| {
editor.delete_to_next_word_end(
&DeleteToNextWordEnd {
ignore_newlines: true,
ignore_brackets: false,
},
window,
cx,
);
});
cx.assert_editor_state("here is some textˇwith a space");
cx.update_editor(|editor, window, cx| {
editor.delete_to_previous_word_start(
&DeleteToPreviousWordStart {
ignore_newlines: true,
ignore_brackets: false,
},
window,
cx,
);
});
cx.assert_editor_state("here is some ˇwith a space");
cx.update_editor(|editor, window, cx| {
editor.delete_to_previous_word_start(
&DeleteToPreviousWordStart {
ignore_newlines: true,
ignore_brackets: false,
},
window,
cx,
);
});
// Single whitespaces are removed with the word behind them.
cx.assert_editor_state("here is ˇwith a space");
cx.update_editor(|editor, window, cx| {
editor.delete_to_previous_word_start(
&DeleteToPreviousWordStart {
ignore_newlines: true,
ignore_brackets: false,
},
window,
cx,
);
});
cx.assert_editor_state("here ˇwith a space");
cx.update_editor(|editor, window, cx| {
editor.delete_to_previous_word_start(
&DeleteToPreviousWordStart {
ignore_newlines: true,
ignore_brackets: false,
},
window,
cx,
);
});
cx.assert_editor_state("ˇwith a space");
cx.update_editor(|editor, window, cx| {
editor.delete_to_previous_word_start(
&DeleteToPreviousWordStart {
ignore_newlines: true,
ignore_brackets: false,
},
window,
cx,
);
});
cx.assert_editor_state("ˇwith a space");
cx.update_editor(|editor, window, cx| {
editor.delete_to_next_word_end(
&DeleteToNextWordEnd {
ignore_newlines: true,
ignore_brackets: false,
},
window,
cx,
);
});
// Same happens in the other direction.
cx.assert_editor_state("ˇ a space");
cx.update_editor(|editor, window, cx| {
editor.delete_to_next_word_end(
&DeleteToNextWordEnd {
ignore_newlines: true,
ignore_brackets: false,
},
window,
cx,
);
});
cx.assert_editor_state("ˇ space");
cx.update_editor(|editor, window, cx| {
editor.delete_to_next_word_end(
&DeleteToNextWordEnd {
ignore_newlines: true,
ignore_brackets: false,
},
window,
cx,
);
});
cx.assert_editor_state("ˇ");
cx.update_editor(|editor, window, cx| {
editor.delete_to_next_word_end(
&DeleteToNextWordEnd {
ignore_newlines: true,
ignore_brackets: false,
},
window,
cx,
);
});
cx.assert_editor_state("ˇ");
cx.update_editor(|editor, window, cx| {
editor.delete_to_previous_word_start(
&DeleteToPreviousWordStart {
ignore_newlines: true,
ignore_brackets: false,
},
window,
cx,
);
});
cx.assert_editor_state("ˇ");
}
#[gpui::test]
async fn test_delete_to_bracket(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let language = Arc::new(
Language::new(
LanguageConfig {
brackets: BracketPairConfig {
pairs: vec![
BracketPair {
start: "\"".to_string(),
end: "\"".to_string(),
close: true,
surround: true,
newline: false,
},
BracketPair {
start: "(".to_string(),
end: ")".to_string(),
close: true,
surround: true,
newline: true,
},
],
..BracketPairConfig::default()
},
..LanguageConfig::default()
},
Some(tree_sitter_rust::LANGUAGE.into()),
)
.with_brackets_query(
r#"
("(" @open ")" @close)
("\"" @open "\"" @close)
"#,
)
.unwrap(),
);
let mut cx = EditorTestContext::new(cx).await;
cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx));
cx.set_state(r#"macro!("// ˇCOMMENT");"#);
cx.update_editor(|editor, window, cx| {
editor.delete_to_previous_word_start(
&DeleteToPreviousWordStart {
ignore_newlines: true,
ignore_brackets: false,
},
window,
cx,
);
});
// Deletion stops before brackets if asked to not ignore them.
cx.assert_editor_state(r#"macro!("ˇCOMMENT");"#);
cx.update_editor(|editor, window, cx| {
editor.delete_to_previous_word_start(
&DeleteToPreviousWordStart {
ignore_newlines: true,
ignore_brackets: false,
},
window,
cx,
);
});
// Deletion has to remove a single bracket and then stop again.
cx.assert_editor_state(r#"macro!(ˇCOMMENT");"#);
cx.update_editor(|editor, window, cx| {
editor.delete_to_previous_word_start(
&DeleteToPreviousWordStart {
ignore_newlines: true,
ignore_brackets: false,
},
window,
cx,
);
});
cx.assert_editor_state(r#"macro!ˇCOMMENT");"#);
cx.update_editor(|editor, window, cx| {
editor.delete_to_previous_word_start(
&DeleteToPreviousWordStart {
ignore_newlines: true,
ignore_brackets: false,
},
window,
cx,
);
});
cx.assert_editor_state(r#"ˇCOMMENT");"#);
cx.update_editor(|editor, window, cx| {
editor.delete_to_previous_word_start(
&DeleteToPreviousWordStart {
ignore_newlines: true,
ignore_brackets: false,
},
window,
cx,
);
});
cx.assert_editor_state(r#"ˇCOMMENT");"#);
cx.update_editor(|editor, window, cx| {
editor.delete_to_next_word_end(
&DeleteToNextWordEnd {
ignore_newlines: true,
ignore_brackets: false,
},
window,
cx,
);
});
// Brackets on the right are not paired anymore, hence deletion does not stop at them
cx.assert_editor_state(r#"ˇ");"#);
cx.update_editor(|editor, window, cx| {
editor.delete_to_next_word_end(
&DeleteToNextWordEnd {
ignore_newlines: true,
ignore_brackets: false,
},
window,
cx,
);
});
cx.assert_editor_state(r#"ˇ"#);
cx.update_editor(|editor, window, cx| {
editor.delete_to_next_word_end(
&DeleteToNextWordEnd {
ignore_newlines: true,
ignore_brackets: false,
},
window,
cx,
);
});
cx.assert_editor_state(r#"ˇ"#);
cx.set_state(r#"macro!("// ˇCOMMENT");"#);
cx.update_editor(|editor, window, cx| {
editor.delete_to_previous_word_start(
&DeleteToPreviousWordStart {
ignore_newlines: true,
ignore_brackets: true,
},
window,
cx,
);
});
cx.assert_editor_state(r#"macroˇCOMMENT");"#);
}
#[gpui::test]
@@ -2533,9 +2861,11 @@ fn test_delete_to_previous_word_start_or_newline(cx: &mut TestAppContext) {
});
let del_to_prev_word_start = DeleteToPreviousWordStart {
ignore_newlines: false,
ignore_brackets: false,
};
let del_to_prev_word_start_ignore_newlines = DeleteToPreviousWordStart {
ignore_newlines: true,
ignore_brackets: false,
};
_ = editor.update(cx, |editor, window, cx| {
@@ -2569,9 +2899,11 @@ fn test_delete_to_next_word_end_or_newline(cx: &mut TestAppContext) {
});
let del_to_next_word_end = DeleteToNextWordEnd {
ignore_newlines: false,
ignore_brackets: false,
};
let del_to_next_word_end_ignore_newlines = DeleteToNextWordEnd {
ignore_newlines: true,
ignore_brackets: false,
};
_ = editor.update(cx, |editor, window, cx| {
@@ -2600,6 +2932,8 @@ fn test_delete_to_next_word_end_or_newline(cx: &mut TestAppContext) {
editor.delete_to_next_word_end(&del_to_next_word_end_ignore_newlines, window, cx);
assert_eq!(editor.buffer.read(cx).read(cx).text(), "\n four");
editor.delete_to_next_word_end(&del_to_next_word_end_ignore_newlines, window, cx);
assert_eq!(editor.buffer.read(cx).read(cx).text(), "four");
editor.delete_to_next_word_end(&del_to_next_word_end_ignore_newlines, window, cx);
assert_eq!(editor.buffer.read(cx).read(cx).text(), "");
});
}