From 4e8ad9a70693fee3e1e95c6fde2148ed860d1073 Mon Sep 17 00:00:00 2001 From: ssdfasd <2156608475@qq.com> Date: Wed, 1 Apr 2026 16:40:54 +0800 Subject: [PATCH] Unify panel search behavior and polish console UI --- .../AssetReferenceEditorUtils.h | 98 +-- editor/src/UI/ReferencePicker.h | 722 ++++++++++++++++++ editor/src/UI/SearchText.h | 50 ++ editor/src/UI/StyleTokens.h | 14 +- editor/src/UI/UI.h | 2 + editor/src/UI/Widgets.h | 26 +- editor/src/panels/ConsolePanel.cpp | 116 ++- editor/src/panels/HierarchyPanel.cpp | 93 ++- editor/src/panels/HierarchyPanel.h | 5 +- editor/src/panels/ProjectPanel.cpp | 41 +- editor/src/panels/ProjectPanel.h | 3 +- 11 files changed, 998 insertions(+), 172 deletions(-) create mode 100644 editor/src/UI/ReferencePicker.h create mode 100644 editor/src/UI/SearchText.h diff --git a/editor/src/ComponentEditors/AssetReferenceEditorUtils.h b/editor/src/ComponentEditors/AssetReferenceEditorUtils.h index da824ffd..c2287c7b 100644 --- a/editor/src/ComponentEditors/AssetReferenceEditorUtils.h +++ b/editor/src/ComponentEditors/AssetReferenceEditorUtils.h @@ -1,15 +1,7 @@ #pragma once -#include "Application.h" -#include "Actions/ProjectActionRouter.h" #include "UI/UI.h" -#include "Utils/ProjectFileUtils.h" -#include -#include -#include -#include -#include #include #include @@ -22,83 +14,35 @@ struct AssetReferenceInteraction { bool clearRequested = false; }; -inline std::string ToProjectRelativeAssetPath(const std::string& assetPath) { - if (assetPath.empty()) { - return {}; - } - - const std::string& projectPath = Application::Get().GetEditorContext().GetProjectPath(); - if (projectPath.empty()) { - return assetPath; - } - - return ProjectFileUtils::MakeProjectRelativePath(projectPath, assetPath); -} - -inline bool HasSupportedExtension( - const std::string& path, - std::initializer_list supportedExtensions) { - std::string extension = std::filesystem::path(path).extension().string(); - std::transform(extension.begin(), extension.end(), extension.begin(), [](unsigned char ch) { - return static_cast(std::tolower(ch)); - }); - - for (const char* supportedExtension : supportedExtensions) { - if (supportedExtension != nullptr && extension == supportedExtension) { - return true; - } - } - - return false; -} - inline AssetReferenceInteraction DrawAssetReferenceProperty( const char* label, const std::string& currentPath, const char* emptyHint, std::initializer_list supportedExtensions) { AssetReferenceInteraction interaction; + const std::string popupTitle = std::string("Select ") + (label ? label : "Object"); + UI::ReferencePickerOptions pickerOptions; + pickerOptions.popupTitle = popupTitle.c_str(); + pickerOptions.emptyHint = emptyHint; + pickerOptions.searchHint = "Search"; + pickerOptions.noAssetsText = "No compatible assets."; + pickerOptions.noSceneText = "No compatible scene objects."; + pickerOptions.assetsTabLabel = "Assets"; + pickerOptions.sceneTabLabel = "Scene"; + pickerOptions.showAssetsTab = true; + pickerOptions.showSceneTab = true; + pickerOptions.supportedAssetExtensions = supportedExtensions; UI::DrawPropertyRow(label, UI::InspectorPropertyLayout(), [&](const UI::PropertyLayoutMetrics& layout) { - constexpr float kClearButtonWidth = 52.0f; - - std::array buffer{}; - if (!currentPath.empty()) { - strncpy_s(buffer.data(), buffer.size(), currentPath.c_str(), _TRUNCATE); - } - - const float spacing = ImGui::GetStyle().ItemSpacing.x; - const float fieldWidth = (std::max)(layout.controlWidth - kClearButtonWidth - spacing, 1.0f); - ImGui::SetNextItemWidth(fieldWidth); - ImGui::InputTextWithHint( - "##AssetPath", - emptyHint, - buffer.data(), - buffer.size(), - ImGuiInputTextFlags_ReadOnly); - - if (ImGui::IsItemHovered() && !currentPath.empty()) { - ImGui::SetTooltip("%s", currentPath.c_str()); - } - - if (ImGui::BeginDragDropTarget()) { - if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload( - Actions::ProjectAssetPayloadType(), - ImGuiDragDropFlags_AcceptNoDrawDefaultRect)) { - if (payload->Data != nullptr) { - const std::string droppedPath(static_cast(payload->Data)); - if (HasSupportedExtension(droppedPath, supportedExtensions)) { - interaction.assignedPath = ToProjectRelativeAssetPath(droppedPath); - } - } - } - ImGui::EndDragDropTarget(); - } - - ImGui::SameLine(0.0f, spacing); - ImGui::BeginDisabled(currentPath.empty()); - interaction.clearRequested = UI::InspectorActionButton("Clear", ImVec2(kClearButtonWidth, 0.0f)); - ImGui::EndDisabled(); + UI::AlignPropertyControlVertically(layout, ImGui::GetFrameHeight()); + const UI::ReferencePickerInteraction pickerInteraction = + UI::DrawReferencePickerControl( + currentPath, + ::XCEngine::Components::GameObject::INVALID_ID, + pickerOptions, + layout.controlWidth); + interaction.assignedPath = pickerInteraction.assignedAssetPath; + interaction.clearRequested = pickerInteraction.clearRequested; return false; }); diff --git a/editor/src/UI/ReferencePicker.h b/editor/src/UI/ReferencePicker.h new file mode 100644 index 00000000..3c9bece9 --- /dev/null +++ b/editor/src/UI/ReferencePicker.h @@ -0,0 +1,722 @@ +#pragma once + +#include "Application.h" +#include "BuiltInIcons.h" +#include "Core.h" +#include "Core/IEditorContext.h" +#include "Core/IProjectManager.h" +#include "Core/ISceneManager.h" +#include "SearchText.h" +#include "StyleTokens.h" +#include "Utils/ProjectFileUtils.h" +#include "Widgets.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace XCEngine { +namespace Editor { +namespace UI { + +enum class ReferencePickerTab { + Assets = 0, + Scene = 1 +}; + +struct ReferencePickerOptions { + const char* popupTitle = "Select Object"; + const char* emptyHint = "None"; + const char* searchHint = "Search"; + const char* noAssetsText = "No compatible assets."; + const char* noSceneText = "No compatible scene objects."; + const char* assetsTabLabel = "Assets"; + const char* sceneTabLabel = "Scene"; + bool showAssetsTab = true; + bool showSceneTab = true; + std::initializer_list supportedAssetExtensions; + std::function sceneFilter; +}; + +struct ReferencePickerInteraction { + std::string assignedAssetPath; + ::XCEngine::Components::GameObject::ID assignedSceneObjectId = + ::XCEngine::Components::GameObject::INVALID_ID; + bool clearRequested = false; +}; + +namespace Detail { + +struct ReferencePickerState { + ReferencePickerTab activeTab = ReferencePickerTab::Assets; + std::array searchBuffer{}; +}; + +struct ReferencePickerCandidate { + std::string label; + std::string secondaryLabel; + std::string normalizedSearchText; + AssetIconKind iconKind = AssetIconKind::File; + std::string assetPath; + ::XCEngine::Components::GameObject::ID sceneObjectId = + ::XCEngine::Components::GameObject::INVALID_ID; + float indent = 0.0f; + + bool IsAsset() const { + return !assetPath.empty(); + } + + bool IsSceneObject() const { + return sceneObjectId != ::XCEngine::Components::GameObject::INVALID_ID; + } +}; + +inline ImVec4 ReferencePickerBackgroundColor() { + return HierarchyInspectorPanelBackgroundColor(); +} + +inline ImVec4 ReferencePickerSurfaceColor() { + return ProjectBrowserPaneBackgroundColor(); +} + +inline ImVec4 ReferencePickerBorderColor() { + return ImVec4(0.32f, 0.32f, 0.32f, 1.0f); +} + +inline ImVec4 ReferencePickerTextColor() { + return ImVec4(0.84f, 0.84f, 0.84f, 1.0f); +} + +inline ImVec4 ReferencePickerTextDisabledColor() { + return ImVec4(0.60f, 0.60f, 0.60f, 1.0f); +} + +inline ImVec4 ReferencePickerItemColor() { + return ToolbarButtonColor(false); +} + +inline ImVec4 ReferencePickerItemHoveredColor() { + return ToolbarButtonHoveredColor(false); +} + +inline ImVec4 ReferencePickerItemActiveColor() { + return ToolbarButtonActiveColor(); +} + +inline constexpr int ReferencePickerPopupStyleVarCount() { + return 3; +} + +inline constexpr int ReferencePickerPopupStyleColorCount() { + return 14; +} + +inline void PushReferencePickerPopupStyle() { + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, PopupWindowPadding()); + ImGui::PushStyleVar(ImGuiStyleVar_PopupRounding, PopupWindowRounding()); + ImGui::PushStyleVar(ImGuiStyleVar_PopupBorderSize, PopupWindowBorderSize()); + ImGui::PushStyleColor(ImGuiCol_PopupBg, ReferencePickerBackgroundColor()); + ImGui::PushStyleColor(ImGuiCol_Border, ReferencePickerBorderColor()); + ImGui::PushStyleColor(ImGuiCol_Text, ReferencePickerTextColor()); + ImGui::PushStyleColor(ImGuiCol_TextDisabled, ReferencePickerTextDisabledColor()); + ImGui::PushStyleColor(ImGuiCol_ChildBg, ReferencePickerSurfaceColor()); + ImGui::PushStyleColor(ImGuiCol_Header, ReferencePickerItemColor()); + ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ReferencePickerItemHoveredColor()); + ImGui::PushStyleColor(ImGuiCol_HeaderActive, ReferencePickerItemActiveColor()); + ImGui::PushStyleColor(ImGuiCol_FrameBg, ReferencePickerSurfaceColor()); + ImGui::PushStyleColor(ImGuiCol_FrameBgHovered, ReferencePickerItemHoveredColor()); + ImGui::PushStyleColor(ImGuiCol_FrameBgActive, ReferencePickerItemActiveColor()); + ImGui::PushStyleColor(ImGuiCol_Button, ReferencePickerItemColor()); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ReferencePickerItemHoveredColor()); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ReferencePickerItemActiveColor()); +} + +inline void PopReferencePickerPopupStyle() { + ImGui::PopStyleColor(ReferencePickerPopupStyleColorCount()); + ImGui::PopStyleVar(ReferencePickerPopupStyleVarCount()); +} + +inline bool BeginReferencePickerPopup(const char* id, const char* title) { + PushReferencePickerPopupStyle(); + const bool open = ImGui::BeginPopup(id); + if (!open) { + PopReferencePickerPopupStyle(); + return false; + } + + if (title && title[0] != '\0') { + ImGui::TextUnformatted(title); + ImGui::Separator(); + } + + return true; +} + +inline void EndReferencePickerPopup() { + ImGui::EndPopup(); + PopReferencePickerPopupStyle(); +} + +inline ReferencePickerState& GetReferencePickerState(ImGuiID id) { + static std::unordered_map states; + return states[id]; +} + +inline bool MatchesSupportedAssetExtension( + std::string_view extensionLower, + std::initializer_list supportedExtensions) { + if (supportedExtensions.size() == 0) { + return true; + } + + for (const char* supportedExtension : supportedExtensions) { + if (supportedExtension != nullptr && extensionLower == supportedExtension) { + return true; + } + } + + return false; +} + +inline AssetIconKind ResolveAssetIconKind(const AssetItemPtr& item) { + if (!item) { + return AssetIconKind::File; + } + + if (item->isFolder) { + return AssetIconKind::Folder; + } + + if (item->type == "Scene") { + return AssetIconKind::Scene; + } + + return AssetIconKind::File; +} + +inline AssetIconKind ResolveAssetIconKindFromPath(const std::string& path) { + std::string extension = std::filesystem::path(path).extension().string(); + std::transform(extension.begin(), extension.end(), extension.begin(), [](unsigned char ch) { + return static_cast(std::tolower(ch)); + }); + if (extension == ".xc" || extension == ".unity" || extension == ".scene") { + return AssetIconKind::Scene; + } + return AssetIconKind::File; +} + +inline std::string GetAssetDisplayName(const AssetItemPtr& item) { + if (!item) { + return {}; + } + + if (item->isFolder) { + return item->name; + } + + return std::filesystem::path(item->name).stem().string(); +} + +inline std::string GetAssetDisplayNameFromPath(const std::string& path) { + if (path.empty()) { + return {}; + } + + return std::filesystem::path(path).stem().string(); +} + +inline std::string BuildSceneHierarchyPath(const ::XCEngine::Components::GameObject* gameObject) { + if (!gameObject) { + return {}; + } + + std::vector names; + names.reserve(8); + const ::XCEngine::Components::GameObject* current = gameObject; + while (current) { + names.push_back(current->GetName()); + current = current->GetParent(); + } + + std::string path; + for (auto it = names.rbegin(); it != names.rend(); ++it) { + if (!path.empty()) { + path += "/"; + } + path += *it; + } + + return path; +} + +inline void CollectAssetCandidatesRecursive( + const AssetItemPtr& root, + const std::string& projectPath, + const ReferencePickerOptions& options, + std::vector& outCandidates) { + if (!root) { + return; + } + + for (const AssetItemPtr& child : root->children) { + if (!child) { + continue; + } + + if (child->isFolder) { + CollectAssetCandidatesRecursive(child, projectPath, options, outCandidates); + continue; + } + + if (!MatchesSupportedAssetExtension(child->extensionLower, options.supportedAssetExtensions)) { + continue; + } + + ReferencePickerCandidate candidate; + candidate.label = GetAssetDisplayName(child); + candidate.secondaryLabel = ProjectFileUtils::MakeProjectRelativePath(projectPath, child->fullPath); + candidate.normalizedSearchText = + NormalizeSearchText(candidate.label + " " + candidate.secondaryLabel); + candidate.iconKind = ResolveAssetIconKind(child); + candidate.assetPath = std::move(candidate.secondaryLabel); + candidate.secondaryLabel = candidate.assetPath; + outCandidates.push_back(std::move(candidate)); + } +} + +inline void CollectSceneCandidatesRecursive( + ::XCEngine::Components::GameObject* gameObject, + const ReferencePickerOptions& options, + std::vector& outCandidates, + int depth = 0) { + if (!gameObject) { + return; + } + + if (options.sceneFilter && options.sceneFilter(*gameObject)) { + ReferencePickerCandidate candidate; + candidate.label = gameObject->GetName(); + candidate.secondaryLabel = BuildSceneHierarchyPath(gameObject); + candidate.normalizedSearchText = + NormalizeSearchText(candidate.label + " " + candidate.secondaryLabel); + candidate.iconKind = AssetIconKind::GameObject; + candidate.sceneObjectId = gameObject->GetID(); + candidate.indent = static_cast(depth) * 14.0f; + outCandidates.push_back(std::move(candidate)); + } + + for (::XCEngine::Components::GameObject* child : gameObject->GetChildren()) { + CollectSceneCandidatesRecursive(child, options, outCandidates, depth + 1); + } +} + +inline bool MatchesSearch(const ReferencePickerCandidate& candidate, const SearchQuery& query) { + return query.Empty() || query.MatchesNormalized(candidate.normalizedSearchText); +} + +inline bool DrawPickerTabButton(const char* label, bool active, ImVec2 size) { + const ImVec4 buttonColor = active ? ReferencePickerItemActiveColor() : ReferencePickerItemColor(); + const ImVec4 hoverColor = ReferencePickerItemHoveredColor(); + const ImVec4 activeColor = ReferencePickerItemActiveColor(); + + ImGui::PushStyleColor(ImGuiCol_Button, buttonColor); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, hoverColor); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, activeColor); + const bool pressed = ImGui::Button(label, size); + ImGui::PopStyleColor(3); + return pressed; +} + +inline bool DrawPickerSearchField(char* buffer, size_t bufferSize, const char* hint) { + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, SearchFieldFramePadding()); + ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, SearchFieldFrameRounding()); + ImGui::PushStyleColor(ImGuiCol_FrameBg, ReferencePickerSurfaceColor()); + ImGui::PushStyleColor(ImGuiCol_FrameBgHovered, ReferencePickerItemHoveredColor()); + ImGui::PushStyleColor(ImGuiCol_FrameBgActive, ReferencePickerItemActiveColor()); + ImGui::SetNextItemWidth(-1.0f); + const bool changed = ImGui::InputTextWithHint("##ReferencePickerSearch", hint, buffer, bufferSize); + + const ImVec2 min = ImGui::GetItemRectMin(); + const ImVec2 max = ImGui::GetItemRectMax(); + const ImVec2 center(min.x + SearchFieldFramePadding().x * 0.5f, (min.y + max.y) * 0.5f); + ImDrawList* drawList = ImGui::GetWindowDrawList(); + const ImU32 glyphColor = ImGui::GetColorU32(ReferencePickerTextDisabledColor()); + drawList->AddCircle(center, 4.0f, glyphColor, 16, 1.5f); + drawList->AddLine( + ImVec2(center.x + 3.0f, center.y + 3.0f), + ImVec2(center.x + 7.0f, center.y + 7.0f), + glyphColor, + 1.5f); + + ImGui::PopStyleColor(3); + ImGui::PopStyleVar(2); + return changed; +} + +inline bool DrawCurrentReferenceField( + const char* displayText, + const char* hint, + AssetIconKind iconKind, + float width, + bool hasValue, + bool* hoveredOut = nullptr) { + const float frameHeight = ImGui::GetFrameHeight(); + const ImVec2 size((std::max)(width, 1.0f), frameHeight); + ImGui::InvisibleButton("##ReferencePickerField", size); + + const bool hovered = ImGui::IsItemHovered(); + const bool held = ImGui::IsItemActive(); + if (hoveredOut) { + *hoveredOut = hovered; + } + + const ImVec2 min = ImGui::GetItemRectMin(); + const ImVec2 max = ImGui::GetItemRectMax(); + ImDrawList* drawList = ImGui::GetWindowDrawList(); + drawList->AddRectFilled( + min, + max, + ImGui::GetColorU32( + held ? ImGuiCol_FrameBgActive : + hovered ? ImGuiCol_FrameBgHovered : + ImGuiCol_FrameBg), + ImGui::GetStyle().FrameRounding); + drawList->AddRect( + min, + max, + ImGui::GetColorU32(ImGuiCol_Border), + ImGui::GetStyle().FrameRounding); + + const float iconSize = 16.0f; + const float iconMinX = min.x + 6.0f; + const float iconMinY = min.y + (frameHeight - iconSize) * 0.5f; + DrawAssetIcon( + drawList, + ImVec2(iconMinX, iconMinY), + ImVec2(iconMinX + iconSize, iconMinY + iconSize), + iconKind); + + const char* text = hasValue ? displayText : hint; + const ImU32 textColor = ImGui::GetColorU32(hasValue ? ImGuiCol_Text : ImGuiCol_TextDisabled); + const ImVec2 textSize = ImGui::CalcTextSize(text ? text : ""); + const float textX = iconMinX + iconSize + 6.0f; + const float textY = min.y + (frameHeight - textSize.y) * 0.5f; + drawList->PushClipRect( + ImVec2(textX, min.y), + ImVec2(max.x - 6.0f, max.y), + true); + drawList->AddText(ImVec2(textX, textY), textColor, text ? text : ""); + drawList->PopClipRect(); + + return ImGui::IsItemClicked(ImGuiMouseButton_Left); +} + +inline bool DrawCandidateRow( + const char* id, + const char* label, + const char* secondaryLabel, + AssetIconKind iconKind, + bool selected, + float indent = 0.0f) { + const bool hasSecondary = secondaryLabel != nullptr && secondaryLabel[0] != '\0'; + const float rowHeight = hasSecondary + ? ImGui::GetTextLineHeight() * 2.0f + 10.0f + : ImGui::GetFrameHeight(); + const ImVec2 rowSize((std::max)(ImGui::GetContentRegionAvail().x, 1.0f), rowHeight); + ImGui::InvisibleButton(id, rowSize); + + const bool hovered = ImGui::IsItemHovered(); + const bool clicked = ImGui::IsItemClicked(ImGuiMouseButton_Left); + + const ImVec2 min = ImGui::GetItemRectMin(); + const ImVec2 max = ImGui::GetItemRectMax(); + ImDrawList* drawList = ImGui::GetWindowDrawList(); + if (selected || hovered) { + drawList->AddRectFilled( + min, + max, + ImGui::GetColorU32(selected ? ReferencePickerItemActiveColor() : ReferencePickerItemHoveredColor()), + 3.0f); + } + + const float iconSize = 16.0f; + const float iconMinX = min.x + 8.0f + indent; + const float iconMinY = min.y + (rowHeight - iconSize) * 0.5f; + DrawAssetIcon( + drawList, + ImVec2(iconMinX, iconMinY), + ImVec2(iconMinX + iconSize, iconMinY + iconSize), + iconKind); + + const float textX = iconMinX + iconSize + 8.0f; + const float primaryY = min.y + (hasSecondary ? 3.0f : (rowHeight - ImGui::GetTextLineHeight()) * 0.5f); + const float secondaryY = primaryY + ImGui::GetTextLineHeight(); + drawList->PushClipRect(ImVec2(textX, min.y), ImVec2(max.x - 6.0f, max.y), true); + drawList->AddText(ImVec2(textX, primaryY), ImGui::GetColorU32(ReferencePickerTextColor()), label ? label : ""); + if (hasSecondary) { + drawList->AddText( + ImVec2(textX, secondaryY), + ImGui::GetColorU32(ReferencePickerTextDisabledColor()), + secondaryLabel); + } + drawList->PopClipRect(); + + return clicked; +} + +inline bool DrawNoneRow(bool selected) { + const ImVec2 rowSize((std::max)(ImGui::GetContentRegionAvail().x, 1.0f), ImGui::GetFrameHeight()); + ImGui::InvisibleButton("##ReferencePickerNone", rowSize); + + const bool hovered = ImGui::IsItemHovered(); + const bool clicked = ImGui::IsItemClicked(ImGuiMouseButton_Left); + const ImVec2 min = ImGui::GetItemRectMin(); + const ImVec2 max = ImGui::GetItemRectMax(); + if (selected || hovered) { + ImGui::GetWindowDrawList()->AddRectFilled( + min, + max, + ImGui::GetColorU32(selected ? ReferencePickerItemActiveColor() : ReferencePickerItemHoveredColor()), + 3.0f); + } + + const ImVec2 textSize = ImGui::CalcTextSize("None"); + const float textY = min.y + (rowSize.y - textSize.y) * 0.5f; + ImGui::GetWindowDrawList()->AddText( + ImVec2(min.x + 8.0f, textY), + ImGui::GetColorU32(ReferencePickerTextDisabledColor()), + "None"); + + return clicked; +} + +inline void EnsureValidActiveTab(ReferencePickerState& state, const ReferencePickerOptions& options) { + if (state.activeTab == ReferencePickerTab::Assets && options.showAssetsTab) { + return; + } + + if (state.activeTab == ReferencePickerTab::Scene && options.showSceneTab) { + return; + } + + state.activeTab = options.showAssetsTab ? ReferencePickerTab::Assets : ReferencePickerTab::Scene; +} + +inline ReferencePickerTab ResolveDefaultTab( + const ReferencePickerOptions& options, + const std::string& currentAssetPath, + ::XCEngine::Components::GameObject::ID currentSceneObjectId) { + if (!currentAssetPath.empty() && options.showAssetsTab) { + return ReferencePickerTab::Assets; + } + + if (currentSceneObjectId != ::XCEngine::Components::GameObject::INVALID_ID && options.showSceneTab) { + return ReferencePickerTab::Scene; + } + + return options.showAssetsTab ? ReferencePickerTab::Assets : ReferencePickerTab::Scene; +} + +} // namespace Detail + +inline ReferencePickerInteraction DrawReferencePickerControl( + const std::string& currentAssetPath, + ::XCEngine::Components::GameObject::ID currentSceneObjectId, + const ReferencePickerOptions& options, + float width = -1.0f) { + ReferencePickerInteraction interaction; + constexpr const char* kProjectAssetPayloadType = "ASSET_ITEM"; + + ImGuiID pickerStateId = ImGui::GetID("##ReferencePickerState"); + Detail::ReferencePickerState& state = Detail::GetReferencePickerState(pickerStateId); + Detail::EnsureValidActiveTab(state, options); + + const float resolvedWidth = width > 0.0f ? width : ImGui::GetContentRegionAvail().x; + const float fieldWidth = (std::max)(resolvedWidth, 1.0f); + + const bool hasAssetValue = !currentAssetPath.empty(); + const bool hasSceneValue = currentSceneObjectId != ::XCEngine::Components::GameObject::INVALID_ID; + + std::string currentDisplayText; + std::string currentTooltip; + AssetIconKind currentIconKind = AssetIconKind::File; + if (hasAssetValue) { + currentDisplayText = Detail::GetAssetDisplayNameFromPath(currentAssetPath); + currentTooltip = currentAssetPath; + currentIconKind = Detail::ResolveAssetIconKindFromPath(currentAssetPath); + } else if (hasSceneValue) { + auto& editorContext = Application::Get().GetEditorContext(); + if (::XCEngine::Components::GameObject* gameObject = + editorContext.GetSceneManager().GetEntity(currentSceneObjectId)) { + currentDisplayText = gameObject->GetName(); + currentTooltip = Detail::BuildSceneHierarchyPath(gameObject); + } else { + currentDisplayText = "Missing"; + } + currentIconKind = AssetIconKind::GameObject; + } + + bool fieldHovered = false; + const bool fieldClicked = Detail::DrawCurrentReferenceField( + currentDisplayText.c_str(), + options.emptyHint, + currentIconKind, + fieldWidth, + hasAssetValue || hasSceneValue, + &fieldHovered); + const ImVec2 fieldMin = ImGui::GetItemRectMin(); + const ImVec2 fieldMax = ImGui::GetItemRectMax(); + + if (fieldHovered && !currentTooltip.empty()) { + ImGui::SetTooltip("%s", currentTooltip.c_str()); + } + + if (ImGui::BeginDragDropTarget()) { + if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload( + kProjectAssetPayloadType, + ImGuiDragDropFlags_AcceptNoDrawDefaultRect)) { + if (payload->Data != nullptr) { + const std::string droppedPath(static_cast(payload->Data)); + std::string extensionLower = std::filesystem::path(droppedPath).extension().string(); + std::transform(extensionLower.begin(), extensionLower.end(), extensionLower.begin(), [](unsigned char ch) { + return static_cast(std::tolower(ch)); + }); + if (Detail::MatchesSupportedAssetExtension(extensionLower, options.supportedAssetExtensions)) { + const auto& editorContext = Application::Get().GetEditorContext(); + interaction.assignedAssetPath = editorContext.GetProjectPath().empty() + ? droppedPath + : ProjectFileUtils::MakeProjectRelativePath(editorContext.GetProjectPath(), droppedPath); + } + } + } + ImGui::EndDragDropTarget(); + } + + const char* popupId = "##ReferencePickerPopup"; + if (fieldClicked) { + state.activeTab = Detail::ResolveDefaultTab(options, currentAssetPath, currentSceneObjectId); + state.searchBuffer[0] = '\0'; + ImGui::OpenPopup(popupId); + } + + ImGui::SetNextWindowPos(ImVec2(fieldMin.x, fieldMax.y + 2.0f), ImGuiCond_Appearing); + ImGui::SetNextWindowSize(ImVec2((std::max)(fieldWidth, 320.0f), 360.0f), ImGuiCond_Appearing); + if (!Detail::BeginReferencePickerPopup(popupId, options.popupTitle)) { + return interaction; + } + + Detail::EnsureValidActiveTab(state, options); + const bool showTabs = options.showAssetsTab && options.showSceneTab; + if (showTabs) { + const float availableWidth = ImGui::GetContentRegionAvail().x; + const float tabWidth = (availableWidth - ImGui::GetStyle().ItemSpacing.x) * 0.5f; + if (Detail::DrawPickerTabButton( + options.assetsTabLabel, + state.activeTab == ReferencePickerTab::Assets, + ImVec2(tabWidth, 0.0f))) { + state.activeTab = ReferencePickerTab::Assets; + } + ImGui::SameLine(); + if (Detail::DrawPickerTabButton( + options.sceneTabLabel, + state.activeTab == ReferencePickerTab::Scene, + ImVec2(tabWidth, 0.0f))) { + state.activeTab = ReferencePickerTab::Scene; + } + ImGui::Spacing(); + } + + Detail::DrawPickerSearchField(state.searchBuffer.data(), state.searchBuffer.size(), options.searchHint); + ImGui::Spacing(); + + std::vector candidates; + candidates.reserve(64); + + auto& editorContext = Application::Get().GetEditorContext(); + if (state.activeTab == ReferencePickerTab::Assets) { + if (options.showAssetsTab) { + Detail::CollectAssetCandidatesRecursive( + editorContext.GetProjectManager().GetRootFolder(), + editorContext.GetProjectPath(), + options, + candidates); + } + } else { + if (options.showSceneTab) { + const auto& roots = editorContext.GetSceneManager().GetRootEntities(); + for (::XCEngine::Components::GameObject* root : roots) { + Detail::CollectSceneCandidatesRecursive(root, options, candidates); + } + } + } + + const SearchQuery searchQuery(state.searchBuffer.data()); + ImGui::BeginChild("##ReferencePickerList", ImVec2(0.0f, 0.0f), true); + + const bool noneSelected = !hasAssetValue && !hasSceneValue; + if (Detail::DrawNoneRow(noneSelected)) { + interaction.clearRequested = true; + ImGui::CloseCurrentPopup(); + } + + bool anyVisibleCandidate = false; + for (size_t index = 0; index < candidates.size(); ++index) { + const Detail::ReferencePickerCandidate& candidate = candidates[index]; + if (!Detail::MatchesSearch(candidate, searchQuery)) { + continue; + } + + anyVisibleCandidate = true; + ImGui::PushID(static_cast(index)); + const bool selected = candidate.IsAsset() + ? candidate.assetPath == currentAssetPath + : candidate.sceneObjectId == currentSceneObjectId; + const bool clicked = Detail::DrawCandidateRow( + "##ReferencePickerCandidate", + candidate.label.c_str(), + candidate.secondaryLabel.c_str(), + candidate.iconKind, + selected, + candidate.indent); + ImGui::PopID(); + + if (!clicked) { + continue; + } + + if (candidate.IsAsset()) { + interaction.assignedAssetPath = candidate.assetPath; + } else if (candidate.IsSceneObject()) { + interaction.assignedSceneObjectId = candidate.sceneObjectId; + } + ImGui::CloseCurrentPopup(); + } + + if (!anyVisibleCandidate) { + ImGui::TextColored( + Detail::ReferencePickerTextDisabledColor(), + "%s", + state.activeTab == ReferencePickerTab::Assets + ? options.noAssetsText + : options.noSceneText); + } + + ImGui::EndChild(); + Detail::EndReferencePickerPopup(); + return interaction; +} + +} // namespace UI +} // namespace Editor +} // namespace XCEngine diff --git a/editor/src/UI/SearchText.h b/editor/src/UI/SearchText.h new file mode 100644 index 00000000..8ca99558 --- /dev/null +++ b/editor/src/UI/SearchText.h @@ -0,0 +1,50 @@ +#pragma once + +#include +#include +#include +#include + +namespace XCEngine { +namespace Editor { +namespace UI { + +inline std::string NormalizeSearchText(std::string_view text) { + std::string normalized(text); + std::transform(normalized.begin(), normalized.end(), normalized.begin(), [](unsigned char ch) { + return static_cast(std::tolower(ch)); + }); + return normalized; +} + +class SearchQuery { +public: + SearchQuery() = default; + + explicit SearchQuery(std::string_view text) + : m_text(NormalizeSearchText(text)) { + } + + bool Empty() const { + return m_text.empty(); + } + + const std::string& Text() const { + return m_text; + } + + bool Matches(std::string_view text) const { + return Empty() || NormalizeSearchText(text).find(m_text) != std::string::npos; + } + + bool MatchesNormalized(std::string_view normalizedText) const { + return Empty() || normalizedText.find(m_text) != std::string::npos; + } + +private: + std::string m_text; +}; + +} // namespace UI +} // namespace Editor +} // namespace XCEngine diff --git a/editor/src/UI/StyleTokens.h b/editor/src/UI/StyleTokens.h index f74b5b1a..8f3df884 100644 --- a/editor/src/UI/StyleTokens.h +++ b/editor/src/UI/StyleTokens.h @@ -233,7 +233,7 @@ inline ImVec2 ProjectNavigationPanePadding() { } inline ImVec2 ProjectBrowserPanePadding() { - return ImVec2(10.0f, 8.0f); + return ImVec2(16.0f, 12.0f); } inline ImVec2 NavigationTreeNodeFramePadding() { @@ -269,7 +269,11 @@ inline float NavigationTreePrefixLabelGap() { } inline float DisclosureArrowScale() { - return 0.18f; + return 0.20f; +} + +inline ImVec4 DisclosureArrowColor() { + return ImVec4(0.42f, 0.42f, 0.42f, 1.0f); } inline ImVec4 NavigationTreePrefixColor(bool selected = false, bool hovered = false) { @@ -339,13 +343,17 @@ inline ImVec4 ToolbarBackgroundColor() { } inline ImVec2 SearchFieldFramePadding() { - return ImVec2(7.0f, 4.0f); + return ImVec2(22.0f, 1.0f); } inline float SearchFieldFrameRounding() { return 2.0f; } +inline float SearchFieldVerticalOffset() { + return -1.0f; +} + inline ImVec2 InlineRenameFieldFramePadding() { return ImVec2(4.0f, 0.0f); } diff --git a/editor/src/UI/UI.h b/editor/src/UI/UI.h index ef79d716..c9b334be 100644 --- a/editor/src/UI/UI.h +++ b/editor/src/UI/UI.h @@ -15,6 +15,8 @@ #include "PopupState.h" #include "PropertyLayout.h" #include "PropertyGrid.h" +#include "ReferencePicker.h" +#include "SearchText.h" #include "SplitterChrome.h" #include "ScalarControls.h" #include "SceneStatusWidget.h" diff --git a/editor/src/UI/Widgets.h b/editor/src/UI/Widgets.h index 2f56f9fa..dcb5c903 100644 --- a/editor/src/UI/Widgets.h +++ b/editor/src/UI/Widgets.h @@ -192,11 +192,35 @@ inline bool ToolbarSearchField( char* buffer, size_t bufferSize, float trailingWidth = 0.0f) { + const float originalCursorY = ImGui::GetCursorPosY(); + const float verticalOffset = SearchFieldVerticalOffset(); + if (verticalOffset != 0.0f) { + const float nextCursorY = originalCursorY + verticalOffset; + ImGui::SetCursorPosY(nextCursorY > 0.0f ? nextCursorY : 0.0f); + } + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, SearchFieldFramePadding()); ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, SearchFieldFrameRounding()); + ImGui::PushStyleColor(ImGuiCol_FrameBg, ToolbarButtonColor(false)); + ImGui::PushStyleColor(ImGuiCol_FrameBgHovered, ToolbarButtonHoveredColor(false)); + ImGui::PushStyleColor(ImGuiCol_FrameBgActive, ToolbarButtonActiveColor()); const float width = ImGui::GetContentRegionAvail().x - trailingWidth; ImGui::SetNextItemWidth(width > 0.0f ? width : 0.0f); const bool changed = ImGui::InputTextWithHint(id, hint, buffer, bufferSize); + + const ImVec2 min = ImGui::GetItemRectMin(); + const ImVec2 max = ImGui::GetItemRectMax(); + const ImVec2 center(min.x + SearchFieldFramePadding().x * 0.5f, (min.y + max.y) * 0.5f); + ImDrawList* drawList = ImGui::GetWindowDrawList(); + const ImU32 glyphColor = ImGui::GetColorU32(ConsoleSecondaryTextColor()); + drawList->AddCircle(center, 4.0f, glyphColor, 16, 1.5f); + drawList->AddLine( + ImVec2(center.x + 3.0f, center.y + 3.0f), + ImVec2(center.x + 7.0f, center.y + 7.0f), + glyphColor, + 1.5f); + + ImGui::PopStyleColor(3); ImGui::PopStyleVar(2); return changed; } @@ -514,7 +538,7 @@ inline ComponentSectionResult BeginComponentSection( style.FrameBorderSize); } - DrawDisclosureArrow(drawList, arrowRect.Min, arrowRect.Max, open, ImGui::GetColorU32(ImGuiCol_Text)); + DrawDisclosureArrow(drawList, arrowRect.Min, arrowRect.Max, open, ImGui::GetColorU32(DisclosureArrowColor())); if (label && label[0] != '\0') { const float textX = itemMin.x + arrowSlotWidth; diff --git a/editor/src/panels/ConsolePanel.cpp b/editor/src/panels/ConsolePanel.cpp index 4e750381..7663ee9b 100644 --- a/editor/src/panels/ConsolePanel.cpp +++ b/editor/src/panels/ConsolePanel.cpp @@ -11,7 +11,6 @@ #include #include -#include #include #include #include @@ -40,6 +39,8 @@ struct ConsoleSeverityCounts { size_t errorCount = 0; }; +bool CanOpenSourceLocation(const LogEntry& entry); + struct ConsoleRowData { uint64_t serial = 0; LogEntry entry = {}; @@ -143,13 +144,6 @@ bool IsErrorLevel(LogLevel level) { return level == LogLevel::Error || level == LogLevel::Fatal; } -std::string ToLowerCopy(std::string text) { - std::transform(text.begin(), text.end(), text.begin(), [](unsigned char ch) { - return static_cast(std::tolower(ch)); - }); - return text; -} - std::string BuildEntryKey(const LogEntry& entry) { std::string key; key.reserve(128 + entry.message.Length() + entry.file.Length() + entry.function.Length()); @@ -173,15 +167,11 @@ std::string BuildSearchHaystack(const LogEntry& entry) { haystack += entry.file.CStr(); haystack.push_back('\n'); haystack += entry.function.CStr(); - return ToLowerCopy(std::move(haystack)); + return haystack; } -bool MatchesSearch(const LogEntry& entry, const std::string& searchText) { - if (searchText.empty()) { - return true; - } - - return BuildSearchHaystack(entry).find(searchText) != std::string::npos; +bool MatchesSearch(const LogEntry& entry, const XCEngine::Editor::UI::SearchQuery& searchQuery) { + return searchQuery.Matches(BuildSearchHaystack(entry)); } void CountSeverity(ConsoleSeverityCounts& counts, LogLevel level) { @@ -210,7 +200,7 @@ uint64_t FindLatestSerial(const std::vector& records) { std::vector BuildVisibleRows( const std::vector& records, const XCEngine::Editor::UI::ConsoleFilterState& filterState, - const std::string& searchText, + const XCEngine::Editor::UI::SearchQuery& searchQuery, ConsoleSeverityCounts& counts) { std::vector rows; rows.reserve(records.size()); @@ -218,7 +208,7 @@ std::vector BuildVisibleRows( if (!filterState.Collapse()) { for (const EditorConsoleRecord& record : records) { CountSeverity(counts, record.entry.level); - if (!filterState.Allows(record.entry.level) || !MatchesSearch(record.entry, searchText)) { + if (!filterState.Allows(record.entry.level) || !MatchesSearch(record.entry, searchQuery)) { continue; } @@ -236,7 +226,7 @@ std::vector BuildVisibleRows( rowIndicesByKey.reserve(records.size()); for (const EditorConsoleRecord& record : records) { CountSeverity(counts, record.entry.level); - if (!filterState.Allows(record.entry.level) || !MatchesSearch(record.entry, searchText)) { + if (!filterState.Allows(record.entry.level) || !MatchesSearch(record.entry, searchQuery)) { continue; } @@ -358,15 +348,6 @@ void DrawSeverityIcon( } } -void DrawSearchGlyph(ImDrawList* drawList, const ImVec2& center, ImU32 color) { - drawList->AddCircle(center, 4.0f, color, 16, 1.5f); - drawList->AddLine( - ImVec2(center.x + 3.0f, center.y + 3.0f), - ImVec2(center.x + 7.0f, center.y + 7.0f), - color, - 1.5f); -} - bool DrawToolbarDropdownButton( const char* id, const char* label, @@ -525,6 +506,37 @@ bool DrawToolbarButton( return pressed; } +bool DrawCompactCheckedMenuItem(const char* label, bool checked) { + if (!label) { + return false; + } + + const bool clicked = ImGui::Selectable(label, false, ImGuiSelectableFlags_SpanAvailWidth); + if (checked) { + const ImRect rect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); + ImDrawList* drawList = ImGui::GetWindowDrawList(); + const ImU32 color = ImGui::GetColorU32(ImGuiCol_CheckMark); + const float height = rect.Max.y - rect.Min.y; + const float checkWidth = height * 0.28f; + const float checkHeight = height * 0.18f; + const float x = rect.Max.x - 12.0f; + const float y = rect.Min.y + height * 0.52f; + + drawList->AddLine( + ImVec2(x - checkWidth, y - checkHeight * 0.15f), + ImVec2(x - checkWidth * 0.42f, y + checkHeight), + color, + 1.4f); + drawList->AddLine( + ImVec2(x - checkWidth * 0.42f, y + checkHeight), + ImVec2(x + checkWidth, y - checkHeight), + color, + 1.4f); + } + + return clicked; +} + void DrawToolbarArrowDropdownButton( const char* id, float width, @@ -566,28 +578,6 @@ void DrawToolbarArrowDropdownButton( } } -bool DrawConsoleSearchField(const char* id, char* buffer, size_t bufferSize) { - const float originalCursorY = ImGui::GetCursorPosY(); - ImGui::SetCursorPosY((std::max)(0.0f, originalCursorY - 1.0f)); - ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(22.0f, 1.0f)); - ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 2.0f); - ImGui::PushStyleColor(ImGuiCol_FrameBg, XCEngine::Editor::UI::ToolbarButtonColor(false)); - ImGui::PushStyleColor(ImGuiCol_FrameBgHovered, XCEngine::Editor::UI::ToolbarButtonHoveredColor(false)); - ImGui::PushStyleColor(ImGuiCol_FrameBgActive, XCEngine::Editor::UI::ToolbarButtonActiveColor()); - ImGui::SetNextItemWidth((std::max)(0.0f, ImGui::GetContentRegionAvail().x)); - const bool changed = ImGui::InputTextWithHint(id, "Search", buffer, bufferSize); - const ImVec2 min = ImGui::GetItemRectMin(); - const ImVec2 max = ImGui::GetItemRectMax(); - ImDrawList* drawList = ImGui::GetWindowDrawList(); - DrawSearchGlyph( - drawList, - ImVec2(min.x + 11.0f, (min.y + max.y) * 0.5f), - ImGui::GetColorU32(XCEngine::Editor::UI::ConsoleSecondaryTextColor())); - ImGui::PopStyleColor(3); - ImGui::PopStyleVar(2); - return changed; -} - bool DrawSeverityToggleButton( const char* id, ConsoleSeverityVisual severity, @@ -595,14 +585,14 @@ bool DrawSeverityToggleButton( bool& active, const char* tooltip) { const float originalCursorY = ImGui::GetCursorPosY(); - ImGui::SetCursorPosY((std::max)(0.0f, originalCursorY - 1.0f)); + ImGui::SetCursorPosY((std::max)(0.0f, originalCursorY - kConsoleToolbarRowPaddingY)); const std::string countText = std::to_string(count); const ImVec2 countSize = ImGui::CalcTextSize(countText.c_str()); - const ImVec2 padding(6.0f, 3.0f); - const float iconRadius = 6.0f; - const float gap = 4.0f; - const float buttonHeight = kConsoleToolbarButtonHeight + 2.0f; + const ImVec2 padding(7.0f, 3.0f); + const float iconRadius = 10.0f; + const float gap = 5.0f; + const float buttonHeight = kConsoleToolbarHeight; const ImVec2 size( (std::max)( kConsoleCounterWidth, @@ -643,9 +633,9 @@ bool DrawSeverityToggleButton( float CalculateSeverityToggleButtonWidth(size_t count) { const std::string countText = std::to_string(count); const ImVec2 countSize = ImGui::CalcTextSize(countText.c_str()); - const ImVec2 padding(6.0f, 3.0f); - const float iconRadius = 6.0f; - const float gap = 4.0f; + const ImVec2 padding(7.0f, 3.0f); + const float iconRadius = 10.0f; + const float gap = 5.0f; return (std::max)( kConsoleCounterWidth, padding.x * 2.0f + iconRadius * 2.0f + gap + countSize.x); @@ -991,9 +981,9 @@ void ConsolePanel::Render() { m_lastErrorPauseScanSerial = FindLatestSerial(records); } - const std::string searchText = ToLowerCopy(std::string(m_searchBuffer)); + const UI::SearchQuery searchQuery(m_searchBuffer); ConsoleSeverityCounts counts; - std::vector rows = BuildVisibleRows(records, m_filterState, searchText, counts); + std::vector rows = BuildVisibleRows(records, m_filterState, searchQuery, counts); const ConsoleRowData* selectedRow = ResolveSelectedRow(rows, m_selectedSerial, m_selectedEntryKey); if (selectedRow) { m_selectedEntryKey = selectedRow->entryKey; @@ -1058,7 +1048,7 @@ void ConsolePanel::Render() { } ImGui::SameLine(0.0f, 1.0f); DrawToolbarArrowDropdownButton("##ConsoleClearOptions", 16.0f, [&]() { - if (ImGui::MenuItem("Clear on Play", nullptr, m_filterState.ClearOnPlay())) { + if (DrawCompactCheckedMenuItem("Clear on Play", m_filterState.ClearOnPlay())) { m_filterState.ClearOnPlay() = !m_filterState.ClearOnPlay(); } }); @@ -1077,7 +1067,7 @@ void ConsolePanel::Render() { ImGui::SetKeyboardFocusHere(); m_requestSearchFocus = false; } - DrawConsoleSearchField("##ConsoleSearch", m_searchBuffer, sizeof(m_searchBuffer)); + UI::ToolbarSearchField("##ConsoleSearch", "Search", m_searchBuffer, sizeof(m_searchBuffer)); ImGui::TableNextColumn(); DrawSeverityToggleButton("##ConsoleLogFilter", ConsoleSeverityVisual::Log, counts.logCount, m_filterState.ShowLog(), "Log"); @@ -1162,8 +1152,8 @@ void ConsolePanel::Render() { if (rows.empty()) { UI::DrawEmptyState( - searchText.empty() ? "No messages" : "No search results", - searchText.empty() ? nullptr : "No console entries match the current search", + searchQuery.Empty() ? "No messages" : "No search results", + searchQuery.Empty() ? nullptr : "No console entries match the current search", ImVec2(12.0f, 12.0f)); } diff --git a/editor/src/panels/HierarchyPanel.cpp b/editor/src/panels/HierarchyPanel.cpp index 72bdb2bc..3c1a032c 100644 --- a/editor/src/panels/HierarchyPanel.cpp +++ b/editor/src/panels/HierarchyPanel.cpp @@ -8,10 +8,37 @@ #include "Core/EditorEvents.h" #include "Core/EventBus.h" #include "UI/UI.h" + +#include + #include namespace { +constexpr float kHierarchyToolbarHeight = 26.0f; +constexpr float kHierarchyToolbarPaddingY = 3.0f; +constexpr float kHierarchySearchWidth = 220.0f; + +bool HierarchyNodeMatchesSearch( + XCEngine::Components::GameObject* gameObject, + const XCEngine::Editor::UI::SearchQuery& searchQuery) { + if (!gameObject) { + return false; + } + + if (searchQuery.Matches(gameObject->GetName().c_str())) { + return true; + } + + for (size_t i = 0; i < gameObject->GetChildCount(); ++i) { + if (HierarchyNodeMatchesSearch(gameObject->GetChild(i), searchQuery)) { + return true; + } + } + + return false; +} + void DrawHierarchyTreePrefix(const XCEngine::Editor::UI::TreeNodePrefixContext& context) { if (!context.drawList) { return; @@ -157,6 +184,33 @@ void HierarchyPanel::Render() { Actions::ObserveFocusedActionRoute(*m_context, EditorActionRoute::Hierarchy); + { + UI::PanelToolbarScope toolbar( + "HierarchyToolbar", + kHierarchyToolbarHeight, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse, + true, + ImVec2(UI::ToolbarPadding().x, kHierarchyToolbarPaddingY), + UI::ToolbarItemSpacing(), + UI::HierarchyInspectorPanelBackgroundColor()); + if (toolbar.IsOpen()) { + ImGui::PushStyleVar(ImGuiStyleVar_CellPadding, ImVec2(0.0f, 0.0f)); + if (ImGui::BeginTable("##HierarchyToolbarLayout", 2, ImGuiTableFlags_NoSavedSettings | ImGuiTableFlags_SizingStretchProp)) { + ImGui::TableSetupColumn("##Spacer", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("##Search", ImGuiTableColumnFlags_WidthFixed, kHierarchySearchWidth); + ImGui::TableNextRow(); + + ImGui::TableNextColumn(); + + ImGui::TableNextColumn(); + UI::ToolbarSearchField("##HierarchySearch", "Search", m_searchBuffer, sizeof(m_searchBuffer)); + + ImGui::EndTable(); + } + ImGui::PopStyleVar(); + } + } + UI::PanelContentScope content("EntityList", UI::HierarchyPanelContentPadding()); if (!content.IsOpen()) { ImGui::PopStyleColor(2); @@ -165,10 +219,18 @@ void HierarchyPanel::Render() { auto& sceneManager = m_context->GetSceneManager(); auto rootEntities = sceneManager.GetRootEntities(); + const UI::SearchQuery searchQuery(m_searchBuffer); + size_t visibleEntityCount = 0; UI::ResetTreeLayout(); for (auto* gameObject : rootEntities) { - RenderEntity(gameObject); + if (RenderEntity(gameObject, searchQuery)) { + ++visibleEntityCount; + } + } + + if (!searchQuery.Empty() && visibleEntityCount == 0) { + UI::DrawEmptyState("No matching entities", "Try a different search term"); } Actions::DrawHierarchyBackgroundInteraction(*m_context, m_renameState); @@ -179,7 +241,7 @@ void HierarchyPanel::Render() { Actions::TraceHierarchyPopup("Hierarchy background popup opened via background surface"); s_backgroundContextOpen = true; } - Actions::DrawHierarchyCreateActions(*m_context, nullptr); + Actions::DrawHierarchyContextActions(*m_context, nullptr, true); UI::EndContextMenu(); } else if (s_backgroundContextOpen) { Actions::TraceHierarchyPopup("Hierarchy background popup closed"); @@ -189,18 +251,32 @@ void HierarchyPanel::Render() { ImGui::PopStyleColor(2); } -void HierarchyPanel::RenderEntity(::XCEngine::Components::GameObject* gameObject) { - if (!gameObject) return; +bool HierarchyPanel::RenderEntity(::XCEngine::Components::GameObject* gameObject, const UI::SearchQuery& searchQuery) { + if (!gameObject) { + return false; + } + + const bool visibleInSearch = HierarchyNodeMatchesSearch(gameObject, searchQuery); + if (!visibleInSearch) { + return false; + } + + const bool searching = !searchQuery.Empty(); ImGui::PushID(static_cast(gameObject->GetID())); UI::TreeNodeDefinition nodeDefinition = BuildHierarchyNodeDefinition(*m_context, gameObject, m_itemContextMenu); - const std::string persistenceKey = std::to_string(gameObject->GetUUID()); - nodeDefinition.persistenceKey = persistenceKey; + const std::string persistenceKey = searching ? std::string() : std::to_string(gameObject->GetUUID()); + if (searching) { + nodeDefinition.options.defaultOpen = gameObject->GetChildCount() > 0; + nodeDefinition.persistenceKey = {}; + } else { + nodeDefinition.persistenceKey = persistenceKey; + } const bool editing = m_renameState.IsEditing(gameObject->GetID()); const UI::TreeNodeResult node = UI::DrawTreeNode( - &m_treeState, + searching ? nullptr : &m_treeState, (void*)gameObject->GetUUID(), editing ? "" : gameObject->GetName().c_str(), nodeDefinition); @@ -220,12 +296,13 @@ void HierarchyPanel::RenderEntity(::XCEngine::Components::GameObject* gameObject if (node.open) { for (size_t i = 0; i < gameObject->GetChildCount(); i++) { - RenderEntity(gameObject->GetChild(i)); + RenderEntity(gameObject->GetChild(i), searchQuery); } UI::EndTreeNode(); } ImGui::PopID(); + return true; } void HierarchyPanel::BeginRename(::XCEngine::Components::GameObject* gameObject) { diff --git a/editor/src/panels/HierarchyPanel.h b/editor/src/panels/HierarchyPanel.h index f6439acd..365838b0 100644 --- a/editor/src/panels/HierarchyPanel.h +++ b/editor/src/panels/HierarchyPanel.h @@ -2,9 +2,11 @@ #include "Panel.h" #include "UI/PopupState.h" +#include "UI/SearchText.h" #include "UI/TreeView.h" #include #include "Core/ISceneManager.h" +#include namespace XCEngine { namespace Editor { @@ -20,7 +22,7 @@ public: private: void OnSelectionChanged(const struct SelectionChangedEvent& event); void OnRenameRequested(const struct EntityRenameRequestedEvent& event); - void RenderEntity(::XCEngine::Components::GameObject* gameObject); + bool RenderEntity(::XCEngine::Components::GameObject* gameObject, const UI::SearchQuery& searchQuery = UI::SearchQuery()); void BeginRename(::XCEngine::Components::GameObject* gameObject); void CommitRename(); void CancelRename(); @@ -31,6 +33,7 @@ private: UI::TargetedPopupState<::XCEngine::Components::GameObject*> m_itemContextMenu; uint64_t m_selectionHandlerId = 0; uint64_t m_renameRequestHandlerId = 0; + char m_searchBuffer[256] = ""; }; } diff --git a/editor/src/panels/ProjectPanel.cpp b/editor/src/panels/ProjectPanel.cpp index 60494447..f41d6191 100644 --- a/editor/src/panels/ProjectPanel.cpp +++ b/editor/src/panels/ProjectPanel.cpp @@ -16,6 +16,9 @@ namespace Editor { namespace { +constexpr float kProjectToolbarHeight = 26.0f; +constexpr float kProjectToolbarPaddingY = 3.0f; + template void QueueDeferredAction(std::function& pendingAction, Fn&& fn) { if (!pendingAction) { @@ -234,16 +237,17 @@ void ProjectPanel::Render() { void ProjectPanel::RenderToolbar() { UI::PanelToolbarScope toolbar( "ProjectToolbar", - UI::ProjectPanelToolbarHeight(), + kProjectToolbarHeight, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse, true, - UI::ToolbarPadding(), + ImVec2(UI::ToolbarPadding().x, kProjectToolbarPaddingY), UI::ToolbarItemSpacing(), UI::ProjectPanelToolbarBackgroundColor()); if (!toolbar.IsOpen()) { return; } + ImGui::PushStyleVar(ImGuiStyleVar_CellPadding, ImVec2(0.0f, 0.0f)); if (ImGui::BeginTable("##ProjectToolbarLayout", 2, ImGuiTableFlags_NoSavedSettings | ImGuiTableFlags_SizingStretchProp)) { ImGui::TableSetupColumn("##Spacer", ImGuiTableColumnFlags_WidthStretch); ImGui::TableSetupColumn("##Search", ImGuiTableColumnFlags_WidthFixed, 220.0f); @@ -257,6 +261,7 @@ void ProjectPanel::RenderToolbar() { ImGui::EndTable(); } + ImGui::PopStyleVar(); } void ProjectPanel::RenderFolderTreePane(IProjectManager& manager) { @@ -359,13 +364,13 @@ void ProjectPanel::RenderBrowserPane(IProjectManager& manager) { std::vector visibleItems; const auto& items = manager.GetCurrentItems(); - const std::string search = m_searchBuffer; + const UI::SearchQuery searchQuery(m_searchBuffer); if (m_renameState.IsActive() && manager.FindCurrentItemIndex(m_renameState.Item()) < 0) { CancelRename(); } visibleItems.reserve(items.size()); for (const auto& item : items) { - if ((m_renameState.IsActive() && item && item->fullPath == m_renameState.Item()) || MatchesSearch(item, search)) { + if ((m_renameState.IsActive() && item && item->fullPath == m_renameState.Item()) || MatchesSearch(item, searchQuery)) { visibleItems.push_back(item); } } @@ -375,7 +380,10 @@ void ProjectPanel::RenderBrowserPane(IProjectManager& manager) { ImGui::PushStyleColor(ImGuiCol_ChildBg, UI::ProjectBrowserPaneBackgroundColor()); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, UI::ProjectBrowserPanePadding()); ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, UI::AssetGridSpacing()); - const bool bodyOpen = ImGui::BeginChild("ProjectBrowserBody", ImVec2(0.0f, 0.0f), false); + const bool bodyOpen = ImGui::BeginChild( + "ProjectBrowserBody", + ImVec2(0.0f, 0.0f), + ImGuiChildFlags_AlwaysUseWindowPadding); ImGui::PopStyleVar(2); ImGui::PopStyleColor(); if (!bodyOpen) { @@ -424,7 +432,7 @@ void ProjectPanel::RenderBrowserPane(IProjectManager& manager) { ImGui::SetCursorPosY(gridOrigin.y + rowCount * tileHeight + (rowCount - 1) * rowSpacing); } - if (visibleItems.empty() && !search.empty()) { + if (visibleItems.empty() && !searchQuery.Empty()) { UI::DrawEmptyState( "No Search Results", "No assets match the current search"); @@ -521,7 +529,12 @@ ProjectPanel::AssetItemInteraction ProjectPanel::RenderAssetItem(const AssetItem isDraggingThisItem, [&](ImDrawList* drawList, const ImVec2& iconMin, const ImVec2& iconMax) { if (item && item->canUseImagePreview && - UI::DrawTextureAssetPreview(drawList, iconMin, iconMax, item->fullPath)) { + UI::DrawTextureAssetPreview( + drawList, + iconMin, + iconMax, + item->fullPath, + m_context ? m_context->GetProjectPath() : std::string())) { return; } UI::DrawAssetIcon(drawList, iconMin, iconMax, iconKind); @@ -584,23 +597,15 @@ ProjectPanel::AssetItemInteraction ProjectPanel::RenderAssetItem(const AssetItem return interaction; } -bool ProjectPanel::MatchesSearch(const AssetItemPtr& item, const std::string& search) { +bool ProjectPanel::MatchesSearch(const AssetItemPtr& item, const UI::SearchQuery& searchQuery) { if (!item) { return false; } - if (search.empty()) { + if (searchQuery.Empty()) { return true; } - auto toLower = [](unsigned char c) { - return static_cast(std::tolower(c)); - }; - - std::string itemName = item->name; - std::string searchText = search; - std::transform(itemName.begin(), itemName.end(), itemName.begin(), toLower); - std::transform(searchText.begin(), searchText.end(), searchText.begin(), toLower); - return itemName.find(searchText) != std::string::npos; + return searchQuery.Matches(item->name); } bool ProjectPanel::IsCurrentTreeBranch(const std::string& currentFolderPath, const std::string& folderPath) { diff --git a/editor/src/panels/ProjectPanel.h b/editor/src/panels/ProjectPanel.h index 602c4b99..c4e78a96 100644 --- a/editor/src/panels/ProjectPanel.h +++ b/editor/src/panels/ProjectPanel.h @@ -3,6 +3,7 @@ #include "Panel.h" #include "Core/AssetItem.h" #include "UI/PopupState.h" +#include "UI/SearchText.h" #include "UI/TreeView.h" #include @@ -52,7 +53,7 @@ private: void RenderBrowserPane(IProjectManager& manager); void RenderBrowserHeader(IProjectManager& manager); AssetItemInteraction RenderAssetItem(const AssetItemPtr& item, bool isSelected); - static bool MatchesSearch(const AssetItemPtr& item, const std::string& search); + static bool MatchesSearch(const AssetItemPtr& item, const UI::SearchQuery& searchQuery); static bool IsCurrentTreeBranch(const std::string& currentFolderPath, const std::string& folderPath); char m_searchBuffer[256] = "";