Add persistent shared editor tree interactions

This commit is contained in:
2026-03-27 23:21:43 +08:00
parent 0c8a3e90ec
commit 291d1a7e41
6 changed files with 240 additions and 40 deletions

View File

@@ -166,6 +166,20 @@ inline ImVec2 NavigationTreeNodeFramePadding() {
return ImVec2(4.0f, 3.0f);
}
inline float NavigationTreePrefixWidth() {
return 16.0f;
}
inline ImVec4 NavigationTreePrefixColor(bool selected = false, bool hovered = false) {
if (selected) {
return ImVec4(0.86f, 0.86f, 0.86f, 1.0f);
}
if (hovered) {
return ImVec4(0.76f, 0.76f, 0.76f, 1.0f);
}
return ImVec4(0.62f, 0.62f, 0.62f, 1.0f);
}
inline ImVec4 ProjectNavigationPaneBackgroundColor() {
return ImVec4(0.20f, 0.20f, 0.20f, 1.0f);
}

View File

@@ -2,12 +2,50 @@
#include "StyleTokens.h"
#include <functional>
#include <imgui.h>
#include <string>
#include <string_view>
#include <unordered_map>
namespace XCEngine {
namespace Editor {
namespace UI {
class TreeViewState {
public:
bool TryGetExpanded(std::string_view key, bool& expanded) const {
const auto it = m_expanded.find(std::string(key));
if (it == m_expanded.end()) {
return false;
}
expanded = it->second;
return true;
}
bool ResolveExpanded(std::string_view key, bool fallback) const {
bool expanded = fallback;
TryGetExpanded(key, expanded);
return expanded;
}
void SetExpanded(std::string_view key, bool expanded) {
if (key.empty()) {
return;
}
m_expanded[std::string(key)] = expanded;
}
void Clear() {
m_expanded.clear();
}
private:
std::unordered_map<std::string, bool> m_expanded;
};
struct TreeNodeOptions {
bool selected = false;
bool leaf = false;
@@ -22,9 +60,72 @@ struct TreeNodeResult {
bool open = false;
bool clicked = false;
bool doubleClicked = false;
bool secondaryClicked = false;
bool hovered = false;
bool toggledOpen = false;
};
inline TreeNodeResult DrawTreeNode(const void* id, const char* label, const TreeNodeOptions& options = {}) {
struct TreeNodePrefixContext {
ImDrawList* drawList = nullptr;
ImVec2 min = ImVec2(0.0f, 0.0f);
ImVec2 max = ImVec2(0.0f, 0.0f);
bool selected = false;
bool hovered = false;
};
struct TreeNodePrefixSlot {
float width = 0.0f;
std::function<void(const TreeNodePrefixContext&)> draw;
bool IsVisible() const {
return width > 0.0f && static_cast<bool>(draw);
}
};
struct TreeNodeCallbacks {
std::function<void(const TreeNodeResult&)> onInteraction;
std::function<void()> onRenderExtras;
};
struct TreeNodeDefinition {
TreeNodeOptions options;
std::string_view persistenceKey;
TreeNodePrefixSlot prefix;
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,
const char* label,
const TreeNodeDefinition& definition = {}) {
TreeNodeOptions options = definition.options;
if (state && !definition.persistenceKey.empty()) {
ImGui::SetNextItemOpen(state->ResolveExpanded(definition.persistenceKey, options.defaultOpen), ImGuiCond_Always);
}
ImGuiTreeNodeFlags flags = 0;
if (options.openOnArrow) {
flags |= ImGuiTreeNodeFlags_OpenOnArrow;
@@ -48,15 +149,54 @@ inline TreeNodeResult DrawTreeNode(const void* id, const char* label, const Tree
flags |= ImGuiTreeNodeFlags_DefaultOpen;
}
const std::string displayLabel = definition.prefix.IsVisible()
? MakeTreeNodeDisplayLabel(label, definition.prefix.width)
: std::string(label ? label : "");
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, NavigationTreeNodeFramePadding());
const bool open = ImGui::TreeNodeEx(id, flags, "%s", label);
const bool open = ImGui::TreeNodeEx(id, flags, "%s", displayLabel.c_str());
ImGui::PopStyleVar();
return TreeNodeResult{
TreeNodeResult result{
open,
ImGui::IsItemClicked() && !ImGui::IsItemToggledOpen(),
ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(0)
ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(0),
ImGui::IsItemClicked(ImGuiMouseButton_Right),
ImGui::IsItemHovered(),
ImGui::IsItemToggledOpen()
};
if (state && !definition.persistenceKey.empty()) {
state->SetExpanded(definition.persistenceKey, result.open);
}
if (definition.prefix.IsVisible()) {
const ImVec2 itemMin = ImGui::GetItemRectMin();
const ImVec2 itemMax = ImGui::GetItemRectMax();
const float prefixMinX = itemMin.x + ImGui::GetTreeNodeToLabelSpacing();
definition.prefix.draw(TreeNodePrefixContext{
ImGui::GetWindowDrawList(),
ImVec2(prefixMinX, itemMin.y),
ImVec2(prefixMinX + definition.prefix.width, itemMax.y),
options.selected,
result.hovered
});
}
if (definition.callbacks.onInteraction) {
definition.callbacks.onInteraction(result);
}
if (definition.callbacks.onRenderExtras) {
definition.callbacks.onRenderExtras();
}
return result;
}
inline TreeNodeResult DrawTreeNode(const void* id, const char* label, const TreeNodeOptions& options = {}) {
TreeNodeDefinition definition;
definition.options = options;
return DrawTreeNode(nullptr, id, label, definition);
}
inline void EndTreeNode() {

View File

@@ -10,6 +10,25 @@
#include "UI/UI.h"
#include <imgui.h>
namespace {
void DrawHierarchyTreePrefix(const XCEngine::Editor::UI::TreeNodePrefixContext& context) {
if (!context.drawList) {
return;
}
const ImVec4 color = XCEngine::Editor::UI::NavigationTreePrefixColor(context.selected, context.hovered);
const float size = 9.0f;
const float centerX = context.min.x + (context.max.x - context.min.x) * 0.5f;
const float centerY = context.min.y + (context.max.y - context.min.y) * 0.5f;
const ImVec2 min(centerX - size * 0.5f, centerY - size * 0.5f);
const ImVec2 max(centerX + size * 0.5f, centerY + size * 0.5f);
context.drawList->AddRect(min, max, ImGui::GetColorU32(color), 1.5f);
context.drawList->AddLine(ImVec2(min.x, centerY), ImVec2(max.x, centerY), ImGui::GetColorU32(color), 1.0f);
}
} // namespace
namespace XCEngine {
namespace Editor {
@@ -122,27 +141,33 @@ void HierarchyPanel::RenderEntity(::XCEngine::Components::GameObject* gameObject
CommitRename();
}
} else {
UI::TreeNodeOptions nodeOptions;
nodeOptions.selected = m_context->GetSelectionManager().IsSelected(gameObject->GetID());
nodeOptions.leaf = gameObject->GetChildCount() == 0;
UI::TreeNodeDefinition nodeDefinition;
nodeDefinition.options.selected = m_context->GetSelectionManager().IsSelected(gameObject->GetID());
nodeDefinition.options.leaf = gameObject->GetChildCount() == 0;
const std::string persistenceKey = std::to_string(gameObject->GetUUID());
nodeDefinition.persistenceKey = persistenceKey;
nodeDefinition.prefix.width = UI::NavigationTreePrefixWidth();
nodeDefinition.prefix.draw = DrawHierarchyTreePrefix;
nodeDefinition.callbacks.onInteraction = [this, gameObject](const UI::TreeNodeResult& node) {
if (node.clicked) {
Actions::HandleHierarchySelectionClick(*m_context, gameObject->GetID(), ImGui::GetIO().KeyCtrl);
}
if (node.doubleClicked) {
BeginRename(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(
&m_treeState,
(void*)gameObject->GetUUID(),
gameObject->GetName().c_str(),
nodeOptions);
if (node.clicked) {
Actions::HandleHierarchySelectionClick(*m_context, gameObject->GetID(), ImGui::GetIO().KeyCtrl);
}
if (node.doubleClicked) {
BeginRename(gameObject);
}
Actions::BeginHierarchyEntityDrag(gameObject);
Actions::AcceptHierarchyEntityDrop(*m_context, gameObject);
Actions::DrawHierarchyEntityContextPopup(*m_context, gameObject);
nodeDefinition);
if (node.open) {
for (size_t i = 0; i < gameObject->GetChildCount(); i++) {

View File

@@ -2,14 +2,13 @@
#include "Panel.h"
#include "UI/PopupState.h"
#include "UI/TreeView.h"
#include <XCEngine/Components/GameObject.h>
#include "Core/ISceneManager.h"
namespace XCEngine {
namespace Editor {
enum class SortMode { Name, ComponentCount, TransformFirst };
class HierarchyPanel : public Panel {
public:
HierarchyPanel();
@@ -21,18 +20,13 @@ public:
private:
void OnSelectionChanged(const struct SelectionChangedEvent& event);
void OnRenameRequested(const struct EntityRenameRequestedEvent& event);
void RenderSearchBar();
void RenderEntity(::XCEngine::Components::GameObject* gameObject, const std::string& filter);
void RenderEntity(::XCEngine::Components::GameObject* gameObject);
void BeginRename(::XCEngine::Components::GameObject* gameObject);
void CommitRename();
void CancelRename();
bool PassesFilter(::XCEngine::Components::GameObject* gameObject, const std::string& filter);
void SortEntities(std::vector<::XCEngine::Components::GameObject*>& entities);
char m_searchBuffer[256] = "";
UI::InlineTextEditState<uint64_t, 256> m_renameState;
UI::DeferredPopupState m_optionsPopup;
SortMode m_sortMode = SortMode::Name;
UI::TreeViewState m_treeState;
uint64_t m_selectionHandlerId = 0;
uint64_t m_renameRequestHandlerId = 0;
};

View File

@@ -14,6 +14,26 @@
namespace XCEngine {
namespace Editor {
namespace {
void DrawProjectFolderTreePrefix(const UI::TreeNodePrefixContext& context) {
if (!context.drawList) {
return;
}
const float iconWidth = 14.0f;
const float iconHeight = 11.0f;
const float minX = context.min.x + ((context.max.x - context.min.x) - iconWidth) * 0.5f;
const float minY = context.min.y + ((context.max.y - context.min.y) - iconHeight) * 0.5f;
UI::DrawAssetIcon(
context.drawList,
ImVec2(minX, minY),
ImVec2(minX + iconWidth, minY + iconHeight),
UI::AssetIconKind::Folder);
}
} // namespace
ProjectPanel::ProjectPanel() : Panel("Project") {
}
@@ -118,19 +138,24 @@ void ProjectPanel::RenderFolderTreeNode(
}
}
UI::TreeNodeOptions nodeOptions;
nodeOptions.selected = folder->fullPath == currentFolderPath;
nodeOptions.leaf = !hasChildFolders;
nodeOptions.defaultOpen = IsCurrentTreeBranch(currentFolderPath, folder->fullPath);
UI::TreeNodeDefinition nodeDefinition;
nodeDefinition.options.selected = folder->fullPath == currentFolderPath;
nodeDefinition.options.leaf = !hasChildFolders;
nodeDefinition.options.defaultOpen = IsCurrentTreeBranch(currentFolderPath, folder->fullPath);
nodeDefinition.persistenceKey = folder->fullPath;
nodeDefinition.prefix.width = UI::NavigationTreePrefixWidth();
nodeDefinition.prefix.draw = DrawProjectFolderTreePrefix;
nodeDefinition.callbacks.onInteraction = [&manager, folder](const UI::TreeNodeResult& node) {
if (node.clicked) {
manager.NavigateToFolder(folder);
}
};
const UI::TreeNodeResult node = UI::DrawTreeNode(
&m_folderTreeState,
(void*)folder.get(),
folder->name.c_str(),
nodeOptions);
if (node.clicked) {
manager.NavigateToFolder(folder);
}
nodeDefinition);
if (node.open) {
for (const auto& child : folder->children) {

View File

@@ -3,6 +3,7 @@
#include "Panel.h"
#include "Core/AssetItem.h"
#include "UI/PopupState.h"
#include "UI/TreeView.h"
namespace XCEngine {
namespace Editor {
@@ -32,6 +33,7 @@ private:
char m_searchBuffer[256] = "";
float m_navigationWidth = UI::ProjectNavigationDefaultWidth();
UI::TreeViewState m_folderTreeState;
UI::TextInputPopupState<256> m_createFolderDialog;
UI::DeferredPopupState m_emptyContextMenu;
UI::TargetedPopupState<AssetItemPtr> m_itemContextMenu;