From c988bec4b3dff7ac32a1940a3f5c3d97a46ca8cd Mon Sep 17 00:00:00 2001 From: Christophe Andral Date: Fri, 2 Feb 2024 23:10:55 +0100 Subject: [PATCH] Add 'Skip to next (text) occurrence' feature to text editor Adds `ui_text_skip_selection_for_next_occurrence` action and related implementation to text editor. This action is bound `Ctrl+Alt+D` shorcut. Used in conjonction with `ui_add_skip_selection_for_next_occurrence`, it gives the user the ability to select many occurrences of a selection and avoid some of them. Used without a previous selection, the action jumps to the next occurrence of the current word under the caret. --- core/input/input_map.cpp | 5 ++ doc/classes/ProjectSettings.xml | 6 ++ doc/classes/TextEdit.xml | 6 ++ scene/gui/text_edit.cpp | 56 +++++++++++- scene/gui/text_edit.h | 1 + tests/scene/test_text_edit.h | 147 ++++++++++++++++++++++++++++++++ 6 files changed, 220 insertions(+), 1 deletion(-) diff --git a/core/input/input_map.cpp b/core/input/input_map.cpp index 5d6de1ad9a3..7fd1806b310 100644 --- a/core/input/input_map.cpp +++ b/core/input/input_map.cpp @@ -383,6 +383,7 @@ static const _BuiltinActionDisplayName _builtin_action_display_names[] = { { "ui_text_select_all", TTRC("Select All") }, { "ui_text_select_word_under_caret", TTRC("Select Word Under Caret") }, { "ui_text_add_selection_for_next_occurrence", TTRC("Add Selection for Next Occurrence") }, + { "ui_text_skip_selection_for_next_occurrence", TTRC("Skip Selection for Next Occurrence") }, { "ui_text_clear_carets_and_selection", TTRC("Clear Carets and Selection") }, { "ui_text_toggle_insert_mode", TTRC("Toggle Insert Mode") }, { "ui_text_submit", TTRC("Submit Text") }, @@ -721,6 +722,10 @@ const HashMap>> &InputMap::get_builtins() { inputs.push_back(InputEventKey::create_reference(Key::D | KeyModifierMask::CMD_OR_CTRL)); default_builtin_cache.insert("ui_text_add_selection_for_next_occurrence", inputs); + inputs = List>(); + inputs.push_back(InputEventKey::create_reference(Key::D | KeyModifierMask::CMD_OR_CTRL | KeyModifierMask::ALT)); + default_builtin_cache.insert("ui_text_skip_selection_for_next_occurrence", inputs); + inputs = List>(); inputs.push_back(InputEventKey::create_reference(Key::ESCAPE)); default_builtin_cache.insert("ui_text_clear_carets_and_selection", inputs); diff --git a/doc/classes/ProjectSettings.xml b/doc/classes/ProjectSettings.xml index d39722cdd01..48ac96a25f3 100644 --- a/doc/classes/ProjectSettings.xml +++ b/doc/classes/ProjectSettings.xml @@ -1334,6 +1334,12 @@ macOS specific override for the shortcut to select the word currently under the caret. + + If no selection is currently active with the last caret in text fields, searches for the next occurrence of the the word currently under the caret and moves the caret to the next occurrence. The action can be performed sequentially for other occurrences of the word under the last caret. + If a selection is currently active with the last caret in text fields, searches for the next occurrence of the selection, adds a caret, selects the next occurrence then deselects the previous selection and its associated caret. The action can be performed sequentially for other occurrences of the selection of the last caret. + The viewport is adjusted to the latest newly added caret. + [b]Note:[/b] Default [code]ui_*[/code] actions cannot be removed as they are necessary for the internal logic of several [Control]s. The events assigned to the action can however be modified. + Default [InputEventAction] to submit a text field. [b]Note:[/b] Default [code]ui_*[/code] actions cannot be removed as they are necessary for the internal logic of several [Control]s. The events assigned to the action can however be modified. diff --git a/doc/classes/TextEdit.xml b/doc/classes/TextEdit.xml index 04d05e7860b..92b54eef21f 100644 --- a/doc/classes/TextEdit.xml +++ b/doc/classes/TextEdit.xml @@ -1070,6 +1070,12 @@ Provide custom tooltip text. The callback method must take the following args: [code]hovered_word: String[/code]. + + + + Moves a selection and a caret for the next occurrence of the current selection. If there is no active selection, moves to the next occurrence of the word under caret. + + diff --git a/scene/gui/text_edit.cpp b/scene/gui/text_edit.cpp index 2d7a66d4c04..29e4956588a 100644 --- a/scene/gui/text_edit.cpp +++ b/scene/gui/text_edit.cpp @@ -2115,7 +2115,7 @@ void TextEdit::gui_input(const Ref &p_gui_input) { } if (is_shortcut_keys_enabled()) { - // SELECT ALL, SELECT WORD UNDER CARET, ADD SELECTION FOR NEXT OCCURRENCE, + // SELECT ALL, SELECT WORD UNDER CARET, ADD SELECTION FOR NEXT OCCURRENCE, SKIP SELECTION FOR NEXT OCCURRENCE, // CLEAR CARETS AND SELECTIONS, CUT, COPY, PASTE. if (k->is_action("ui_text_select_all", true)) { select_all(); @@ -2132,6 +2132,11 @@ void TextEdit::gui_input(const Ref &p_gui_input) { accept_event(); return; } + if (k->is_action("ui_text_skip_selection_for_next_occurrence", true)) { + skip_selection_for_next_occurrence(); + accept_event(); + return; + } if (k->is_action("ui_text_clear_carets_and_selection", true)) { // Since the default shortcut is ESC, accepts the event only if it's actually performed. if (_clear_carets_and_selection()) { @@ -5185,6 +5190,54 @@ void TextEdit::add_selection_for_next_occurrence() { } } +void TextEdit::skip_selection_for_next_occurrence() { + if (!selecting_enabled) { + return; + } + + if (text.size() == 1 && text[0].length() == 0) { + return; + } + + // Always use the last caret, to correctly search for + // the next occurrence that comes after this caret. + int caret = get_caret_count() - 1; + + // Supports getting the text under caret without selecting it. + // It allows to use this shortcut to simply jump to the next (under caret) word. + // Due to const and &(reference) presence, ternary operator is a way to avoid errors and warnings. + const String &searched_text = has_selection(caret) ? get_selected_text(caret) : get_word_under_caret(caret); + + int column = (has_selection(caret) ? get_selection_from_column(caret) : get_caret_column(caret)) + 1; + int line = get_caret_line(caret); + + const Point2i next_occurrence = search(searched_text, SEARCH_MATCH_CASE, line, column); + + if (next_occurrence.x == -1 || next_occurrence.y == -1) { + return; + } + + int to_column = (has_selection(caret) ? get_selection_to_column(caret) : get_caret_column(caret)) + 1; + int end = next_occurrence.x + (to_column - column); + int new_caret = add_caret(next_occurrence.y, end); + + if (new_caret != -1) { + select(next_occurrence.y, next_occurrence.x, next_occurrence.y, end, new_caret); + adjust_viewport_to_caret(new_caret); + merge_overlapping_carets(); + } + + // Deselect word under previous caret. + if (has_selection(caret)) { + select_word_under_caret(caret); + } + + // Remove previous caret. + if (get_caret_count() > 1) { + remove_caret(caret); + } +} + void TextEdit::select(int p_from_line, int p_from_column, int p_to_line, int p_to_column, int p_caret) { ERR_FAIL_INDEX(p_caret, carets.size()); if (!selecting_enabled) { @@ -6351,6 +6404,7 @@ void TextEdit::_bind_methods() { ClassDB::bind_method(D_METHOD("select_all"), &TextEdit::select_all); ClassDB::bind_method(D_METHOD("select_word_under_caret", "caret_index"), &TextEdit::select_word_under_caret, DEFVAL(-1)); ClassDB::bind_method(D_METHOD("add_selection_for_next_occurrence"), &TextEdit::add_selection_for_next_occurrence); + ClassDB::bind_method(D_METHOD("skip_selection_for_next_occurrence"), &TextEdit::skip_selection_for_next_occurrence); ClassDB::bind_method(D_METHOD("select", "from_line", "from_column", "to_line", "to_column", "caret_index"), &TextEdit::select, DEFVAL(0)); ClassDB::bind_method(D_METHOD("has_selection", "caret_index"), &TextEdit::has_selection, DEFVAL(-1)); diff --git a/scene/gui/text_edit.h b/scene/gui/text_edit.h index b8e30c7900d..1099295d3b5 100644 --- a/scene/gui/text_edit.h +++ b/scene/gui/text_edit.h @@ -890,6 +890,7 @@ public: void select_all(); void select_word_under_caret(int p_caret = -1); void add_selection_for_next_occurrence(); + void skip_selection_for_next_occurrence(); void select(int p_from_line, int p_from_column, int p_to_line, int p_to_column, int p_caret = 0); bool has_selection(int p_caret = -1) const; diff --git a/tests/scene/test_text_edit.h b/tests/scene/test_text_edit.h index 8f603c698da..8577dd71483 100644 --- a/tests/scene/test_text_edit.h +++ b/tests/scene/test_text_edit.h @@ -802,6 +802,153 @@ TEST_CASE("[SceneTree][TextEdit] text entry") { CHECK(text_edit->get_selected_text(3) == "test"); } + SUBCASE("[TextEdit] skip selection for next occurrence") { + text_edit->set_text("\ntest other_test\nrandom test\nword test word nonrandom"); + text_edit->set_caret_column(0); + text_edit->set_caret_line(1); + + // Without selection on the current caret, the caret as 'jumped' to the next occurrence of the word under the caret. + text_edit->skip_selection_for_next_occurrence(); + CHECK(text_edit->get_caret_count() == 1); + CHECK_FALSE(text_edit->has_selection(0)); + CHECK(text_edit->get_caret_line(0) == 1); + CHECK(text_edit->get_caret_column(0) == 13); + + // Repeating previous action. + // This time caret is in 'other_test' (other_|test) + // so the searched term will be 'other_test' or not just 'test' + // => no occurrence, as a side effect, the caret will move to start of the term. + text_edit->skip_selection_for_next_occurrence(); + CHECK(text_edit->get_caret_count() == 1); + CHECK_FALSE(text_edit->has_selection(0)); + CHECK(text_edit->get_caret_line(0) == 1); + CHECK(text_edit->get_caret_column(0) == 7); + + // Repeating action again should do nothing now + text_edit->skip_selection_for_next_occurrence(); + CHECK(text_edit->get_caret_count() == 1); + CHECK_FALSE(text_edit->has_selection(0)); + CHECK(text_edit->get_caret_line(0) == 1); + CHECK(text_edit->get_caret_column(0) == 7); + + // Moving back to the first 'test' occurrence. + text_edit->set_caret_column(0); + text_edit->set_caret_line(1); + + // But this time, create a selection of it. + text_edit->add_selection_for_next_occurrence(); + CHECK(text_edit->get_caret_count() == 1); + CHECK(text_edit->has_selection(0)); + CHECK(text_edit->get_selected_text(0) == "test"); + CHECK(text_edit->get_selection_from_line(0) == 1); + CHECK(text_edit->get_selection_from_column(0) == 0); + CHECK(text_edit->get_selection_to_line(0) == 1); + CHECK(text_edit->get_selection_to_column(0) == 4); + CHECK(text_edit->get_caret_line(0) == 1); + CHECK(text_edit->get_caret_column(0) == 4); + + // Then, skipping it, but this time, selection has been made on the next occurrence. + text_edit->skip_selection_for_next_occurrence(); + CHECK(text_edit->get_caret_count() == 1); + CHECK(text_edit->has_selection(0)); + CHECK(text_edit->get_selected_text(0) == "test"); + CHECK(text_edit->get_selection_from_line(0) == 1); + CHECK(text_edit->get_selection_from_column(0) == 13); + CHECK(text_edit->get_selection_to_line(0) == 1); + CHECK(text_edit->get_selection_to_column(0) == 17); + CHECK(text_edit->get_caret_line(0) == 1); + CHECK(text_edit->get_caret_column(0) == 17); + + text_edit->skip_selection_for_next_occurrence(); + CHECK(text_edit->get_caret_count() == 1); + CHECK(text_edit->has_selection(0)); + CHECK(text_edit->get_selected_text(0) == "test"); + CHECK(text_edit->get_selection_from_line(0) == 2); + CHECK(text_edit->get_selection_from_column(0) == 9); + CHECK(text_edit->get_selection_to_line(0) == 2); + CHECK(text_edit->get_selection_to_column(0) == 13); + CHECK(text_edit->get_caret_line(0) == 2); + CHECK(text_edit->get_caret_column(0) == 13); + + text_edit->skip_selection_for_next_occurrence(); + CHECK(text_edit->get_caret_count() == 1); + CHECK(text_edit->has_selection(0)); + CHECK(text_edit->get_selected_text(0) == "test"); + CHECK(text_edit->get_selection_from_line(0) == 3); + CHECK(text_edit->get_selection_from_column(0) == 5); + CHECK(text_edit->get_selection_to_line(0) == 3); + CHECK(text_edit->get_selection_to_column(0) == 9); + CHECK(text_edit->get_caret_line(0) == 3); + CHECK(text_edit->get_caret_column(0) == 9); + + // Last skip, we are back to the first occurrence. + text_edit->skip_selection_for_next_occurrence(); + CHECK(text_edit->has_selection(0)); + CHECK(text_edit->get_selected_text(0) == "test"); + CHECK(text_edit->get_selection_from_line(0) == 1); + CHECK(text_edit->get_selection_from_column(0) == 0); + CHECK(text_edit->get_selection_to_line(0) == 1); + CHECK(text_edit->get_selection_to_column(0) == 4); + CHECK(text_edit->get_caret_line(0) == 1); + CHECK(text_edit->get_caret_column(0) == 4); + + // Adding first occurrence to selections/carets list + // and select occurrence on 'other_test'. + text_edit->add_selection_for_next_occurrence(); + CHECK(text_edit->get_caret_count() == 2); + CHECK(text_edit->has_selection(0)); + CHECK(text_edit->get_selected_text(0) == "test"); + + CHECK(text_edit->has_selection(1)); + CHECK(text_edit->get_selected_text(1) == "test"); + CHECK(text_edit->get_selection_from_line(1) == 1); + CHECK(text_edit->get_selection_from_column(1) == 13); + CHECK(text_edit->get_selection_to_line(1) == 1); + CHECK(text_edit->get_selection_to_column(1) == 17); + CHECK(text_edit->get_caret_line(1) == 1); + CHECK(text_edit->get_caret_column(1) == 17); + + // We don't want this occurrence. + // Let's skip it. + text_edit->skip_selection_for_next_occurrence(); + CHECK(text_edit->get_caret_count() == 2); + CHECK(text_edit->has_selection(0)); + CHECK(text_edit->get_selected_text(0) == "test"); + + CHECK(text_edit->get_selected_text(1) == "test"); + CHECK(text_edit->get_selection_from_line(1) == 2); + CHECK(text_edit->get_selection_from_column(1) == 9); + CHECK(text_edit->get_selection_to_line(1) == 2); + CHECK(text_edit->get_selection_to_column(1) == 13); + CHECK(text_edit->get_caret_line(1) == 2); + CHECK(text_edit->get_caret_column(1) == 13); + + text_edit->skip_selection_for_next_occurrence(); + CHECK(text_edit->get_caret_count() == 2); + CHECK(text_edit->has_selection(0)); + CHECK(text_edit->get_selected_text(0) == "test"); + + CHECK(text_edit->get_selected_text(1) == "test"); + CHECK(text_edit->get_selection_from_line(1) == 3); + CHECK(text_edit->get_selection_from_column(1) == 5); + CHECK(text_edit->get_selection_to_line(1) == 3); + CHECK(text_edit->get_selection_to_column(1) == 9); + CHECK(text_edit->get_caret_line(1) == 3); + CHECK(text_edit->get_caret_column(1) == 9); + + // We are back the first occurrence. + text_edit->skip_selection_for_next_occurrence(); + CHECK(text_edit->get_caret_count() == 1); + CHECK(text_edit->has_selection(0)); + CHECK(text_edit->get_selected_text(0) == "test"); + CHECK(text_edit->get_selection_from_line(0) == 1); + CHECK(text_edit->get_selection_from_column(0) == 0); + CHECK(text_edit->get_selection_to_line(0) == 1); + CHECK(text_edit->get_selection_to_column(0) == 4); + CHECK(text_edit->get_caret_line(0) == 1); + CHECK(text_edit->get_caret_column(0) == 4); + } + SUBCASE("[TextEdit] deselect on focus loss") { text_edit->set_text("test");