Polish shared editor tree context behavior
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user