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.
This commit is contained in:
Christophe Andral 2024-02-02 23:10:55 +01:00
parent 5d08c2631c
commit c988bec4b3
6 changed files with 220 additions and 1 deletions

View File

@ -383,6 +383,7 @@ static const _BuiltinActionDisplayName _builtin_action_display_names[] = {
{ "ui_text_select_all", TTRC("Select All") }, { "ui_text_select_all", TTRC("Select All") },
{ "ui_text_select_word_under_caret", TTRC("Select Word Under Caret") }, { "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_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_clear_carets_and_selection", TTRC("Clear Carets and Selection") },
{ "ui_text_toggle_insert_mode", TTRC("Toggle Insert Mode") }, { "ui_text_toggle_insert_mode", TTRC("Toggle Insert Mode") },
{ "ui_text_submit", TTRC("Submit Text") }, { "ui_text_submit", TTRC("Submit Text") },
@ -721,6 +722,10 @@ const HashMap<String, List<Ref<InputEvent>>> &InputMap::get_builtins() {
inputs.push_back(InputEventKey::create_reference(Key::D | KeyModifierMask::CMD_OR_CTRL)); inputs.push_back(InputEventKey::create_reference(Key::D | KeyModifierMask::CMD_OR_CTRL));
default_builtin_cache.insert("ui_text_add_selection_for_next_occurrence", inputs); default_builtin_cache.insert("ui_text_add_selection_for_next_occurrence", inputs);
inputs = List<Ref<InputEvent>>();
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<Ref<InputEvent>>(); inputs = List<Ref<InputEvent>>();
inputs.push_back(InputEventKey::create_reference(Key::ESCAPE)); inputs.push_back(InputEventKey::create_reference(Key::ESCAPE));
default_builtin_cache.insert("ui_text_clear_carets_and_selection", inputs); default_builtin_cache.insert("ui_text_clear_carets_and_selection", inputs);

View File

@ -1334,6 +1334,12 @@
<member name="input/ui_text_select_word_under_caret.macos" type="Dictionary" setter="" getter=""> <member name="input/ui_text_select_word_under_caret.macos" type="Dictionary" setter="" getter="">
macOS specific override for the shortcut to select the word currently under the caret. macOS specific override for the shortcut to select the word currently under the caret.
</member> </member>
<member name="input/ui_text_skip_selection_for_next_occurrence" type="Dictionary" setter="" getter="">
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.
</member>
<member name="input/ui_text_submit" type="Dictionary" setter="" getter=""> <member name="input/ui_text_submit" type="Dictionary" setter="" getter="">
Default [InputEventAction] to submit a text field. 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. [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.

View File

@ -1070,6 +1070,12 @@
Provide custom tooltip text. The callback method must take the following args: [code]hovered_word: String[/code]. Provide custom tooltip text. The callback method must take the following args: [code]hovered_word: String[/code].
</description> </description>
</method> </method>
<method name="skip_selection_for_next_occurrence">
<return type="void" />
<description>
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.
</description>
</method>
<method name="start_action"> <method name="start_action">
<return type="void" /> <return type="void" />
<param index="0" name="action" type="int" enum="TextEdit.EditAction" /> <param index="0" name="action" type="int" enum="TextEdit.EditAction" />

View File

@ -2115,7 +2115,7 @@ void TextEdit::gui_input(const Ref<InputEvent> &p_gui_input) {
} }
if (is_shortcut_keys_enabled()) { 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. // CLEAR CARETS AND SELECTIONS, CUT, COPY, PASTE.
if (k->is_action("ui_text_select_all", true)) { if (k->is_action("ui_text_select_all", true)) {
select_all(); select_all();
@ -2132,6 +2132,11 @@ void TextEdit::gui_input(const Ref<InputEvent> &p_gui_input) {
accept_event(); accept_event();
return; 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)) { 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. // Since the default shortcut is ESC, accepts the event only if it's actually performed.
if (_clear_carets_and_selection()) { 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) { 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()); ERR_FAIL_INDEX(p_caret, carets.size());
if (!selecting_enabled) { 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_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("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("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("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)); ClassDB::bind_method(D_METHOD("has_selection", "caret_index"), &TextEdit::has_selection, DEFVAL(-1));

View File

@ -890,6 +890,7 @@ public:
void select_all(); void select_all();
void select_word_under_caret(int p_caret = -1); void select_word_under_caret(int p_caret = -1);
void add_selection_for_next_occurrence(); 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); 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; bool has_selection(int p_caret = -1) const;

View File

@ -802,6 +802,153 @@ TEST_CASE("[SceneTree][TextEdit] text entry") {
CHECK(text_edit->get_selected_text(3) == "test"); 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") { SUBCASE("[TextEdit] deselect on focus loss") {
text_edit->set_text("test"); text_edit->set_text("test");