From a966cd7a399ea8a8fe4461b938578d0e1c2ae2ae Mon Sep 17 00:00:00 2001 From: Pedro Yang Date: Thu, 5 Jun 2025 13:32:29 +0100 Subject: [PATCH] editor: Add classic text search functionality to Output panel Implements classic search in the Output (EditorLog) panel, including: - Search box with toggles for "Match Case" and "Whole Words" - Navigation buttons for next/previous match - Highlights matches, with distinction for the currently selected - Scroll to active match when navigating This improves usability for developers debugging or scanning log output by allowing quick and flexible keyword searching. This looks to implement what was referenced in godot-proposals features #12074 and #10249. Co-authored-by: Sun Chenwei fix: re-added a line that was deleted by accident fix : inconsistent text and unwanted checkbox being pushed off to the right. --- editor/editor_log.cpp | 429 ++++++++++++++++++++++++++++++++++++++++-- editor/editor_log.h | 45 ++++- 2 files changed, 455 insertions(+), 19 deletions(-) diff --git a/editor/editor_log.cpp b/editor/editor_log.cpp index 9a76b24ea85e..584f327a1218 100644 --- a/editor/editor_log.cpp +++ b/editor/editor_log.cpp @@ -38,9 +38,40 @@ #include "editor/editor_settings.h" #include "editor/editor_string_names.h" #include "editor/themes/editor_scale.h" +#include "scene/gui/check_box.h" +#include "scene/gui/label.h" #include "scene/gui/separator.h" #include "scene/resources/font.h" +static bool find_next_match(const String &line, const String &pattern, int from, bool match_case, bool whole_words, int &out_begin, int &out_end) { + int end = from; + + while (true) { + int begin = match_case ? line.find(pattern, end) : line.findn(pattern, end); + + if (begin == -1) { + return false; + } + + end = begin + pattern.length(); + out_begin = begin; + out_end = end; + + if (whole_words) { + if (begin > 0 && (is_ascii_identifier_char(line[begin - 1]))) { + continue; + } + if (end < line.size() && (is_ascii_identifier_char(line[end]))) { + continue; + } + } + + return true; + } +} + +//-------------------------------------------------------------------------------- + void EditorLog::_error_handler(void *p_self, const char *p_func, const char *p_file, int p_line, const char *p_error, const char *p_errorexp, bool p_editor_notify, ErrorHandlerType p_type) { EditorLog *self = static_cast(p_self); @@ -86,6 +117,14 @@ void EditorLog::_update_theme() { log->add_theme_font_override("mono_font", mono_font); } + if (search_prev_button) { + search_prev_button->set_button_icon(get_editor_theme_icon(SNAME("ArrowUp"))); + } + + if (search_next_button) { + search_next_button->set_button_icon(get_editor_theme_icon(SNAME("ArrowDown"))); + } + // Disable padding for highlighted background/foreground to prevent highlights from overlapping on close lines. // This also better matches terminal output, which does not use any form of padding. log->add_theme_constant_override("text_highlight_h_padding", 0); @@ -113,7 +152,6 @@ void EditorLog::_update_theme() { copy_button->set_button_icon(get_editor_theme_icon(SNAME("ActionCopy"))); collapse_button->set_button_icon(get_editor_theme_icon(SNAME("CombineLines"))); show_search_button->set_button_icon(get_editor_theme_icon(SNAME("Search"))); - search_box->set_right_icon(get_editor_theme_icon(SNAME("Search"))); theme_cache.error_color = get_theme_color(SNAME("error_color"), EditorStringName(Editor)); theme_cache.error_icon = get_editor_theme_icon(SNAME("Error")); @@ -130,6 +168,22 @@ void EditorLog::_editor_settings_changed() { } } +void EditorLog::set_whole_words(bool p_whole_word) { + _whole_words = p_whole_word; + if (search_mode && !search_box->get_text().is_empty()) { + _perform_classic_search(search_box->get_text()); + } + _start_state_save_timer(); +} + +void EditorLog::set_match_case(bool p_match_case) { + _match_case = p_match_case; + if (search_mode && !search_box->get_text().is_empty()) { + _perform_classic_search(search_box->get_text()); + } + _start_state_save_timer(); +} + void EditorLog::_notification(int p_what) { switch (p_what) { case NOTIFICATION_ENTER_TREE: { @@ -169,6 +223,9 @@ void EditorLog::_save_state() { config->set_value(section, "collapse", collapse); config->set_value(section, "show_search", search_box->is_visible()); + config->set_value(section, "search_mode", search_mode); + config->set_value(section, "match_case", _match_case); + config->set_value(section, "whole_words", _whole_words); config->save(EditorPaths::get_singleton()->get_project_settings_dir().path_join("editor_layout.cfg")); } @@ -191,6 +248,13 @@ void EditorLog::_load_state() { bool show_search = config->get_value(section, "show_search", true); search_box->set_visible(show_search); show_search_button->set_pressed(show_search); + search_mode = config->get_value(section, "search_mode", false); + search_mode_button->set_pressed(search_mode); + _match_case = config->get_value(section, "match_case", false); + _match_case_checkbox->set_pressed(_match_case); + _whole_words = config->get_value(section, "whole_words", false); + _whole_words_checkbox->set_pressed(_whole_words); + _on_search_mode_toggled(search_mode); is_loading_state = false; } @@ -267,7 +331,7 @@ void EditorLog::_undo_redo_cbk(void *p_self, const String &p_name) { self->add_message(p_name, EditorLog::MSG_TYPE_EDITOR); } -void EditorLog::_rebuild_log() { +void EditorLog::_rebuild_log(const String &p_search_highlight) { if (messages.is_empty()) { return; } @@ -282,13 +346,13 @@ void EditorLog::_rebuild_log() { for (start_message_index = messages.size() - 1; start_message_index >= 0; start_message_index--) { LogMessage msg = messages[start_message_index]; if (collapse) { - if (_check_display_message(msg)) { + if (_check_display_message(msg, search_mode)) { line_count++; } } else { // If not collapsing, log each instance on a line. for (int i = 0; i < msg.count; i++) { - if (_check_display_message(msg)) { + if (_check_display_message(msg, search_mode)) { line_count++; } } @@ -307,25 +371,32 @@ void EditorLog::_rebuild_log() { if (collapse) { // If collapsing, only log one instance of the message. - _add_log_line(msg); + _add_log_line(msg, false, msg_idx, 0, p_search_highlight); } else { // If not collapsing, log each instance on a line. for (int i = initial_skip; i < msg.count; i++) { initial_skip = 0; - _add_log_line(msg); + _add_log_line(msg, false, msg_idx, i, p_search_highlight); } } } } -bool EditorLog::_check_display_message(LogMessage &p_message) { +bool EditorLog::_check_display_message(LogMessage &p_message, bool p_ignore_search_filter) { bool filter_active = type_filter_map[p_message.type]->is_active(); - String search_text = search_box->get_text(); - bool search_match = search_text.is_empty() || p_message.text.containsn(search_text); - return filter_active && search_match; + + if (p_ignore_search_filter || search_mode) { + // In search mode or when explicitly ignoring search filter, only apply type filters + return filter_active; + } else { + // In filter mode, apply both type and text search filters + String search_text = search_box->get_text(); + bool search_match = search_text.is_empty() || p_message.text.containsn(search_text); + return filter_active && search_match; + } } -void EditorLog::_add_log_line(LogMessage &p_message, bool p_replace_previous) { +void EditorLog::_add_log_line(LogMessage &p_message, bool p_replace_previous, int p_message_index, int p_display_instance, const String &p_search_highlight) { if (!is_inside_tree()) { // The log will be built all at once when it enters the tree and has its theme items. return; @@ -384,11 +455,16 @@ void EditorLog::_add_log_line(LogMessage &p_message, bool p_replace_previous) { log->pop(); } - if (p_message.type == MSG_TYPE_STD_RICH) { - log->append_text(p_message.text); + if (!p_search_highlight.is_empty()) { + _add_text_with_search_highlighting(p_message.text, p_search_highlight, p_message_index, p_display_instance); } else { - log->add_text(p_message.text); + if (p_message.type == MSG_TYPE_STD_RICH) { + log->append_text(p_message.text); + } else { + log->add_text(p_message.text); + } } + if (p_message.clear || p_message.type != MSG_TYPE_STD_RICH) { log->pop_all(); // Pop all unclosed tags. } @@ -414,14 +490,269 @@ void EditorLog::_set_filter_active(bool p_active, MessageType p_message_type) { void EditorLog::_set_search_visible(bool p_visible) { search_box->set_visible(p_visible); + search_mode_button->set_visible(p_visible); + search_nav_container->set_visible(p_visible && search_mode); if (p_visible) { search_box->grab_focus(); + } else { + if (search_mode) { + _clear_search_highlights(); + } } _start_state_save_timer(); } void EditorLog::_search_changed(const String &p_text) { - _rebuild_log(); + if (search_mode) { + // Classic search mode + _perform_classic_search(p_text); + } else { + // Filter mode (existing functionality) + _rebuild_log(); + } +} + +void EditorLog::_on_search_mode_toggled(bool p_pressed) { + search_mode = p_pressed; + + if (search_mode) { + // Switch to classic search mode + search_mode_button->set_tooltip_text(TTR("Switch to Filter Mode")); + search_mode_button->set_button_icon(get_editor_theme_icon(SNAME("AnimationFilter"))); + search_box->set_placeholder(TTR("Find")); + search_box->set_accessibility_name(TTRC("Find")); + + // Show navigation buttons + search_nav_container->set_visible(search_box->is_visible()); + + // Perform search if there's text + String search_text = search_box->get_text(); + if (!search_text.is_empty()) { + _perform_classic_search(search_text); + } + } else { + // Switch to filter mode + search_mode_button->set_tooltip_text(TTR("Switch to Search Mode")); + search_mode_button->set_button_icon(get_editor_theme_icon(SNAME("Search"))); + search_box->set_placeholder(TTR("Filter Messages")); + search_box->set_accessibility_name(TTRC("Filter Messages")); + + // Hide navigation buttons + search_nav_container->set_visible(false); + + // Clear search highlights and rebuild log with filter + _clear_search_highlights(); + _rebuild_log(); + } + + _start_state_save_timer(); +} + +void EditorLog::_perform_classic_search(const String &p_text) { + search_matches.clear(); + current_search_index = -1; + + if (p_text.is_empty()) { + _clear_search_highlights(); + _update_search_navigation(); + _rebuild_log(); + return; + } + + _find_search_matches(p_text); + + if (!search_matches.is_empty()) { + current_search_index = 0; + bool original_scroll_follow = log->is_scroll_following(); + log->set_scroll_follow(false); + + _rebuild_log(p_text); + + callable_mp(this, &EditorLog::_scroll_to_current_match_deferred) + .call_deferred(original_scroll_follow); + } else { + _rebuild_log(p_text); + } + + _update_search_navigation(); +} +void EditorLog::_find_search_matches(const String &p_search_text) { + int visible_paragraph_index = 0; + int start_message_index = 0; + + for (int msg_idx = start_message_index; msg_idx < messages.size(); msg_idx++) { + LogMessage msg = messages[msg_idx]; + + if (!_check_display_message(msg, true)) { + continue; + } + + int message_display_count = collapse ? 1 : msg.count; + Vector match_positions = _find_matches_in_text(msg.text, p_search_text); + + for (int display_instance = 0; display_instance < message_display_count; display_instance++) { + for (int match_pos : match_positions) { + SearchMatch match; + match.message_index = msg_idx; + match.visible_index = visible_paragraph_index; + match.text_position = match_pos; + match.display_instance = display_instance; + search_matches.push_back(match); + } + visible_paragraph_index++; + } + } +} +Vector EditorLog::_find_matches_in_text(const String &p_text, const String &p_pattern) { + Vector matches; + int pos = 0; + int match_begin, match_end; + + while (find_next_match(p_text, p_pattern, pos, _match_case, _whole_words, match_begin, match_end)) { + matches.push_back(match_begin); + pos = match_end; + } + + return matches; +} + +void EditorLog::_add_text_with_search_highlighting(const String &p_text, const String &p_search_text, int p_message_index, int p_display_instance) { + if (p_search_text.is_empty()) { + log->add_text(p_text); + return; + } + + Vector match_positions; + int pos = 0; + int match_begin, match_end; + while (find_next_match(p_text, p_search_text, pos, _match_case, _whole_words, match_begin, match_end)) { + match_positions.push_back(match_begin); + pos = match_end; + } + + if (match_positions.is_empty()) { + log->add_text(p_text); + return; + } + + // Determine which match (if any) is the current one + int current_match_position = -1; + if (current_search_index >= 0 && current_search_index < search_matches.size()) { + const SearchMatch ¤t_match = search_matches[current_search_index]; + if (current_match.message_index == p_message_index && + current_match.display_instance == p_display_instance) { + current_match_position = current_match.text_position; + } + } + + // Add text with highlights + int last_pos = 0; + for (int match_pos : match_positions) { + // Add text before match + if (match_pos > last_pos) { + log->add_text(p_text.substr(last_pos, match_pos - last_pos)); + } + + // Determine highlight color based on whether this is the current match + bool is_current_match = (match_pos == current_match_position); + + if (is_current_match) { + // Highlight current match with orange/red color + log->push_bgcolor(Color(1.0, 0.5, 0.0, 0.7)); // Orange highlight for current match + } else { + // Highlight other matches with yellow color + log->push_bgcolor(Color(1.0, 1.0, 0.0, 0.3)); // Yellow highlight for other matches + } + + log->add_text(p_text.substr(match_pos, p_search_text.length())); + log->pop(); // bgcolor + + last_pos = match_pos + p_search_text.length(); + } + + // Add remaining text + if (last_pos < p_text.length()) { + log->add_text(p_text.substr(last_pos)); + } +} + +void EditorLog::_scroll_to_current_match() { + if (search_matches.is_empty() || current_search_index < 0 || current_search_index >= search_matches.size()) { + return; + } + + const SearchMatch &match = search_matches[current_search_index]; + + // Wait for any pending updates to complete + if (log->get_pending_paragraphs() > 0) { + log->wait_until_finished(); + } + + // Use the visible_index directly - it represents the paragraph position + int target_paragraph = match.visible_index; + + // Add some context above the target + target_paragraph = MAX(0, target_paragraph - 2); + + // Get total paragraph count for validation + int paragraph_count = log->get_paragraph_count(); + + if (target_paragraph < paragraph_count) { + log->scroll_to_paragraph(target_paragraph); + } +} + +void EditorLog::_scroll_to_current_match_deferred(bool p_restore_scroll_follow) { + _scroll_to_current_match(); + + // Restore the original scroll follow state + log->set_scroll_follow(p_restore_scroll_follow); +} + +void EditorLog::_navigate_search_result(bool p_next) { + if (search_matches.is_empty()) { + return; + } + // Store the scroll follow state and disable it + bool original_scroll_follow = log->is_scroll_following(); + log->set_scroll_follow(false); + + if (p_next) { + current_search_index = (current_search_index + 1) % search_matches.size(); + } else { + current_search_index = (current_search_index - 1 + search_matches.size()) % search_matches.size(); + } + // Update the search navigation immediately + _update_search_navigation(); + + // Refresh the log with updated highlighting + String search_text = search_box->get_text(); + if (!search_text.is_empty()) { + _rebuild_log(search_text); + + // Use call_deferred to scroll after the log has been fully rebuilt + callable_mp(this, &EditorLog::_scroll_to_current_match_deferred).call_deferred(original_scroll_follow); + } +} + +void EditorLog::_update_search_navigation() { + if (search_matches.is_empty()) { + search_results_label->set_text(TTR("No match")); + search_prev_button->set_disabled(true); + search_next_button->set_disabled(true); + } else { + String results_text = vformat(TTRN("%d of %d match", "%d of %d matches", search_matches.size()), current_search_index + 1, search_matches.size()); + search_results_label->set_text(results_text); + search_prev_button->set_disabled(false); + search_next_button->set_disabled(false); + } +} + +void EditorLog::_clear_search_highlights() { + // Simply rebuild the log without highlighting + if (search_mode && !search_box->get_text().is_empty()) { + _rebuild_log(); + } } void EditorLog::_reset_message_counts() { @@ -462,6 +793,11 @@ EditorLog::EditorLog() { log->connect("meta_clicked", callable_mp(this, &EditorLog::_meta_clicked)); vb_left->add_child(log); + // Search container - holds both search box and mode toggle + HBoxContainer *search_container = memnew(HBoxContainer); + search_container->set_visible(true); // Will be controlled by show_search_button + vb_left->add_child(search_container); + // Search box search_box = memnew(LineEdit); search_box->set_h_size_flags(Control::SIZE_EXPAND_FILL); @@ -470,7 +806,68 @@ EditorLog::EditorLog() { search_box->set_clear_button_enabled(true); search_box->set_visible(true); search_box->connect(SceneStringName(text_changed), callable_mp(this, &EditorLog::_search_changed)); - vb_left->add_child(search_box); + search_container->add_child(search_box); + + // Search mode toggle button + search_mode_button = memnew(Button); + search_mode_button->set_theme_type_variation(SceneStringName(FlatButton)); + search_mode_button->set_focus_mode(FOCUS_NONE); + search_mode_button->set_toggle_mode(true); + search_mode_button->set_pressed(false); + search_mode_button->set_tooltip_text(TTR("Switch to Search Mode")); + search_mode_button->connect(SceneStringName(toggled), callable_mp(this, &EditorLog::_on_search_mode_toggled)); + search_container->add_child(search_mode_button); + + // Search navigation container + search_nav_container = memnew(HBoxContainer); + search_nav_container->set_visible(false); // Hidden by default (filter mode) + vb_left->add_child(search_nav_container); + + // Previous search result button + search_prev_button = memnew(Button); + search_prev_button->set_theme_type_variation(SceneStringName(FlatButton)); + search_prev_button->set_focus_mode(FOCUS_NONE); + search_prev_button->set_tooltip_text(TTR("Previous Match")); + search_prev_button->set_shortcut(ED_SHORTCUT("editor/search_prev", TTRC("Previous Match"), Key::F3 | KeyModifierMask::SHIFT)); + search_prev_button->connect(SceneStringName(pressed), callable_mp(this, &EditorLog::_navigate_search_result).bind(false)); + search_nav_container->add_child(search_prev_button); + + // Next search result button + search_next_button = memnew(Button); + search_next_button->set_theme_type_variation(SceneStringName(FlatButton)); + search_next_button->set_focus_mode(FOCUS_NONE); + search_next_button->set_tooltip_text(TTR("Next Match")); + search_next_button->set_shortcut(ED_SHORTCUT("editor/search_next", TTRC("Next Match"), Key::F3)); + search_next_button->connect(SceneStringName(pressed), callable_mp(this, &EditorLog::_navigate_search_result).bind(true)); + search_nav_container->add_child(search_next_button); + + // Search results label + search_results_label = memnew(Label); + search_results_label->set_text(TTR("No match")); + search_nav_container->add_child(search_results_label); + + // Classic Search options + HBoxContainer *search_options = memnew(HBoxContainer); + search_options->set_h_size_flags(SIZE_EXPAND_FILL); + search_nav_container->add_child(search_options); + + Control *right_align_spacer = memnew(Control); + right_align_spacer->set_h_size_flags(SIZE_EXPAND_FILL); + search_options->add_child(right_align_spacer); + + _match_case_checkbox = memnew(CheckBox); + _match_case_checkbox->set_text(TTR("Match Case")); + _match_case_checkbox->set_focus_mode(FOCUS_NONE); + _match_case_checkbox->set_tooltip_text(TTR("Match case when searching")); + _match_case_checkbox->connect("toggled", callable_mp(this, &EditorLog::set_match_case)); + search_options->add_child(_match_case_checkbox); + + _whole_words_checkbox = memnew(CheckBox); + _whole_words_checkbox->set_text(TTR("Whole Words")); + _whole_words_checkbox->set_focus_mode(FOCUS_NONE); + _whole_words_checkbox->set_tooltip_text(TTR("Search for whole words only")); + _whole_words_checkbox->connect("toggled", callable_mp(this, &EditorLog::set_whole_words)); + search_options->add_child(_whole_words_checkbox); VBoxContainer *vb_right = memnew(VBoxContainer); hb->add_child(vb_right); diff --git a/editor/editor_log.h b/editor/editor_log.h index 498357f5ba9a..9eec3ae52dab 100644 --- a/editor/editor_log.h +++ b/editor/editor_log.h @@ -37,6 +37,7 @@ #include "scene/gui/rich_text_label.h" class UndoRedo; +class CheckBox; class EditorLog : public HBoxContainer { GDCLASS(EditorLog, HBoxContainer); @@ -50,6 +51,9 @@ class EditorLog : public HBoxContainer { MSG_TYPE_EDITOR, }; + bool is_whole_words() const { return _whole_words; } + bool is_match_case() const { return _match_case; } + private: struct LogMessage { String text; @@ -76,6 +80,13 @@ class EditorLog : public HBoxContainer { Color message_color; } theme_cache; + struct SearchMatch { + int message_index; + int visible_index; + int text_position; + int display_instance = 0; // Which instance of a repeated message (for non-collapsed mode) + }; + // Encapsulates all data and functionality regarding filters. struct LogFilter { private: @@ -139,6 +150,20 @@ class EditorLog : public HBoxContainer { Button *show_search_button = nullptr; LineEdit *search_box = nullptr; + HBoxContainer *search_nav_container = nullptr; + Button *search_mode_button = nullptr; + Button *search_prev_button = nullptr; + Button *search_next_button = nullptr; + Label *search_results_label = nullptr; + CheckBox *_match_case_checkbox = nullptr; + CheckBox *_whole_words_checkbox = nullptr; + bool search_mode = false; // If true, search box is in "search mode" (searches for text), otherwise it is in "filter mode" (filters messages by text). + bool _match_case = false; + bool _whole_words = false; + // Classic search state + int current_search_index = -1; + Vector search_matches; // Stores paragraph indices of matches + // Reference to the "Output" button on the toolbar so we can update its icon when warnings or errors are encountered. Button *tool_button = nullptr; @@ -155,13 +180,27 @@ class EditorLog : public HBoxContainer { void _copy_request(); static void _undo_redo_cbk(void *p_self, const String &p_name); - void _rebuild_log(); - void _add_log_line(LogMessage &p_message, bool p_replace_previous = false); - bool _check_display_message(LogMessage &p_message); + void _rebuild_log(const String &p_search_highlight = String()); + void _add_log_line(LogMessage &p_message, bool p_replace_previous = false, + int p_message_index = -1, int p_display_instance = 0, + const String &p_search_highlight = String()); + bool _check_display_message(LogMessage &p_message, bool p_ignore_search_filter = false); void _set_filter_active(bool p_active, MessageType p_message_type); void _set_search_visible(bool p_visible); void _search_changed(const String &p_text); + void set_whole_words(bool p_whole_word); + void set_match_case(bool p_match_case); + void _perform_classic_search(const String &p_text); + void _find_search_matches(const String &p_search_text); + Vector _find_matches_in_text(const String &p_text, const String &p_pattern); + void _add_text_with_search_highlighting(const String &p_text, const String &p_search_text, int p_message_index = -1, int p_display_instance = 0); + void _scroll_to_current_match(); + void _scroll_to_current_match_deferred(bool p_restore_scroll_follow); + void _navigate_search_result(bool p_next); + void _update_search_navigation(); + void _clear_search_highlights(); + void _on_search_mode_toggled(bool p_pressed); void _process_message(const String &p_msg, MessageType p_type, bool p_clear); void _reset_message_counts();