From b77615569c0316622358ae6e23b4492f08f32245 Mon Sep 17 00:00:00 2001 From: ssdfasd <2156608475@qq.com> Date: Sat, 28 Mar 2026 00:03:20 +0800 Subject: [PATCH] Polish shared editor tree context behavior --- editor/src/Actions/HierarchyActionRouter.h | 40 ++++++++-- editor/src/UI/StyleTokens.h | 8 ++ editor/src/UI/TreeView.h | 50 ++++++------- editor/src/panels/HierarchyPanel.cpp | 10 ++- editor/src/panels/HierarchyPanel.h | 2 + editor/src/panels/ProjectPanel.cpp | 6 +- tests/editor/test_action_routing.cpp | 86 ++++++++++++++++++++++ 7 files changed, 164 insertions(+), 38 deletions(-) diff --git a/editor/src/Actions/HierarchyActionRouter.h b/editor/src/Actions/HierarchyActionRouter.h index 5fe40151..1aac0e2d 100644 --- a/editor/src/Actions/HierarchyActionRouter.h +++ b/editor/src/Actions/HierarchyActionRouter.h @@ -59,6 +59,12 @@ inline void RequestHierarchyOptionsPopup(UI::DeferredPopupState& optionsPopup) { optionsPopup.RequestOpen(); } +inline void RequestHierarchyBackgroundContextPopup(UI::DeferredPopupState& backgroundContextMenu) { + if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(1) && !ImGui::IsAnyItemHovered()) { + backgroundContextMenu.RequestOpen(); + } +} + template inline void DrawHierarchySortOptionsPopup( UI::DeferredPopupState& optionsPopup, @@ -170,8 +176,22 @@ inline void DrawHierarchyCreateActions(IEditorContext& context, ::XCEngine::Comp }); } -inline void DrawHierarchyBackgroundContextPopup(IEditorContext& context) { - if (!UI::BeginPopupContextWindow("HierarchyContextMenu", ImGuiPopupFlags_MouseButtonRight)) { +inline void HandleHierarchyItemContextRequest( + IEditorContext& context, + ::XCEngine::Components::GameObject* gameObject, + UI::TargetedPopupState<::XCEngine::Components::GameObject*>& itemContextMenu) { + if (!gameObject) { + return; + } + + context.GetSelectionManager().SetSelectedEntity(gameObject->GetID()); + itemContextMenu.RequestOpen(gameObject); +} + +inline void DrawHierarchyBackgroundContextPopup(IEditorContext& context, UI::DeferredPopupState& backgroundContextMenu) { + backgroundContextMenu.ConsumeOpenRequest("HierarchyContextMenu"); + + if (!UI::BeginPopup("HierarchyContextMenu")) { return; } @@ -210,13 +230,23 @@ inline void DrawHierarchyContextActions(IEditorContext& context, ::XCEngine::Com }); } -inline void DrawHierarchyEntityContextPopup(IEditorContext& context, ::XCEngine::Components::GameObject* gameObject) { - if (!UI::BeginPopupContextItem("EntityContextMenu")) { +inline void DrawHierarchyEntityContextPopup( + IEditorContext& context, + UI::TargetedPopupState<::XCEngine::Components::GameObject*>& itemContextMenu) { + itemContextMenu.ConsumeOpenRequest("HierarchyEntityContextMenu"); + + if (!UI::BeginPopup("HierarchyEntityContextMenu")) { return; } - DrawHierarchyContextActions(context, gameObject); + if (itemContextMenu.HasTarget()) { + DrawHierarchyContextActions(context, itemContextMenu.TargetValue()); + } UI::EndPopup(); + + if (!ImGui::IsPopupOpen("HierarchyEntityContextMenu") && !itemContextMenu.HasPendingOpenRequest()) { + itemContextMenu.Clear(); + } } } // namespace Actions diff --git a/editor/src/UI/StyleTokens.h b/editor/src/UI/StyleTokens.h index 6b95473e..a3a652cb 100644 --- a/editor/src/UI/StyleTokens.h +++ b/editor/src/UI/StyleTokens.h @@ -170,6 +170,14 @@ inline float NavigationTreePrefixWidth() { return 16.0f; } +inline float NavigationTreePrefixLabelGap() { + return 6.0f; +} + +inline float NavigationTreePrefixStartOffset() { + return 2.0f; +} + inline ImVec4 NavigationTreePrefixColor(bool selected = false, bool hovered = false) { if (selected) { return ImVec4(0.86f, 0.86f, 0.86f, 1.0f); diff --git a/editor/src/UI/TreeView.h b/editor/src/UI/TreeView.h index ba6489e7..118302cf 100644 --- a/editor/src/UI/TreeView.h +++ b/editor/src/UI/TreeView.h @@ -94,28 +94,6 @@ struct TreeNodeDefinition { TreeNodeCallbacks callbacks; }; -inline std::string MakeTreeNodeDisplayLabel(const char* label, float prefixWidth) { - std::string result; - if (!label) { - return result; - } - - if (prefixWidth <= 0.0f) { - result = label; - return result; - } - - const float spaceWidth = ImGui::CalcTextSize(" ").x; - int spaceCount = static_cast(prefixWidth / (spaceWidth > 0.0f ? spaceWidth : 1.0f)) + 1; - if (spaceCount < 1) { - spaceCount = 1; - } - - result.assign(static_cast(spaceCount), ' '); - result += label; - return result; -} - inline TreeNodeResult DrawTreeNode( TreeViewState* state, const void* id, @@ -149,9 +127,20 @@ inline TreeNodeResult DrawTreeNode( flags |= ImGuiTreeNodeFlags_DefaultOpen; } - const std::string displayLabel = definition.prefix.IsVisible() - ? MakeTreeNodeDisplayLabel(label, definition.prefix.width) - : std::string(label ? label : ""); + std::string displayLabel = label ? label : ""; + if (definition.prefix.IsVisible()) { + const float reserveWidth = + NavigationTreePrefixStartOffset() + + definition.prefix.width + + NavigationTreePrefixLabelGap(); + const float spaceWidth = ImGui::CalcTextSize(" ").x; + int spaceCount = static_cast(reserveWidth / (spaceWidth > 0.0f ? spaceWidth : 1.0f)) + 2; + if (spaceCount < 1) { + spaceCount = 1; + } + + displayLabel.insert(0, static_cast(spaceCount), ' '); + } ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, NavigationTreeNodeFramePadding()); const bool open = ImGui::TreeNodeEx(id, flags, "%s", displayLabel.c_str()); @@ -170,14 +159,17 @@ inline TreeNodeResult DrawTreeNode( state->SetExpanded(definition.persistenceKey, result.open); } + const ImVec2 itemMin = ImGui::GetItemRectMin(); + const ImVec2 itemMax = ImGui::GetItemRectMax(); + const float labelStartX = itemMin.x + ImGui::GetTreeNodeToLabelSpacing(); + if (definition.prefix.IsVisible()) { - const ImVec2 itemMin = ImGui::GetItemRectMin(); - const ImVec2 itemMax = ImGui::GetItemRectMax(); - const float prefixMinX = itemMin.x + ImGui::GetTreeNodeToLabelSpacing(); + const float prefixMinX = labelStartX + NavigationTreePrefixStartOffset(); + const float prefixMaxX = prefixMinX + definition.prefix.width; definition.prefix.draw(TreeNodePrefixContext{ ImGui::GetWindowDrawList(), ImVec2(prefixMinX, itemMin.y), - ImVec2(prefixMinX + definition.prefix.width, itemMax.y), + ImVec2(prefixMaxX, itemMax.y), options.selected, result.hovered }); diff --git a/editor/src/panels/HierarchyPanel.cpp b/editor/src/panels/HierarchyPanel.cpp index d71ba60d..822122db 100644 --- a/editor/src/panels/HierarchyPanel.cpp +++ b/editor/src/panels/HierarchyPanel.cpp @@ -109,8 +109,9 @@ void HierarchyPanel::Render() { } Actions::HandleHierarchyBackgroundPrimaryClick(*m_context, m_renameState); - - Actions::DrawHierarchyBackgroundContextPopup(*m_context); + Actions::RequestHierarchyBackgroundContextPopup(m_backgroundContextMenu); + Actions::DrawHierarchyEntityContextPopup(*m_context, m_itemContextMenu); + Actions::DrawHierarchyBackgroundContextPopup(*m_context, m_backgroundContextMenu); Actions::DrawHierarchyRootDropTarget(*m_context); } ImGui::PopStyleColor(2); @@ -153,6 +154,10 @@ void HierarchyPanel::RenderEntity(::XCEngine::Components::GameObject* gameObject Actions::HandleHierarchySelectionClick(*m_context, gameObject->GetID(), ImGui::GetIO().KeyCtrl); } + if (node.secondaryClicked) { + Actions::HandleHierarchyItemContextRequest(*m_context, gameObject, m_itemContextMenu); + } + if (node.doubleClicked) { BeginRename(gameObject); } @@ -160,7 +165,6 @@ void HierarchyPanel::RenderEntity(::XCEngine::Components::GameObject* gameObject nodeDefinition.callbacks.onRenderExtras = [this, gameObject]() { Actions::BeginHierarchyEntityDrag(gameObject); Actions::AcceptHierarchyEntityDrop(*m_context, gameObject); - Actions::DrawHierarchyEntityContextPopup(*m_context, gameObject); }; const UI::TreeNodeResult node = UI::DrawTreeNode( diff --git a/editor/src/panels/HierarchyPanel.h b/editor/src/panels/HierarchyPanel.h index 80118824..f6439acd 100644 --- a/editor/src/panels/HierarchyPanel.h +++ b/editor/src/panels/HierarchyPanel.h @@ -27,6 +27,8 @@ private: UI::InlineTextEditState m_renameState; UI::TreeViewState m_treeState; + UI::DeferredPopupState m_backgroundContextMenu; + UI::TargetedPopupState<::XCEngine::Components::GameObject*> m_itemContextMenu; uint64_t m_selectionHandlerId = 0; uint64_t m_renameRequestHandlerId = 0; }; diff --git a/editor/src/panels/ProjectPanel.cpp b/editor/src/panels/ProjectPanel.cpp index f5e56631..837f70e5 100644 --- a/editor/src/panels/ProjectPanel.cpp +++ b/editor/src/panels/ProjectPanel.cpp @@ -145,10 +145,14 @@ void ProjectPanel::RenderFolderTreeNode( nodeDefinition.persistenceKey = folder->fullPath; nodeDefinition.prefix.width = UI::NavigationTreePrefixWidth(); nodeDefinition.prefix.draw = DrawProjectFolderTreePrefix; - nodeDefinition.callbacks.onInteraction = [&manager, folder](const UI::TreeNodeResult& node) { + nodeDefinition.callbacks.onInteraction = [this, &manager, folder](const UI::TreeNodeResult& node) { if (node.clicked) { manager.NavigateToFolder(folder); } + + if (node.secondaryClicked) { + Actions::HandleProjectItemContextRequest(manager, folder, m_itemContextMenu); + } }; const UI::TreeNodeResult node = UI::DrawTreeNode( diff --git a/tests/editor/test_action_routing.cpp b/tests/editor/test_action_routing.cpp index 0cb77fa4..46e046e4 100644 --- a/tests/editor/test_action_routing.cpp +++ b/tests/editor/test_action_routing.cpp @@ -307,6 +307,22 @@ TEST_F(EditorActionRoutingTest, HierarchyRouterRenameHelpersPublishAndCommit) { m_context.GetEventBus().Unsubscribe(renameSubscription); } +TEST_F(EditorActionRoutingTest, HierarchyItemContextRequestSelectsEntityAndStoresPopupTarget) { + auto* entity = Commands::CreateEmptyEntity(m_context, nullptr, "Create Entity", "ContextTarget"); + ASSERT_NE(entity, nullptr); + + UI::TargetedPopupState<::XCEngine::Components::GameObject*> popupState; + EXPECT_FALSE(popupState.HasTarget()); + EXPECT_FALSE(popupState.HasPendingOpenRequest()); + + Actions::HandleHierarchyItemContextRequest(m_context, entity, popupState); + + EXPECT_EQ(m_context.GetSelectionManager().GetSelectedEntity(), entity->GetID()); + ASSERT_TRUE(popupState.HasTarget()); + EXPECT_EQ(popupState.TargetValue(), entity); + EXPECT_TRUE(popupState.HasPendingOpenRequest()); +} + TEST_F(EditorActionRoutingTest, ProjectCommandsCreateFolderMoveAssetAndOpenFolderHelper) { const fs::path assetsDir = m_projectRoot / "Assets"; const fs::path sourceFilePath = assetsDir / "MoveMe.txt"; @@ -328,6 +344,27 @@ TEST_F(EditorActionRoutingTest, ProjectCommandsCreateFolderMoveAssetAndOpenFolde EXPECT_EQ(m_context.GetProjectManager().GetCurrentPath(), "Assets/MovedFolder"); } +TEST_F(EditorActionRoutingTest, ProjectItemContextRequestSelectsAssetAndStoresPopupTarget) { + const fs::path assetsDir = m_projectRoot / "Assets"; + const fs::path filePath = assetsDir / "ContextAsset.txt"; + std::ofstream(filePath.string()) << "context asset"; + + m_context.GetProjectManager().RefreshCurrentFolder(); + const AssetItemPtr item = FindCurrentItemByName("ContextAsset.txt"); + ASSERT_NE(item, nullptr); + + UI::TargetedPopupState popupState; + EXPECT_FALSE(popupState.HasTarget()); + EXPECT_FALSE(popupState.HasPendingOpenRequest()); + + Actions::HandleProjectItemContextRequest(m_context.GetProjectManager(), item, popupState); + + EXPECT_EQ(m_context.GetProjectManager().GetSelectedItemPath(), item->fullPath); + ASSERT_TRUE(popupState.HasTarget()); + EXPECT_EQ(popupState.TargetValue(), item); + EXPECT_TRUE(popupState.HasPendingOpenRequest()); +} + TEST_F(EditorActionRoutingTest, ProjectCommandsRejectInvalidMoveTargets) { const fs::path assetsDir = m_projectRoot / "Assets"; const fs::path sourceFilePath = assetsDir / "MoveSource.txt"; @@ -353,5 +390,54 @@ TEST_F(EditorActionRoutingTest, ProjectCommandsRejectInvalidMoveTargets) { EXPECT_TRUE(fs::exists(sourceFilePath)); } +TEST_F(EditorActionRoutingTest, ProjectSelectionSurvivesRefreshWhenItemOrderChanges) { + const fs::path assetsDir = m_projectRoot / "Assets"; + const fs::path selectedPath = assetsDir / "Selected.txt"; + const fs::path earlierPath = assetsDir / "Earlier.txt"; + + std::ofstream(selectedPath.string()) << "selected"; + m_context.GetProjectManager().RefreshCurrentFolder(); + + const int selectedIndex = FindCurrentItemIndexByName("Selected.txt"); + ASSERT_GE(selectedIndex, 0); + m_context.GetProjectManager().SetSelectedIndex(selectedIndex); + + AssetItemPtr selectedItem = Actions::GetSelectedAssetItem(m_context); + ASSERT_NE(selectedItem, nullptr); + EXPECT_EQ(selectedItem->name, "Selected.txt"); + + std::ofstream(earlierPath.string()) << "earlier"; + m_context.GetProjectManager().RefreshCurrentFolder(); + + selectedItem = Actions::GetSelectedAssetItem(m_context); + ASSERT_NE(selectedItem, nullptr); + EXPECT_EQ(selectedItem->name, "Selected.txt"); + EXPECT_EQ( + m_context.GetProjectManager().GetSelectedIndex(), + FindCurrentItemIndexByName("Selected.txt")); +} + +TEST_F(EditorActionRoutingTest, ProjectCommandsRejectMovingFolderIntoItsDescendant) { + const fs::path assetsDir = m_projectRoot / "Assets"; + const fs::path parentPath = assetsDir / "Parent"; + const fs::path childPath = parentPath / "Child"; + + fs::create_directories(childPath); + m_context.GetProjectManager().RefreshCurrentFolder(); + + const AssetItemPtr parentFolder = FindCurrentItemByName("Parent"); + ASSERT_NE(parentFolder, nullptr); + ASSERT_TRUE(parentFolder->isFolder); + + m_context.GetProjectManager().NavigateToFolder(parentFolder); + const AssetItemPtr childFolder = FindCurrentItemByName("Child"); + ASSERT_NE(childFolder, nullptr); + ASSERT_TRUE(childFolder->isFolder); + + EXPECT_FALSE(Commands::MoveAssetToFolder(m_context.GetProjectManager(), parentFolder->fullPath, childFolder)); + EXPECT_TRUE(fs::exists(parentPath)); + EXPECT_TRUE(fs::exists(childPath)); +} + } // namespace } // namespace XCEngine::Editor