Polish shared editor tree context behavior

This commit is contained in:
2026-03-28 00:03:20 +08:00
parent 7d6032be23
commit b77615569c
7 changed files with 164 additions and 38 deletions

View File

@@ -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 <typename SortMode, typename SetSortModeFn>
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

View File

@@ -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);

View File

@@ -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<int>(prefixWidth / (spaceWidth > 0.0f ? spaceWidth : 1.0f)) + 1;
if (spaceCount < 1) {
spaceCount = 1;
}
result.assign(static_cast<size_t>(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<int>(reserveWidth / (spaceWidth > 0.0f ? spaceWidth : 1.0f)) + 2;
if (spaceCount < 1) {
spaceCount = 1;
}
displayLabel.insert(0, static_cast<size_t>(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
});

View File

@@ -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(

View File

@@ -27,6 +27,8 @@ private:
UI::InlineTextEditState<uint64_t, 256> 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;
};

View File

@@ -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(

View File

@@ -307,6 +307,22 @@ TEST_F(EditorActionRoutingTest, HierarchyRouterRenameHelpersPublishAndCommit) {
m_context.GetEventBus().Unsubscribe<EntityRenameRequestedEvent>(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<AssetItemPtr> 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