diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index af370658fe0de57fce20932b71f9d3e1f9eb1831..ddac2b32499fd2c3431aeb3388ac0ae7b8daa956 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -244,6 +244,32 @@ pub struct DeleteToPreviousWordStart { pub ignore_brackets: bool, } +/// Deletes from the cursor to the end of the next subword. +/// Stops before the end of the next subword, if whitespace sequences of length >= 2 are encountered. +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] +#[action(namespace = editor)] +#[serde(deny_unknown_fields)] +pub struct DeleteToNextSubwordEnd { + #[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, +} + +/// Deletes from the cursor to the start of the previous subword. +/// Stops before the start of the previous subword, if whitespace sequences of length >= 2 are encountered. +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] +#[action(namespace = editor)] +#[serde(deny_unknown_fields)] +pub struct DeleteToPreviousSubwordStart { + #[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, +} + /// Cuts from cursor to end of line. #[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] #[action(namespace = editor)] @@ -450,10 +476,6 @@ actions!( DeleteLine, /// Deletes from cursor to end of line. DeleteToEndOfLine, - /// Deletes to the end of the next subword. - DeleteToNextSubwordEnd, - /// Deletes to the start of the previous subword. - DeleteToPreviousSubwordStart, /// Diffs the text stored in the clipboard against the current selection. DiffClipboardWithSelection, /// Displays names of all active cursors. diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 81d13d345a87e85d6b8d972c8476233686846a2d..de2e6b757bdd7968ed08341c5a4fe3c7675c6d23 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -14158,7 +14158,7 @@ impl Editor { pub fn delete_to_previous_subword_start( &mut self, - _: &DeleteToPreviousSubwordStart, + action: &DeleteToPreviousSubwordStart, window: &mut Window, cx: &mut Context, ) { @@ -14168,9 +14168,17 @@ impl Editor { this.change_selections(Default::default(), window, cx, |s| { s.move_with(|map, selection| { if selection.is_empty() { - let mut cursor = movement::previous_subword_start(map, selection.head()); - cursor = - movement::adjust_greedy_deletion(map, selection.head(), cursor, false); + let mut cursor = if action.ignore_newlines { + movement::previous_subword_start(map, selection.head()) + } else { + movement::previous_subword_start_or_newline(map, selection.head()) + }; + cursor = movement::adjust_greedy_deletion( + map, + selection.head(), + cursor, + action.ignore_brackets, + ); selection.set_head(cursor, SelectionGoal::None); } }); @@ -14267,7 +14275,7 @@ impl Editor { pub fn delete_to_next_subword_end( &mut self, - _: &DeleteToNextSubwordEnd, + action: &DeleteToNextSubwordEnd, window: &mut Window, cx: &mut Context, ) { @@ -14276,9 +14284,17 @@ impl Editor { this.change_selections(Default::default(), window, cx, |s| { s.move_with(|map, selection| { if selection.is_empty() { - let mut cursor = movement::next_subword_end(map, selection.head()); - cursor = - movement::adjust_greedy_deletion(map, selection.head(), cursor, false); + let mut cursor = if action.ignore_newlines { + movement::next_subword_end(map, selection.head()) + } else { + movement::next_subword_end_or_newline(map, selection.head()) + }; + cursor = movement::adjust_greedy_deletion( + map, + selection.head(), + cursor, + action.ignore_brackets, + ); selection.set_head(cursor, SelectionGoal::None); } }); diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 029501084998f2abdaf53483e1652ee47cbfdd55..f63644adb9808c28f68fb6b775b56cbc570d701e 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -3028,6 +3028,48 @@ fn test_delete_to_previous_word_start_or_newline(cx: &mut TestAppContext) { }); } +#[gpui::test] +fn test_delete_to_previous_subword_start_or_newline(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("fooBar\n\nbazQux", cx); + build_editor(buffer, window, cx) + }); + let del_to_prev_sub_word_start = DeleteToPreviousSubwordStart { + ignore_newlines: false, + ignore_brackets: false, + }; + let del_to_prev_sub_word_start_ignore_newlines = DeleteToPreviousSubwordStart { + ignore_newlines: true, + ignore_brackets: false, + }; + + _ = editor.update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(2), 6)..DisplayPoint::new(DisplayRow(2), 6) + ]) + }); + editor.delete_to_previous_subword_start(&del_to_prev_sub_word_start, window, cx); + assert_eq!(editor.buffer.read(cx).read(cx).text(), "fooBar\n\nbaz"); + editor.delete_to_previous_subword_start(&del_to_prev_sub_word_start, window, cx); + assert_eq!(editor.buffer.read(cx).read(cx).text(), "fooBar\n\n"); + editor.delete_to_previous_subword_start(&del_to_prev_sub_word_start, window, cx); + assert_eq!(editor.buffer.read(cx).read(cx).text(), "fooBar\n"); + editor.delete_to_previous_subword_start(&del_to_prev_sub_word_start, window, cx); + assert_eq!(editor.buffer.read(cx).read(cx).text(), "fooBar"); + editor.delete_to_previous_subword_start(&del_to_prev_sub_word_start, window, cx); + assert_eq!(editor.buffer.read(cx).read(cx).text(), "foo"); + editor.delete_to_previous_subword_start( + &del_to_prev_sub_word_start_ignore_newlines, + window, + cx, + ); + assert_eq!(editor.buffer.read(cx).read(cx).text(), ""); + }); +} + #[gpui::test] fn test_delete_to_next_word_end_or_newline(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -3077,6 +3119,50 @@ fn test_delete_to_next_word_end_or_newline(cx: &mut TestAppContext) { }); } +#[gpui::test] +fn test_delete_to_next_subword_end_or_newline(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("\nfooBar\n bazQux", cx); + build_editor(buffer, window, cx) + }); + let del_to_next_subword_end = DeleteToNextSubwordEnd { + ignore_newlines: false, + ignore_brackets: false, + }; + let del_to_next_subword_end_ignore_newlines = DeleteToNextSubwordEnd { + ignore_newlines: true, + ignore_brackets: false, + }; + + _ = editor.update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0) + ]) + }); + // Delete "\n" (empty line) + editor.delete_to_next_subword_end(&del_to_next_subword_end, window, cx); + assert_eq!(editor.buffer.read(cx).read(cx).text(), "fooBar\n bazQux"); + // Delete "foo" (subword boundary) + editor.delete_to_next_subword_end(&del_to_next_subword_end, window, cx); + assert_eq!(editor.buffer.read(cx).read(cx).text(), "Bar\n bazQux"); + // Delete "Bar" + editor.delete_to_next_subword_end(&del_to_next_subword_end, window, cx); + assert_eq!(editor.buffer.read(cx).read(cx).text(), "\n bazQux"); + // Delete "\n " (newline + leading whitespace) + editor.delete_to_next_subword_end(&del_to_next_subword_end, window, cx); + assert_eq!(editor.buffer.read(cx).read(cx).text(), "bazQux"); + // Delete "baz" (subword boundary) + editor.delete_to_next_subword_end(&del_to_next_subword_end, window, cx); + assert_eq!(editor.buffer.read(cx).read(cx).text(), "Qux"); + // With ignore_newlines, delete "Qux" + editor.delete_to_next_subword_end(&del_to_next_subword_end_ignore_newlines, window, cx); + assert_eq!(editor.buffer.read(cx).read(cx).text(), ""); + }); +} + #[gpui::test] fn test_newline(cx: &mut TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/editor/src/movement.rs b/crates/editor/src/movement.rs index 8635d89ed13e77d260307667740bf79ab4022e6f..abd983b96b1251b0ab08db1950efdaeef834f690 100644 --- a/crates/editor/src/movement.rs +++ b/crates/editor/src/movement.rs @@ -412,6 +412,21 @@ pub fn previous_subword_start(map: &DisplaySnapshot, point: DisplayPoint) -> Dis }) } +/// 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 or newline. +pub fn previous_subword_start_or_newline( + map: &DisplaySnapshot, + point: DisplayPoint, +) -> DisplayPoint { + let raw_point = point.to_point(map); + let classifier = map.buffer_snapshot().char_classifier_at(raw_point); + + find_preceding_boundary_display_point(map, point, FindRange::MultiLine, |left, right| { + (is_subword_start(left, right, &classifier)) || left == '\n' || right == '\n' + }) +} + pub fn is_subword_start(left: char, right: char, classifier: &CharClassifier) -> bool { let is_word_start = classifier.kind(left) != classifier.kind(right) && !right.is_whitespace(); let is_subword_start = classifier.is_word('-') && left == '-' && right != '-' @@ -473,13 +488,38 @@ pub fn next_subword_end(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPo }) } +/// Returns a position of the next 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 or newline. +pub fn next_subword_end_or_newline(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint { + let raw_point = point.to_point(map); + let classifier = map.buffer_snapshot().char_classifier_at(raw_point); + + let mut on_starting_row = true; + find_boundary(map, point, FindRange::MultiLine, |left, right| { + if left == '\n' { + on_starting_row = false; + } + ((classifier.kind(left) != classifier.kind(right) + || is_subword_boundary_end(left, right, &classifier)) + && ((on_starting_row && !left.is_whitespace()) + || (!on_starting_row && !right.is_whitespace()))) + || right == '\n' + }) +} + pub fn is_subword_end(left: char, right: char, classifier: &CharClassifier) -> bool { let is_word_end = (classifier.kind(left) != classifier.kind(right)) && !classifier.is_whitespace(left); - let is_subword_end = classifier.is_word('-') && left != '-' && right == '-' + is_word_end || is_subword_boundary_end(left, right, classifier) +} + +/// Returns true if the transition from `left` to `right` is a subword boundary, +/// such as case changes, underscores, or dashes. Does not include word boundaries like whitespace. +fn is_subword_boundary_end(left: char, right: char, classifier: &CharClassifier) -> bool { + classifier.is_word('-') && left != '-' && right == '-' || left != '_' && right == '_' - || left.is_lowercase() && right.is_uppercase(); - is_word_end || is_subword_end + || left.is_lowercase() && right.is_uppercase() } /// Returns a position of the start of the current paragraph, where a paragraph