From 0cbacb850082e92aa1503efac06b0f595444a8d3 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Wed, 3 Sep 2025 17:48:17 +0300 Subject: [PATCH] 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 --- assets/keymaps/default-linux.json | 4 +- assets/keymaps/default-macos.json | 6 +- assets/keymaps/default-windows.json | 4 +- assets/keymaps/linux/emacs.json | 2 +- assets/keymaps/linux/sublime_text.json | 4 +- assets/keymaps/macos/emacs.json | 2 +- assets/keymaps/macos/sublime_text.json | 4 +- assets/keymaps/macos/textmate.json | 8 +- assets/keymaps/vim.json | 2 +- crates/editor/src/actions.rs | 8 + crates/editor/src/editor.rs | 24 +- crates/editor/src/editor_tests.rs | 382 +++++++++++++++++++++++-- crates/editor/src/movement.rs | 104 ++++++- crates/language/src/language.rs | 1 + 14 files changed, 508 insertions(+), 47 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index a60dc92844b337409e717b56975789073eb964fb..28518490ccbe9d3a4e8161ffbc32ed5c27ae0d84 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -63,8 +63,8 @@ "ctrl-k": "editor::CutToEndOfLine", "ctrl-k ctrl-q": "editor::Rewrap", "ctrl-k q": "editor::Rewrap", - "ctrl-backspace": "editor::DeleteToPreviousWordStart", - "ctrl-delete": "editor::DeleteToNextWordEnd", + "ctrl-backspace": ["editor::DeleteToPreviousWordStart", { "ignore_newlines": false, "ignore_brackets": false }], + "ctrl-delete": ["editor::DeleteToNextWordEnd", { "ignore_newlines": false, "ignore_brackets": false }], "cut": "editor::Cut", "shift-delete": "editor::Cut", "ctrl-x": "editor::Cut", diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index e72f4174ffe2afbd2605ed2bd859842f2c586107..954684c826b18828857c6411e2413aa514aeec45 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -70,9 +70,9 @@ "cmd-k q": "editor::Rewrap", "cmd-backspace": "editor::DeleteToBeginningOfLine", "cmd-delete": "editor::DeleteToEndOfLine", - "alt-backspace": "editor::DeleteToPreviousWordStart", - "ctrl-w": "editor::DeleteToPreviousWordStart", - "alt-delete": "editor::DeleteToNextWordEnd", + "alt-backspace": ["editor::DeleteToPreviousWordStart", { "ignore_newlines": false, "ignore_brackets": false }], + "ctrl-w": ["editor::DeleteToPreviousWordStart", { "ignore_newlines": false, "ignore_brackets": false }], + "alt-delete": ["editor::DeleteToNextWordEnd", { "ignore_newlines": false, "ignore_brackets": false }], "cmd-x": "editor::Cut", "cmd-c": "editor::Copy", "cmd-v": "editor::Paste", diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index dbd377409f4423dd12bac06b651efd079772dbb5..728907e60ca3361270f15b20f66aaf7571be6ac2 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -66,8 +66,8 @@ "ctrl-k": "editor::CutToEndOfLine", "ctrl-k ctrl-q": "editor::Rewrap", "ctrl-k q": "editor::Rewrap", - "ctrl-backspace": "editor::DeleteToPreviousWordStart", - "ctrl-delete": "editor::DeleteToNextWordEnd", + "ctrl-backspace": ["editor::DeleteToPreviousWordStart", { "ignore_newlines": false, "ignore_brackets": false }], + "ctrl-delete": ["editor::DeleteToNextWordEnd", { "ignore_newlines": false, "ignore_brackets": false }], "cut": "editor::Cut", "shift-delete": "editor::Cut", "ctrl-x": "editor::Cut", diff --git a/assets/keymaps/linux/emacs.json b/assets/keymaps/linux/emacs.json index 62910e297bb18f52917477806ceea1b79dcb5d86..0f936ba2f968abe0759e4bb294271a5e5f501848 100755 --- a/assets/keymaps/linux/emacs.json +++ b/assets/keymaps/linux/emacs.json @@ -42,7 +42,7 @@ "alt-,": "pane::GoBack", // xref-pop-marker-stack "ctrl-x h": "editor::SelectAll", // mark-whole-buffer "ctrl-d": "editor::Delete", // delete-char - "alt-d": "editor::DeleteToNextWordEnd", // kill-word + "alt-d": ["editor::DeleteToNextWordEnd", { "ignore_newlines": false, "ignore_brackets": false }], // kill-word "ctrl-k": "editor::KillRingCut", // kill-line "ctrl-w": "editor::Cut", // kill-region "alt-w": "editor::Copy", // kill-ring-save diff --git a/assets/keymaps/linux/sublime_text.json b/assets/keymaps/linux/sublime_text.json index ece9d69dd102c019072678373e9328f302d4cb07..f526db45ff29e0828ce58df6ca9816bd71a4cbe5 100644 --- a/assets/keymaps/linux/sublime_text.json +++ b/assets/keymaps/linux/sublime_text.json @@ -50,8 +50,8 @@ "ctrl-k ctrl-u": "editor::ConvertToUpperCase", "ctrl-k ctrl-l": "editor::ConvertToLowerCase", "shift-alt-m": "markdown::OpenPreviewToTheSide", - "ctrl-backspace": "editor::DeleteToPreviousWordStart", - "ctrl-delete": "editor::DeleteToNextWordEnd", + "ctrl-backspace": ["editor::DeleteToPreviousWordStart", { "ignore_newlines": false, "ignore_brackets": false }], + "ctrl-delete": ["editor::DeleteToNextWordEnd", { "ignore_newlines": false, "ignore_brackets": false }], "alt-right": "editor::MoveToNextSubwordEnd", "alt-left": "editor::MoveToPreviousSubwordStart", "alt-shift-right": "editor::SelectToNextSubwordEnd", diff --git a/assets/keymaps/macos/emacs.json b/assets/keymaps/macos/emacs.json index 62910e297bb18f52917477806ceea1b79dcb5d86..0f936ba2f968abe0759e4bb294271a5e5f501848 100755 --- a/assets/keymaps/macos/emacs.json +++ b/assets/keymaps/macos/emacs.json @@ -42,7 +42,7 @@ "alt-,": "pane::GoBack", // xref-pop-marker-stack "ctrl-x h": "editor::SelectAll", // mark-whole-buffer "ctrl-d": "editor::Delete", // delete-char - "alt-d": "editor::DeleteToNextWordEnd", // kill-word + "alt-d": ["editor::DeleteToNextWordEnd", { "ignore_newlines": false, "ignore_brackets": false }], // kill-word "ctrl-k": "editor::KillRingCut", // kill-line "ctrl-w": "editor::Cut", // kill-region "alt-w": "editor::Copy", // kill-ring-save diff --git a/assets/keymaps/macos/sublime_text.json b/assets/keymaps/macos/sublime_text.json index 9fa528c75fa75061c34d767c3e9f9082c9eb2a81..a1e61bf8859e2e4ea227ed3dbe22ec29eb35a149 100644 --- a/assets/keymaps/macos/sublime_text.json +++ b/assets/keymaps/macos/sublime_text.json @@ -52,8 +52,8 @@ "cmd-k cmd-l": "editor::ConvertToLowerCase", "cmd-shift-j": "editor::JoinLines", "shift-alt-m": "markdown::OpenPreviewToTheSide", - "ctrl-backspace": "editor::DeleteToPreviousWordStart", - "ctrl-delete": "editor::DeleteToNextWordEnd", + "ctrl-backspace": ["editor::DeleteToPreviousWordStart", { "ignore_newlines": false, "ignore_brackets": false }], + "ctrl-delete": ["editor::DeleteToNextWordEnd", { "ignore_newlines": false, "ignore_brackets": false }], "ctrl-right": "editor::MoveToNextSubwordEnd", "ctrl-left": "editor::MoveToPreviousSubwordStart", "ctrl-shift-right": "editor::SelectToNextSubwordEnd", diff --git a/assets/keymaps/macos/textmate.json b/assets/keymaps/macos/textmate.json index 0bd8873b1749d2423d97df480b1aadeb28fe9bab..f91f39b7f5c079f81b5fcf8e28e2092a33ff1aa4 100644 --- a/assets/keymaps/macos/textmate.json +++ b/assets/keymaps/macos/textmate.json @@ -21,10 +21,10 @@ { "context": "Editor", "bindings": { - "alt-backspace": "editor::DeleteToPreviousWordStart", - "alt-shift-backspace": "editor::DeleteToNextWordEnd", - "alt-delete": "editor::DeleteToNextWordEnd", - "alt-shift-delete": "editor::DeleteToNextWordEnd", + "alt-backspace": ["editor::DeleteToPreviousWordStart", { "ignore_newlines": false, "ignore_brackets": false }], + "alt-shift-backspace": ["editor::DeleteToNextWordEnd", { "ignore_newlines": false, "ignore_brackets": false }], + "alt-delete": ["editor::DeleteToNextWordEnd", { "ignore_newlines": false, "ignore_brackets": false }], + "alt-shift-delete": ["editor::DeleteToNextWordEnd", { "ignore_newlines": false, "ignore_brackets": false }], "ctrl-backspace": "editor::DeleteToPreviousSubwordStart", "ctrl-delete": "editor::DeleteToNextSubwordEnd", "alt-left": ["editor::MoveToPreviousWordStart", { "stop_at_soft_wraps": true }], diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index fd33b888b742bff8ba6a3c1b1ff15b8dbe0c11f8..fa7f82e1032ead9cb1f1ce12f3484602954123ca 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -337,7 +337,7 @@ "ctrl-x ctrl-z": "editor::Cancel", "ctrl-x ctrl-e": "vim::LineDown", "ctrl-x ctrl-y": "vim::LineUp", - "ctrl-w": "editor::DeleteToPreviousWordStart", + "ctrl-w": ["editor::DeleteToPreviousWordStart", { "ignore_newlines": false, "ignore_brackets": false }], "ctrl-u": "editor::DeleteToBeginningOfLine", "ctrl-t": "vim::Indent", "ctrl-d": "vim::Outdent", diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 3cc6c28464449907abbd19235f9123e44cca78ba..5b92f138c7bd494dd3d0fd30f3b8b3479995e53f 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -228,21 +228,29 @@ pub struct ShowCompletions { pub struct HandleInput(pub String); /// Deletes from the cursor to the end of the next word. +/// Stops before the end of the next word, if whitespace sequences of length >= 2 are encountered. #[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] #[action(namespace = editor)] #[serde(deny_unknown_fields)] pub struct DeleteToNextWordEnd { #[serde(default)] pub ignore_newlines: bool, + // Whether to stop before the end of the next word, if language-defined bracket is encountered. + #[serde(default)] + pub ignore_brackets: bool, } /// Deletes from the cursor to the start of the previous word. +/// Stops before the start of the previous word, if whitespace sequences of length >= 2 are encountered. #[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] #[action(namespace = editor)] #[serde(deny_unknown_fields)] pub struct DeleteToPreviousWordStart { #[serde(default)] pub ignore_newlines: bool, + // Whether to stop before the start of the previous word, if language-defined bracket is encountered. + #[serde(default)] + pub ignore_brackets: bool, } /// Folds all code blocks at the specified indentation level. diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index a8e2b001f330423e2108a22eb6f5113c3aa3e78b..5edc7f3c061efe05bd4112bcbf152695ab56c50d 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -13153,11 +13153,17 @@ impl Editor { this.change_selections(Default::default(), window, cx, |s| { s.move_with(|map, selection| { if selection.is_empty() { - let cursor = if action.ignore_newlines { + let mut cursor = if action.ignore_newlines { movement::previous_word_start(map, selection.head()) } else { movement::previous_word_start_or_newline(map, selection.head()) }; + cursor = movement::adjust_greedy_deletion( + map, + selection.head(), + cursor, + action.ignore_brackets, + ); selection.set_head(cursor, SelectionGoal::None); } }); @@ -13178,7 +13184,9 @@ impl Editor { this.change_selections(Default::default(), window, cx, |s| { s.move_with(|map, selection| { if selection.is_empty() { - let cursor = movement::previous_subword_start(map, selection.head()); + let mut cursor = movement::previous_subword_start(map, selection.head()); + cursor = + movement::adjust_greedy_deletion(map, selection.head(), cursor, false); selection.set_head(cursor, SelectionGoal::None); } }); @@ -13254,11 +13262,17 @@ impl Editor { this.change_selections(Default::default(), window, cx, |s| { s.move_with(|map, selection| { if selection.is_empty() { - let cursor = if action.ignore_newlines { + let mut cursor = if action.ignore_newlines { movement::next_word_end(map, selection.head()) } else { movement::next_word_end_or_newline(map, selection.head()) }; + cursor = movement::adjust_greedy_deletion( + map, + selection.head(), + cursor, + action.ignore_brackets, + ); selection.set_head(cursor, SelectionGoal::None); } }); @@ -13278,7 +13292,9 @@ impl Editor { this.change_selections(Default::default(), window, cx, |s| { s.move_with(|map, selection| { if selection.is_empty() { - let cursor = movement::next_subword_end(map, selection.head()); + let mut cursor = movement::next_subword_end(map, selection.head()); + cursor = + movement::adjust_greedy_deletion(map, selection.head(), cursor, false); selection.set_head(cursor, SelectionGoal::None); } }); diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 1893839ea67995d316dca64418cc05b13586ac4b..5efa3908256531e40845096187d44353ae140bbc 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -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; + + // 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, + ); }); + 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 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), - ]) - }); + 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, + ); + }); + 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, ); - assert_eq!(editor.buffer.read(cx).read(cx).text(), "e two te four"); }); + // 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"); - _ = 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("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, ); - assert_eq!(editor.buffer.read(cx).read(cx).text(), "e t te our"); }); + 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(), ""); }); } diff --git a/crates/editor/src/movement.rs b/crates/editor/src/movement.rs index 7a008e3ba257173429448a02be7abe5ab00cedec..216bea169683409b219641cc3496de9280bb05f6 100644 --- a/crates/editor/src/movement.rs +++ b/crates/editor/src/movement.rs @@ -289,12 +289,114 @@ pub fn previous_word_start_or_newline(map: &DisplaySnapshot, point: DisplayPoint let classifier = map.buffer_snapshot.char_classifier_at(raw_point); find_preceding_boundary_display_point(map, point, FindRange::MultiLine, |left, right| { - (classifier.kind(left) != classifier.kind(right) && !right.is_whitespace()) + (classifier.kind(left) != classifier.kind(right) && !classifier.is_whitespace(right)) || left == '\n' || right == '\n' }) } +/// Text movements are too greedy, making deletions too greedy too. +/// Makes deletions more ergonomic by potentially reducing the deletion range based on its text contents: +/// * whitespace sequences with length >= 2 stop the deletion after removal (despite movement jumping over the word behind the whitespaces) +/// * brackets stop the deletion after removal (despite movement currently not accounting for these and jumping over) +pub fn adjust_greedy_deletion( + map: &DisplaySnapshot, + delete_from: DisplayPoint, + delete_until: DisplayPoint, + ignore_brackets: bool, +) -> DisplayPoint { + if delete_from == delete_until { + return delete_until; + } + let is_backward = delete_from > delete_until; + let delete_range = if is_backward { + map.display_point_to_point(delete_until, Bias::Left) + .to_offset(&map.buffer_snapshot) + ..map + .display_point_to_point(delete_from, Bias::Right) + .to_offset(&map.buffer_snapshot) + } else { + map.display_point_to_point(delete_from, Bias::Left) + .to_offset(&map.buffer_snapshot) + ..map + .display_point_to_point(delete_until, Bias::Right) + .to_offset(&map.buffer_snapshot) + }; + + let trimmed_delete_range = if ignore_brackets { + delete_range + } else { + let brackets_in_delete_range = map + .buffer_snapshot + .bracket_ranges(delete_range.clone()) + .into_iter() + .flatten() + .flat_map(|(left_bracket, right_bracket)| { + [ + left_bracket.start, + left_bracket.end, + right_bracket.start, + right_bracket.end, + ] + }) + .filter(|&bracket| delete_range.start < bracket && bracket < delete_range.end); + let closest_bracket = if is_backward { + brackets_in_delete_range.max() + } else { + brackets_in_delete_range.min() + }; + + if is_backward { + closest_bracket.unwrap_or(delete_range.start)..delete_range.end + } else { + delete_range.start..closest_bracket.unwrap_or(delete_range.end) + } + }; + + let mut whitespace_sequences = Vec::new(); + let mut current_offset = trimmed_delete_range.start; + let mut whitespace_sequence_length = 0; + let mut whitespace_sequence_start = 0; + for ch in map + .buffer_snapshot + .text_for_range(trimmed_delete_range.clone()) + .flat_map(str::chars) + { + if ch.is_whitespace() { + if whitespace_sequence_length == 0 { + whitespace_sequence_start = current_offset; + } + whitespace_sequence_length += 1; + } else { + if whitespace_sequence_length >= 2 { + whitespace_sequences.push((whitespace_sequence_start, current_offset)); + } + whitespace_sequence_start = 0; + whitespace_sequence_length = 0; + } + current_offset += ch.len_utf8(); + } + if whitespace_sequence_length >= 2 { + whitespace_sequences.push((whitespace_sequence_start, current_offset)); + } + + let closest_whitespace_end = if is_backward { + whitespace_sequences.last().map(|&(start, _)| start) + } else { + whitespace_sequences.first().map(|&(_, end)| end) + }; + + closest_whitespace_end + .unwrap_or_else(|| { + if is_backward { + trimmed_delete_range.start + } else { + trimmed_delete_range.end + } + }) + .to_display_point(map) +} + /// Returns a position of the previous subword boundary, where a subword is defined as a run of /// word characters of the same "subkind" - where subcharacter kinds are '_' character, /// lowerspace characters and uppercase characters. diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index b349122193f1f31b323e03ff0421dfc3705c92fa..0606ae3de9be5800401787a852bafd5cfd9051be 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -1250,6 +1250,7 @@ struct InjectionPatternConfig { combined: bool, } +#[derive(Debug)] struct BracketsConfig { query: Query, open_capture_ix: u32,