Advance new editor hosted panels and state flow

This commit is contained in:
2026-04-12 11:12:27 +08:00
parent 7ad4bfbb1c
commit b7ce8618d2
23 changed files with 2059 additions and 381 deletions

View File

@@ -150,11 +150,16 @@ if(XCENGINE_BUILD_XCUI_EDITOR_APP)
app/Commands/ProductEditorHostCommandBridge.cpp app/Commands/ProductEditorHostCommandBridge.cpp
app/Core/ProductEditorContext.cpp app/Core/ProductEditorContext.cpp
app/Core/ProductEditorSession.cpp app/Core/ProductEditorSession.cpp
app/Panels/ProductConsolePanel.cpp
app/Hierarchy/ProductHierarchyModel.cpp
app/Icons/ProductBuiltInIcons.cpp app/Icons/ProductBuiltInIcons.cpp
app/Panels/ProductHierarchyPanel.cpp app/Panels/ProductHierarchyPanel.cpp
app/Panels/ProductInspectorPanel.cpp
app/Panels/ProductProjectPanel.cpp app/Panels/ProductProjectPanel.cpp
app/Project/ProductProjectBrowserModel.cpp
app/Shell/ProductShellAsset.cpp app/Shell/ProductShellAsset.cpp
app/Workspace/ProductEditorWorkspace.cpp app/Workspace/ProductEditorWorkspace.cpp
app/Workspace/ProductEditorWorkspaceEventRouter.cpp
) )
target_include_directories(XCUIEditorApp PRIVATE target_include_directories(XCUIEditorApp PRIVATE

View File

@@ -344,6 +344,39 @@ std::string DescribeProjectPanelEvent(const App::ProductProjectPanel::Event& eve
return stream.str(); return stream.str();
} }
std::string DescribeHierarchyPanelEvent(const App::ProductHierarchyPanel::Event& event) {
std::ostringstream stream = {};
switch (event.kind) {
case App::ProductHierarchyPanel::EventKind::SelectionChanged:
stream << "SelectionChanged";
break;
case App::ProductHierarchyPanel::EventKind::Reparented:
stream << "Reparented";
break;
case App::ProductHierarchyPanel::EventKind::MovedToRoot:
stream << "MovedToRoot";
break;
case App::ProductHierarchyPanel::EventKind::RenameRequested:
stream << "RenameRequested";
break;
case App::ProductHierarchyPanel::EventKind::None:
default:
stream << "None";
break;
}
if (!event.itemId.empty()) {
stream << " item=" << event.itemId;
}
if (!event.targetItemId.empty()) {
stream << " target=" << event.targetItemId;
}
if (!event.label.empty()) {
stream << " label=" << event.label;
}
return stream.str();
}
} // namespace } // namespace
int Application::Run(HINSTANCE hInstance, int nCmdShow) { int Application::Run(HINSTANCE hInstance, int nCmdShow) {
@@ -534,9 +567,8 @@ void Application::RenderFrame() {
frameTrace.str()); frameTrace.str());
} }
ApplyHostCaptureRequests(shellFrame.result); ApplyHostCaptureRequests(shellFrame.result);
for (const App::ProductProjectPanel::Event& event : m_editorWorkspace.GetProjectPanelEvents()) { for (const App::ProductEditorWorkspaceTraceEntry& entry : m_editorWorkspace.GetTraceEntries()) {
LogRuntimeTrace("project", DescribeProjectPanelEvent(event)); LogRuntimeTrace(entry.channel, entry.message);
m_editorContext.SetStatus("Project", DescribeProjectPanelEvent(event));
} }
ApplyHostedContentCaptureRequests(); ApplyHostedContentCaptureRequests();
ApplyCurrentCursor(); ApplyCurrentCursor();

View File

@@ -11,6 +11,7 @@ namespace XCEngine::UI::Editor::App {
namespace { namespace {
using ::XCEngine::UI::Editor::BuildEditorShellShortcutManager; using ::XCEngine::UI::Editor::BuildEditorShellShortcutManager;
constexpr std::size_t kMaxConsoleEntries = 256u;
std::string ComposeStatusText( std::string ComposeStatusText(
std::string_view status, std::string_view status,
@@ -82,6 +83,14 @@ const ProductEditorSession& ProductEditorContext::GetSession() const {
return m_session; return m_session;
} }
void ProductEditorContext::SetSelection(ProductEditorSelectionState selection) {
m_session.selection = std::move(selection);
}
void ProductEditorContext::ClearSelection() {
m_session.selection = {};
}
UIEditorWorkspaceController& ProductEditorContext::GetWorkspaceController() { UIEditorWorkspaceController& ProductEditorContext::GetWorkspaceController() {
return m_workspaceController; return m_workspaceController;
} }
@@ -110,6 +119,9 @@ void ProductEditorContext::SetReadyStatus() {
void ProductEditorContext::SetStatus( void ProductEditorContext::SetStatus(
std::string status, std::string status,
std::string message) { std::string message) {
if (m_lastStatus != status || m_lastMessage != message) {
AppendConsoleEntry(status, message);
}
m_lastStatus = std::move(status); m_lastStatus = std::move(status);
m_lastMessage = std::move(message); m_lastMessage = std::move(message);
} }
@@ -172,6 +184,18 @@ void ProductEditorContext::UpdateStatusFromShellResult(
} }
} }
void ProductEditorContext::AppendConsoleEntry(
std::string channel,
std::string message) {
ProductEditorConsoleEntry entry = {};
entry.channel = std::move(channel);
entry.message = std::move(message);
m_session.consoleEntries.push_back(std::move(entry));
if (m_session.consoleEntries.size() > kMaxConsoleEntries) {
m_session.consoleEntries.erase(m_session.consoleEntries.begin());
}
}
std::string ProductEditorContext::DescribeWorkspaceState( std::string ProductEditorContext::DescribeWorkspaceState(
const UIEditorShellInteractionState& interactionState) const { const UIEditorShellInteractionState& interactionState) const {
std::ostringstream stream = {}; std::ostringstream stream = {};

View File

@@ -26,6 +26,8 @@ public:
const std::string& GetValidationMessage() const; const std::string& GetValidationMessage() const;
const EditorShellAsset& GetShellAsset() const; const EditorShellAsset& GetShellAsset() const;
const ProductEditorSession& GetSession() const; const ProductEditorSession& GetSession() const;
void SetSelection(ProductEditorSelectionState selection);
void ClearSelection();
UIEditorWorkspaceController& GetWorkspaceController(); UIEditorWorkspaceController& GetWorkspaceController();
const UIEditorWorkspaceController& GetWorkspaceController() const; const UIEditorWorkspaceController& GetWorkspaceController() const;
@@ -41,6 +43,8 @@ public:
const UIEditorShellInteractionState& interactionState) const; const UIEditorShellInteractionState& interactionState) const;
private: private:
void AppendConsoleEntry(std::string channel, std::string message);
EditorShellAsset m_shellAsset = {}; EditorShellAsset m_shellAsset = {};
EditorShellAssetValidationResult m_shellValidation = {}; EditorShellAssetValidationResult m_shellValidation = {};
UIEditorWorkspaceController m_workspaceController = {}; UIEditorWorkspaceController m_workspaceController = {};

View File

@@ -35,6 +35,18 @@ std::string_view GetProductEditorActionRouteName(ProductEditorActionRoute route)
} }
} }
std::string_view GetProductEditorSelectionKindName(ProductEditorSelectionKind kind) {
switch (kind) {
case ProductEditorSelectionKind::HierarchyNode:
return "HierarchyNode";
case ProductEditorSelectionKind::ProjectItem:
return "ProjectItem";
case ProductEditorSelectionKind::None:
default:
return "None";
}
}
ProductEditorActionRoute ResolveProductEditorActionRoute(std::string_view panelId) { ProductEditorActionRoute ResolveProductEditorActionRoute(std::string_view panelId) {
if (panelId == "hierarchy") { if (panelId == "hierarchy") {
return ProductEditorActionRoute::Hierarchy; return ProductEditorActionRoute::Hierarchy;

View File

@@ -25,16 +25,38 @@ enum class ProductEditorActionRoute : std::uint8_t {
Game Game
}; };
enum class ProductEditorSelectionKind : std::uint8_t {
None = 0,
HierarchyNode,
ProjectItem
};
struct ProductEditorSelectionState {
ProductEditorSelectionKind kind = ProductEditorSelectionKind::None;
std::string itemId = {};
std::string displayName = {};
std::filesystem::path absolutePath = {};
bool directory = false;
};
struct ProductEditorConsoleEntry {
std::string channel = {};
std::string message = {};
};
struct ProductEditorSession { struct ProductEditorSession {
std::filesystem::path repoRoot = {}; std::filesystem::path repoRoot = {};
std::filesystem::path projectRoot = {}; std::filesystem::path projectRoot = {};
std::string activePanelId = {}; std::string activePanelId = {};
ProductEditorRuntimeMode runtimeMode = ProductEditorRuntimeMode::Edit; ProductEditorRuntimeMode runtimeMode = ProductEditorRuntimeMode::Edit;
ProductEditorActionRoute activeRoute = ProductEditorActionRoute::None; ProductEditorActionRoute activeRoute = ProductEditorActionRoute::None;
ProductEditorSelectionState selection = {};
std::vector<ProductEditorConsoleEntry> consoleEntries = {};
}; };
std::string_view GetProductEditorRuntimeModeName(ProductEditorRuntimeMode mode); std::string_view GetProductEditorRuntimeModeName(ProductEditorRuntimeMode mode);
std::string_view GetProductEditorActionRouteName(ProductEditorActionRoute route); std::string_view GetProductEditorActionRouteName(ProductEditorActionRoute route);
std::string_view GetProductEditorSelectionKindName(ProductEditorSelectionKind kind);
ProductEditorActionRoute ResolveProductEditorActionRoute(std::string_view panelId); ProductEditorActionRoute ResolveProductEditorActionRoute(std::string_view panelId);

View File

@@ -0,0 +1,293 @@
#include "Hierarchy/ProductHierarchyModel.h"
#include <algorithm>
#include <functional>
#include <optional>
#include <sstream>
#include <utility>
namespace XCEngine::UI::Editor::App {
namespace {
const ProductHierarchyNode* FindNodeRecursive(
const std::vector<ProductHierarchyNode>& nodes,
std::string_view nodeId) {
for (const ProductHierarchyNode& node : nodes) {
if (node.nodeId == nodeId) {
return &node;
}
if (const ProductHierarchyNode* child =
FindNodeRecursive(node.children, nodeId);
child != nullptr) {
return child;
}
}
return nullptr;
}
ProductHierarchyNode* FindNodeRecursive(
std::vector<ProductHierarchyNode>& nodes,
std::string_view nodeId) {
for (ProductHierarchyNode& node : nodes) {
if (node.nodeId == nodeId) {
return &node;
}
if (ProductHierarchyNode* child =
FindNodeRecursive(node.children, nodeId);
child != nullptr) {
return child;
}
}
return nullptr;
}
bool FindNodeParentRecursive(
const std::vector<ProductHierarchyNode>& nodes,
std::string_view nodeId,
const ProductHierarchyNode*& parent) {
for (const ProductHierarchyNode& node : nodes) {
for (const ProductHierarchyNode& child : node.children) {
if (child.nodeId == nodeId) {
parent = &node;
return true;
}
}
if (FindNodeParentRecursive(node.children, nodeId, parent)) {
return true;
}
}
return false;
}
bool ExtractNodeRecursive(
std::vector<ProductHierarchyNode>& nodes,
std::string_view nodeId,
ProductHierarchyNode& extractedNode) {
for (std::size_t index = 0u; index < nodes.size(); ++index) {
if (nodes[index].nodeId == nodeId) {
extractedNode = std::move(nodes[index]);
nodes.erase(nodes.begin() + static_cast<std::ptrdiff_t>(index));
return true;
}
if (ExtractNodeRecursive(nodes[index].children, nodeId, extractedNode)) {
return true;
}
}
return false;
}
void BuildTreeItemsRecursive(
const std::vector<ProductHierarchyNode>& nodes,
std::uint32_t depth,
const ::XCEngine::UI::UITextureHandle& icon,
std::vector<Widgets::UIEditorTreeViewItem>& items) {
for (const ProductHierarchyNode& node : nodes) {
Widgets::UIEditorTreeViewItem item = {};
item.itemId = node.nodeId;
item.label = node.label;
item.depth = depth;
item.forceLeaf = node.children.empty();
item.leadingIcon = icon;
items.push_back(std::move(item));
BuildTreeItemsRecursive(node.children, depth + 1u, icon, items);
}
}
} // namespace
ProductHierarchyModel ProductHierarchyModel::BuildDefault() {
ProductHierarchyModel model = {};
model.m_roots = {
ProductHierarchyNode{ "main_camera", "Main Camera", {} },
ProductHierarchyNode{ "directional_light", "Directional Light", {} },
ProductHierarchyNode{
"player",
"Player",
{
ProductHierarchyNode{ "camera_pivot", "Camera Pivot", {} },
ProductHierarchyNode{ "player_mesh", "Mesh", {} }
} },
ProductHierarchyNode{
"environment",
"Environment",
{
ProductHierarchyNode{ "ground", "Ground", {} },
ProductHierarchyNode{
"props",
"Props",
{
ProductHierarchyNode{ "crate_01", "Crate_01", {} },
ProductHierarchyNode{ "barrel_01", "Barrel_01", {} }
} }
} }
};
model.m_nextGeneratedNodeId = 1u;
return model;
}
bool ProductHierarchyModel::Empty() const {
return m_roots.empty();
}
bool ProductHierarchyModel::ContainsNode(std::string_view nodeId) const {
return FindNode(nodeId) != nullptr;
}
const ProductHierarchyNode* ProductHierarchyModel::FindNode(std::string_view nodeId) const {
return FindNodeRecursive(m_roots, nodeId);
}
ProductHierarchyNode* ProductHierarchyModel::FindNode(std::string_view nodeId) {
return FindNodeRecursive(m_roots, nodeId);
}
std::optional<std::string> ProductHierarchyModel::GetParentId(std::string_view nodeId) const {
const ProductHierarchyNode* parent = nullptr;
if (!FindNodeParentRecursive(m_roots, nodeId, parent) || parent == nullptr) {
return std::nullopt;
}
return parent->nodeId;
}
bool ProductHierarchyModel::RenameNode(std::string_view nodeId, std::string label) {
ProductHierarchyNode* node = FindNode(nodeId);
if (node == nullptr || label.empty() || node->label == label) {
return false;
}
node->label = std::move(label);
return true;
}
std::string ProductHierarchyModel::CreateChild(
std::string_view parentId,
std::string_view label) {
ProductHierarchyNode* parent = FindNode(parentId);
if (parent == nullptr) {
return {};
}
ProductHierarchyNode node = {};
node.nodeId = AllocateNodeId();
node.label = label.empty() ? std::string("GameObject") : std::string(label);
parent->children.push_back(std::move(node));
return parent->children.back().nodeId;
}
bool ProductHierarchyModel::DeleteNode(std::string_view nodeId) {
if (nodeId.empty()) {
return false;
}
ProductHierarchyNode removed = {};
return ExtractNodeRecursive(m_roots, nodeId, removed);
}
bool ProductHierarchyModel::CanReparent(
std::string_view sourceNodeId,
std::string_view targetParentId) const {
if (sourceNodeId.empty()) {
return false;
}
const ProductHierarchyNode* source = FindNode(sourceNodeId);
if (source == nullptr) {
return false;
}
if (targetParentId.empty()) {
return true;
}
const ProductHierarchyNode* targetParent = FindNode(targetParentId);
return CanAdopt(sourceNodeId, targetParent);
}
bool ProductHierarchyModel::Reparent(
std::string_view sourceNodeId,
std::string_view targetParentId) {
if (!CanReparent(sourceNodeId, targetParentId) || targetParentId.empty()) {
return false;
}
const std::optional<std::string> currentParentId = GetParentId(sourceNodeId);
if (currentParentId.has_value() && currentParentId.value() == targetParentId) {
return false;
}
ProductHierarchyNode movedNode = {};
if (!ExtractNodeRecursive(m_roots, sourceNodeId, movedNode)) {
return false;
}
ProductHierarchyNode* targetParent = FindNode(targetParentId);
if (targetParent == nullptr) {
return false;
}
targetParent->children.push_back(std::move(movedNode));
return true;
}
bool ProductHierarchyModel::MoveToRoot(std::string_view sourceNodeId) {
if (sourceNodeId.empty() || !ContainsNode(sourceNodeId)) {
return false;
}
if (!GetParentId(sourceNodeId).has_value()) {
return false;
}
ProductHierarchyNode movedNode = {};
if (!ExtractNodeRecursive(m_roots, sourceNodeId, movedNode)) {
return false;
}
m_roots.push_back(std::move(movedNode));
return true;
}
std::vector<Widgets::UIEditorTreeViewItem> ProductHierarchyModel::BuildTreeItems(
const ::XCEngine::UI::UITextureHandle& icon) const {
std::vector<Widgets::UIEditorTreeViewItem> items = {};
BuildTreeItemsRecursive(m_roots, 0u, icon, items);
return items;
}
std::string ProductHierarchyModel::AllocateNodeId() {
std::ostringstream stream = {};
stream << "generated_node_" << m_nextGeneratedNodeId++;
return stream.str();
}
bool ProductHierarchyModel::CanAdopt(
std::string_view sourceNodeId,
const ProductHierarchyNode* targetParent) const {
if (targetParent == nullptr || sourceNodeId == targetParent->nodeId) {
return false;
}
const ProductHierarchyNode* source = FindNode(sourceNodeId);
if (source == nullptr) {
return false;
}
return !ContainsDescendant(*source, targetParent->nodeId);
}
bool ProductHierarchyModel::ContainsDescendant(
const ProductHierarchyNode& node,
std::string_view candidateId) const {
for (const ProductHierarchyNode& child : node.children) {
if (child.nodeId == candidateId || ContainsDescendant(child, candidateId)) {
return true;
}
}
return false;
}
} // namespace XCEngine::UI::Editor::App

View File

@@ -0,0 +1,54 @@
#pragma once
#include <XCEditor/Collections/UIEditorTreeView.h>
#include <XCEngine/UI/Types.h>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
namespace XCEngine::UI::Editor::App {
struct ProductHierarchyNode {
std::string nodeId = {};
std::string label = {};
std::vector<ProductHierarchyNode> children = {};
};
class ProductHierarchyModel {
public:
static ProductHierarchyModel BuildDefault();
bool Empty() const;
bool ContainsNode(std::string_view nodeId) const;
const ProductHierarchyNode* FindNode(std::string_view nodeId) const;
ProductHierarchyNode* FindNode(std::string_view nodeId);
std::optional<std::string> GetParentId(std::string_view nodeId) const;
bool RenameNode(std::string_view nodeId, std::string label);
std::string CreateChild(std::string_view parentId, std::string_view label);
bool DeleteNode(std::string_view nodeId);
bool CanReparent(std::string_view sourceNodeId, std::string_view targetParentId) const;
bool Reparent(std::string_view sourceNodeId, std::string_view targetParentId);
bool MoveToRoot(std::string_view sourceNodeId);
std::vector<Widgets::UIEditorTreeViewItem> BuildTreeItems(
const ::XCEngine::UI::UITextureHandle& icon) const;
private:
std::string AllocateNodeId();
bool CanAdopt(
std::string_view sourceNodeId,
const ProductHierarchyNode* targetParent) const;
bool ContainsDescendant(
const ProductHierarchyNode& node,
std::string_view candidateId) const;
std::vector<ProductHierarchyNode> m_roots = {};
std::uint64_t m_nextGeneratedNodeId = 1u;
};
} // namespace XCEngine::UI::Editor::App

View File

@@ -0,0 +1,109 @@
#include "ProductConsolePanel.h"
#include <XCEditor/Foundation/UIEditorTheme.h>
#include <algorithm>
#include <cmath>
namespace XCEngine::UI::Editor::App {
namespace {
using ::XCEngine::UI::UIColor;
using ::XCEngine::UI::UIDrawList;
using ::XCEngine::UI::UIPoint;
using ::XCEngine::UI::UIRect;
constexpr std::string_view kConsolePanelId = "console";
constexpr float kPadding = 8.0f;
constexpr float kLineHeight = 18.0f;
constexpr float kFontSize = 11.0f;
constexpr UIColor kSurfaceColor(0.205f, 0.205f, 0.205f, 1.0f);
constexpr UIColor kTextColor(0.840f, 0.840f, 0.840f, 1.0f);
constexpr UIColor kChannelColor(0.640f, 0.640f, 0.640f, 1.0f);
constexpr UIColor kEmptyColor(0.580f, 0.580f, 0.580f, 1.0f);
float ResolveTextTop(float rectY, float rectHeight, float fontSize) {
const float lineHeight = fontSize * 1.6f;
return rectY + std::floor((rectHeight - lineHeight) * 0.5f);
}
} // namespace
const UIEditorPanelContentHostPanelState* ProductConsolePanel::FindMountedConsolePanel(
const UIEditorPanelContentHostFrame& contentHostFrame) const {
for (const UIEditorPanelContentHostPanelState& panelState : contentHostFrame.panelStates) {
if (panelState.panelId == kConsolePanelId && panelState.mounted) {
return &panelState;
}
}
return nullptr;
}
void ProductConsolePanel::Update(
const ProductEditorSession& session,
const UIEditorPanelContentHostFrame& contentHostFrame) {
const UIEditorPanelContentHostPanelState* panelState =
FindMountedConsolePanel(contentHostFrame);
if (panelState == nullptr) {
m_visible = false;
m_bounds = {};
m_entries = nullptr;
return;
}
m_visible = true;
m_bounds = panelState->bounds;
m_entries = &session.consoleEntries;
}
void ProductConsolePanel::Append(UIDrawList& drawList) const {
if (!m_visible || m_bounds.width <= 0.0f || m_bounds.height <= 0.0f) {
return;
}
drawList.AddFilledRect(m_bounds, kSurfaceColor);
const UIRect contentRect(
m_bounds.x + kPadding,
m_bounds.y + kPadding,
(std::max)(m_bounds.width - kPadding * 2.0f, 0.0f),
(std::max)(m_bounds.height - kPadding * 2.0f, 0.0f));
if (m_entries == nullptr || m_entries->empty()) {
drawList.AddText(
UIPoint(contentRect.x, ResolveTextTop(contentRect.y, kLineHeight, kFontSize)),
"Console is empty.",
kEmptyColor,
kFontSize);
return;
}
const std::size_t maxVisibleLines = (std::max)(
static_cast<std::size_t>(1),
static_cast<std::size_t>(contentRect.height / kLineHeight));
const std::size_t entryCount = m_entries->size();
const std::size_t firstVisible =
entryCount > maxVisibleLines ? entryCount - maxVisibleLines : 0u;
drawList.PushClipRect(contentRect);
float nextY = contentRect.y;
for (std::size_t index = firstVisible; index < entryCount; ++index) {
const ProductEditorConsoleEntry& entry = (*m_entries)[index];
const std::string line = entry.channel.empty()
? entry.message
: "[" + entry.channel + "] " + entry.message;
drawList.AddText(
UIPoint(contentRect.x, ResolveTextTop(nextY, kLineHeight, kFontSize)),
line,
entry.channel.empty() ? kTextColor : kChannelColor,
kFontSize);
nextY += kLineHeight;
}
drawList.PopClipRect();
}
} // namespace XCEngine::UI::Editor::App

View File

@@ -0,0 +1,27 @@
#pragma once
#include "Core/ProductEditorSession.h"
#include <XCEditor/Shell/UIEditorPanelContentHost.h>
#include <XCEngine/UI/DrawData.h>
namespace XCEngine::UI::Editor::App {
class ProductConsolePanel {
public:
void Update(
const ProductEditorSession& session,
const UIEditorPanelContentHostFrame& contentHostFrame);
void Append(::XCEngine::UI::UIDrawList& drawList) const;
private:
const UIEditorPanelContentHostPanelState* FindMountedConsolePanel(
const UIEditorPanelContentHostFrame& contentHostFrame) const;
bool m_visible = false;
::XCEngine::UI::UIRect m_bounds = {};
const std::vector<ProductEditorConsoleEntry>* m_entries = nullptr;
};
} // namespace XCEngine::UI::Editor::App

View File

@@ -5,6 +5,8 @@
#include <XCEditor/Collections/UIEditorTreeView.h> #include <XCEditor/Collections/UIEditorTreeView.h>
#include <algorithm>
#include <cmath>
#include <string> #include <string>
#include <string_view> #include <string_view>
@@ -12,15 +14,25 @@ namespace XCEngine::UI::Editor::App {
namespace { namespace {
using ::XCEngine::UI::UIColor;
using ::XCEngine::UI::UIDrawList; using ::XCEngine::UI::UIDrawList;
using ::XCEngine::UI::UIInputEvent; using ::XCEngine::UI::UIInputEvent;
using ::XCEngine::UI::UIInputEventType; using ::XCEngine::UI::UIInputEventType;
using ::XCEngine::UI::UIPoint; using ::XCEngine::UI::UIPoint;
using ::XCEngine::UI::UIPointerButton;
using ::XCEngine::UI::UIRect; using ::XCEngine::UI::UIRect;
using Widgets::AppendUIEditorTreeViewBackground; using Widgets::AppendUIEditorTreeViewBackground;
using Widgets::AppendUIEditorTreeViewForeground; using Widgets::AppendUIEditorTreeViewForeground;
using Widgets::DoesUIEditorTreeViewItemHaveChildren;
using Widgets::HitTestUIEditorTreeView;
using Widgets::IsUIEditorTreeViewPointInside;
using Widgets::UIEditorTreeViewHitTarget;
using Widgets::UIEditorTreeViewHitTargetKind;
using Widgets::UIEditorTreeViewInvalidIndex;
constexpr std::string_view kHierarchyPanelId = "hierarchy"; constexpr std::string_view kHierarchyPanelId = "hierarchy";
constexpr float kDragThreshold = 4.0f;
constexpr UIColor kDragPreviewColor(0.82f, 0.82f, 0.82f, 0.55f);
bool ContainsPoint(const UIRect& rect, const UIPoint& point) { bool ContainsPoint(const UIRect& rect, const UIPoint& point) {
return point.x >= rect.x && return point.x >= rect.x &&
@@ -29,12 +41,26 @@ bool ContainsPoint(const UIRect& rect, const UIPoint& point) {
point.y <= rect.y + rect.height; point.y <= rect.y + rect.height;
} }
float ComputeSquaredDistance(const UIPoint& lhs, const UIPoint& rhs) {
const float dx = lhs.x - rhs.x;
const float dy = lhs.y - rhs.y;
return dx * dx + dy * dy;
}
::XCEngine::UI::UITextureHandle ResolveGameObjectIcon(
const ProductBuiltInIcons* icons) {
return icons != nullptr
? icons->Resolve(ProductBuiltInIconKind::GameObject)
: ::XCEngine::UI::UITextureHandle {};
}
std::vector<UIInputEvent> FilterHierarchyInputEvents( std::vector<UIInputEvent> FilterHierarchyInputEvents(
const UIRect& bounds, const UIRect& bounds,
const std::vector<UIInputEvent>& inputEvents, const std::vector<UIInputEvent>& inputEvents,
bool allowInteraction, bool allowInteraction,
bool panelActive) { bool panelActive,
if (!allowInteraction) { bool captureActive) {
if (!allowInteraction && !captureActive) {
return {}; return {};
} }
@@ -46,7 +72,7 @@ std::vector<UIInputEvent> FilterHierarchyInputEvents(
case UIInputEventType::PointerButtonDown: case UIInputEventType::PointerButtonDown:
case UIInputEventType::PointerButtonUp: case UIInputEventType::PointerButtonUp:
case UIInputEventType::PointerWheel: case UIInputEventType::PointerWheel:
if (ContainsPoint(bounds, event.position)) { if (captureActive || ContainsPoint(bounds, event.position)) {
filteredEvents.push_back(event); filteredEvents.push_back(event);
} }
break; break;
@@ -55,6 +81,10 @@ std::vector<UIInputEvent> FilterHierarchyInputEvents(
break; break;
case UIInputEventType::FocusGained: case UIInputEventType::FocusGained:
case UIInputEventType::FocusLost: case UIInputEventType::FocusLost:
if (panelActive || captureActive) {
filteredEvents.push_back(event);
}
break;
case UIInputEventType::KeyDown: case UIInputEventType::KeyDown:
case UIInputEventType::KeyUp: case UIInputEventType::KeyUp:
case UIInputEventType::Character: case UIInputEventType::Character:
@@ -70,16 +100,41 @@ std::vector<UIInputEvent> FilterHierarchyInputEvents(
return filteredEvents; return filteredEvents;
} }
::XCEngine::UI::UITextureHandle ResolveGameObjectIcon( const Widgets::UIEditorTreeViewItem* ResolveHitItem(
const ProductBuiltInIcons* icons) { const Widgets::UIEditorTreeViewLayout& layout,
return icons != nullptr const std::vector<Widgets::UIEditorTreeViewItem>& items,
? icons->Resolve(ProductBuiltInIconKind::GameObject) const UIPoint& point,
: ::XCEngine::UI::UITextureHandle {}; UIEditorTreeViewHitTarget* hitTargetOutput = nullptr) {
const UIEditorTreeViewHitTarget hitTarget = HitTestUIEditorTreeView(layout, point);
if (hitTargetOutput != nullptr) {
*hitTargetOutput = hitTarget;
}
if (hitTarget.itemIndex >= items.size()) {
return nullptr;
}
return &items[hitTarget.itemIndex];
}
std::size_t FindVisibleIndexForItemId(
const Widgets::UIEditorTreeViewLayout& layout,
const std::vector<Widgets::UIEditorTreeViewItem>& items,
std::string_view itemId) {
for (std::size_t visibleIndex = 0u; visibleIndex < layout.visibleItemIndices.size(); ++visibleIndex) {
const std::size_t itemIndex = layout.visibleItemIndices[visibleIndex];
if (itemIndex < items.size() && items[itemIndex].itemId == itemId) {
return visibleIndex;
}
}
return UIEditorTreeViewInvalidIndex;
} }
} // namespace } // namespace
void ProductHierarchyPanel::Initialize() { void ProductHierarchyPanel::Initialize() {
m_model = ProductHierarchyModel::BuildDefault();
RebuildItems(); RebuildItems();
} }
@@ -99,35 +154,25 @@ const UIEditorPanelContentHostPanelState* ProductHierarchyPanel::FindMountedHier
return nullptr; return nullptr;
} }
void ProductHierarchyPanel::ResetTransientState() {
m_frameEvents.clear();
m_dragState.requestPointerCapture = false;
m_dragState.requestPointerRelease = false;
}
void ProductHierarchyPanel::RebuildItems() { void ProductHierarchyPanel::RebuildItems() {
const auto icon = ResolveGameObjectIcon(m_icons); const auto icon = ResolveGameObjectIcon(m_icons);
const std::string previousSelection = const std::string previousSelection =
m_selection.HasSelection() ? m_selection.GetSelectedId() : std::string(); m_selection.HasSelection() ? m_selection.GetSelectedId() : std::string();
m_treeItems = { m_treeItems = m_model.BuildTreeItems(icon);
{ "main-camera", "Main Camera", 0u, true, 0.0f, icon },
{ "directional-light", "Directional Light", 0u, true, 0.0f, icon },
{ "player", "Player", 0u, false, 0.0f, icon },
{ "player/camera-pivot", "Camera Pivot", 1u, true, 0.0f, icon },
{ "player/mesh", "Mesh", 1u, true, 0.0f, icon },
{ "environment", "Environment", 0u, false, 0.0f, icon },
{ "environment/ground", "Ground", 1u, true, 0.0f, icon },
{ "environment/props", "Props", 1u, false, 0.0f, icon },
{ "environment/props/crate-01", "Crate_01", 2u, true, 0.0f, icon },
{ "environment/props/barrel-01", "Barrel_01", 2u, true, 0.0f, icon }
};
m_expansion.Expand("player"); m_expansion.Expand("player");
m_expansion.Expand("environment"); m_expansion.Expand("environment");
m_expansion.Expand("environment/props"); m_expansion.Expand("props");
if (!previousSelection.empty()) { if (!previousSelection.empty() && m_model.ContainsNode(previousSelection)) {
for (const Widgets::UIEditorTreeViewItem& item : m_treeItems) { m_selection.SetSelection(previousSelection);
if (item.itemId == previousSelection) { return;
m_selection.SetSelection(previousSelection);
return;
}
}
} }
if (!m_treeItems.empty()) { if (!m_treeItems.empty()) {
@@ -135,16 +180,295 @@ void ProductHierarchyPanel::RebuildItems() {
} }
} }
void ProductHierarchyPanel::EmitSelectionEvent() {
if (!m_selection.HasSelection()) {
return;
}
const ProductHierarchyNode* node = m_model.FindNode(m_selection.GetSelectedId());
if (node == nullptr) {
return;
}
Event event = {};
event.kind = EventKind::SelectionChanged;
event.itemId = node->nodeId;
event.label = node->label;
m_frameEvents.push_back(std::move(event));
}
void ProductHierarchyPanel::EmitReparentEvent(
EventKind kind,
std::string itemId,
std::string targetItemId) {
Event event = {};
event.kind = kind;
event.itemId = std::move(itemId);
event.targetItemId = std::move(targetItemId);
if (const ProductHierarchyNode* node = m_model.FindNode(event.itemId); node != nullptr) {
event.label = node->label;
}
m_frameEvents.push_back(std::move(event));
}
std::vector<UIInputEvent> ProductHierarchyPanel::BuildInteractionInputEvents(
const std::vector<UIInputEvent>& inputEvents,
const UIRect& bounds,
bool allowInteraction,
bool panelActive) const {
const std::vector<UIInputEvent> rawEvents = FilterHierarchyInputEvents(
bounds,
inputEvents,
allowInteraction,
panelActive,
HasActivePointerCapture());
struct DragPreviewState {
std::string armedItemId = {};
UIPoint pressPosition = {};
bool armed = false;
bool dragging = false;
};
const Widgets::UIEditorTreeViewLayout layout =
m_treeFrame.layout.bounds.width > 0.0f ? m_treeFrame.layout : Widgets::BuildUIEditorTreeViewLayout(
bounds,
m_treeItems,
m_expansion,
BuildProductTreeViewMetrics());
DragPreviewState preview = {};
preview.armed = m_dragState.armed;
preview.armedItemId = m_dragState.armedItemId;
preview.pressPosition = m_dragState.pressPosition;
preview.dragging = m_dragState.dragging;
std::vector<UIInputEvent> filteredEvents = {};
filteredEvents.reserve(rawEvents.size());
for (const UIInputEvent& event : rawEvents) {
bool suppress = false;
switch (event.type) {
case UIInputEventType::PointerButtonDown:
if (event.pointerButton == UIPointerButton::Left) {
UIEditorTreeViewHitTarget hitTarget = {};
const Widgets::UIEditorTreeViewItem* hitItem =
ResolveHitItem(layout, m_treeItems, event.position, &hitTarget);
if (hitItem != nullptr &&
hitTarget.kind == UIEditorTreeViewHitTargetKind::Row) {
preview.armed = true;
preview.armedItemId = hitItem->itemId;
preview.pressPosition = event.position;
} else {
preview.armed = false;
preview.armedItemId.clear();
}
}
if (preview.dragging) {
suppress = true;
}
break;
case UIInputEventType::PointerMove:
if (preview.dragging) {
suppress = true;
break;
}
if (preview.armed &&
ComputeSquaredDistance(event.position, preview.pressPosition) >=
kDragThreshold * kDragThreshold) {
preview.dragging = true;
suppress = true;
}
break;
case UIInputEventType::PointerButtonUp:
if (event.pointerButton == UIPointerButton::Left) {
if (preview.dragging) {
suppress = true;
preview.dragging = false;
}
preview.armed = false;
preview.armedItemId.clear();
} else if (preview.dragging) {
suppress = true;
}
break;
case UIInputEventType::PointerLeave:
if (preview.dragging) {
suppress = true;
}
break;
case UIInputEventType::FocusLost:
preview.armed = false;
preview.dragging = false;
preview.armedItemId.clear();
break;
default:
break;
}
if (!suppress) {
filteredEvents.push_back(event);
}
}
return filteredEvents;
}
void ProductHierarchyPanel::ProcessDragAndFrameEvents(
const std::vector<UIInputEvent>& inputEvents,
const UIRect& bounds,
bool allowInteraction,
bool panelActive) {
const std::vector<UIInputEvent> filteredEvents = FilterHierarchyInputEvents(
bounds,
inputEvents,
allowInteraction,
panelActive,
HasActivePointerCapture());
if (m_treeFrame.result.selectionChanged) {
EmitSelectionEvent();
}
if (m_treeFrame.result.renameRequested &&
!m_treeFrame.result.renameItemId.empty()) {
Event event = {};
event.kind = EventKind::RenameRequested;
event.itemId = m_treeFrame.result.renameItemId;
if (const ProductHierarchyNode* node = m_model.FindNode(event.itemId); node != nullptr) {
event.label = node->label;
}
m_frameEvents.push_back(std::move(event));
}
for (const UIInputEvent& event : filteredEvents) {
switch (event.type) {
case UIInputEventType::PointerButtonDown:
if (event.pointerButton == UIPointerButton::Left) {
UIEditorTreeViewHitTarget hitTarget = {};
const Widgets::UIEditorTreeViewItem* hitItem =
ResolveHitItem(m_treeFrame.layout, m_treeItems, event.position, &hitTarget);
if (hitItem != nullptr &&
hitTarget.kind == UIEditorTreeViewHitTargetKind::Row) {
m_dragState.armed = true;
m_dragState.armedItemId = hitItem->itemId;
m_dragState.pressPosition = event.position;
} else {
m_dragState.armed = false;
m_dragState.armedItemId.clear();
}
}
break;
case UIInputEventType::PointerMove:
if (m_dragState.armed && !m_dragState.dragging &&
ComputeSquaredDistance(event.position, m_dragState.pressPosition) >=
kDragThreshold * kDragThreshold) {
m_dragState.dragging = !m_dragState.armedItemId.empty();
m_dragState.draggedItemId = m_dragState.armedItemId;
m_dragState.dropTargetItemId.clear();
m_dragState.dropToRoot = false;
m_dragState.validDropTarget = false;
if (m_dragState.dragging) {
m_dragState.requestPointerCapture = true;
if (!m_selection.IsSelected(m_dragState.draggedItemId)) {
m_selection.SetSelection(m_dragState.draggedItemId);
EmitSelectionEvent();
}
}
}
if (m_dragState.dragging) {
UIEditorTreeViewHitTarget hitTarget = {};
const Widgets::UIEditorTreeViewItem* hitItem =
ResolveHitItem(m_treeFrame.layout, m_treeItems, event.position, &hitTarget);
m_dragState.dropTargetItemId.clear();
m_dragState.dropToRoot = false;
m_dragState.validDropTarget = false;
if (hitItem != nullptr &&
(hitTarget.kind == UIEditorTreeViewHitTargetKind::Row ||
hitTarget.kind == UIEditorTreeViewHitTargetKind::Disclosure)) {
m_dragState.dropTargetItemId = hitItem->itemId;
m_dragState.validDropTarget =
m_model.CanReparent(
m_dragState.draggedItemId,
m_dragState.dropTargetItemId);
} else if (ContainsPoint(bounds, event.position)) {
m_dragState.dropToRoot = true;
m_dragState.validDropTarget =
m_model.GetParentId(m_dragState.draggedItemId).has_value();
}
}
break;
case UIInputEventType::PointerButtonUp:
if (event.pointerButton != UIPointerButton::Left) {
break;
}
if (m_dragState.dragging) {
if (m_dragState.validDropTarget) {
const std::string draggedItemId = m_dragState.draggedItemId;
const std::string dropTargetItemId = m_dragState.dropTargetItemId;
const bool changed =
m_dragState.dropToRoot
? m_model.MoveToRoot(draggedItemId)
: m_model.Reparent(draggedItemId, dropTargetItemId);
if (changed) {
RebuildItems();
EmitReparentEvent(
m_dragState.dropToRoot ? EventKind::MovedToRoot : EventKind::Reparented,
draggedItemId,
dropTargetItemId);
}
}
m_dragState.armed = false;
m_dragState.dragging = false;
m_dragState.armedItemId.clear();
m_dragState.draggedItemId.clear();
m_dragState.dropTargetItemId.clear();
m_dragState.dropToRoot = false;
m_dragState.validDropTarget = false;
m_dragState.requestPointerRelease = true;
} else {
m_dragState.armed = false;
m_dragState.armedItemId.clear();
}
break;
case UIInputEventType::FocusLost:
if (m_dragState.dragging) {
m_dragState.requestPointerRelease = true;
}
m_dragState = {};
break;
default:
break;
}
}
}
void ProductHierarchyPanel::Update( void ProductHierarchyPanel::Update(
const UIEditorPanelContentHostFrame& contentHostFrame, const UIEditorPanelContentHostFrame& contentHostFrame,
const std::vector<UIInputEvent>& inputEvents, const std::vector<UIInputEvent>& inputEvents,
bool allowInteraction, bool allowInteraction,
bool panelActive) { bool panelActive) {
ResetTransientState();
const UIEditorPanelContentHostPanelState* panelState = const UIEditorPanelContentHostPanelState* panelState =
FindMountedHierarchyPanel(contentHostFrame); FindMountedHierarchyPanel(contentHostFrame);
if (panelState == nullptr) { if (panelState == nullptr) {
m_visible = false; m_visible = false;
m_treeFrame = {}; m_treeFrame = {};
m_dragState = {};
return; return;
} }
@@ -153,14 +477,25 @@ void ProductHierarchyPanel::Update(
} }
m_visible = true; m_visible = true;
const std::vector<UIInputEvent> interactionEvents =
BuildInteractionInputEvents(
inputEvents,
panelState->bounds,
allowInteraction,
panelActive);
m_treeFrame = UpdateUIEditorTreeViewInteraction( m_treeFrame = UpdateUIEditorTreeViewInteraction(
m_treeInteractionState, m_treeInteractionState,
m_selection, m_selection,
m_expansion, m_expansion,
panelState->bounds, panelState->bounds,
m_treeItems, m_treeItems,
FilterHierarchyInputEvents(panelState->bounds, inputEvents, allowInteraction, panelActive), interactionEvents,
BuildProductTreeViewMetrics()); BuildProductTreeViewMetrics());
ProcessDragAndFrameEvents(
inputEvents,
panelState->bounds,
allowInteraction,
panelActive);
} }
void ProductHierarchyPanel::Append(UIDrawList& drawList) const { void ProductHierarchyPanel::Append(UIDrawList& drawList) const {
@@ -184,6 +519,50 @@ void ProductHierarchyPanel::Append(UIDrawList& drawList) const {
m_treeItems, m_treeItems,
palette, palette,
metrics); metrics);
if (!m_dragState.dragging || !m_dragState.validDropTarget) {
return;
}
if (m_dragState.dropToRoot) {
drawList.AddRectOutline(
m_treeFrame.layout.bounds,
kDragPreviewColor,
1.0f,
0.0f);
return;
}
const std::size_t visibleIndex = FindVisibleIndexForItemId(
m_treeFrame.layout,
m_treeItems,
m_dragState.dropTargetItemId);
if (visibleIndex == UIEditorTreeViewInvalidIndex ||
visibleIndex >= m_treeFrame.layout.rowRects.size()) {
return;
}
drawList.AddRectOutline(
m_treeFrame.layout.rowRects[visibleIndex],
kDragPreviewColor,
1.0f,
0.0f);
}
bool ProductHierarchyPanel::WantsHostPointerCapture() const {
return m_dragState.requestPointerCapture;
}
bool ProductHierarchyPanel::WantsHostPointerRelease() const {
return m_dragState.requestPointerRelease;
}
bool ProductHierarchyPanel::HasActivePointerCapture() const {
return m_dragState.dragging;
}
const std::vector<ProductHierarchyPanel::Event>& ProductHierarchyPanel::GetFrameEvents() const {
return m_frameEvents;
} }
} // namespace XCEngine::UI::Editor::App } // namespace XCEngine::UI::Editor::App

View File

@@ -1,5 +1,7 @@
#pragma once #pragma once
#include "Hierarchy/ProductHierarchyModel.h"
#include <XCEditor/Collections/UIEditorTreeViewInteraction.h> #include <XCEditor/Collections/UIEditorTreeViewInteraction.h>
#include <XCEditor/Shell/UIEditorPanelContentHost.h> #include <XCEditor/Shell/UIEditorPanelContentHost.h>
@@ -7,6 +9,8 @@
#include <XCEngine/UI/Widgets/UIExpansionModel.h> #include <XCEngine/UI/Widgets/UIExpansionModel.h>
#include <XCEngine/UI/Widgets/UISelectionModel.h> #include <XCEngine/UI/Widgets/UISelectionModel.h>
#include <cstdint>
#include <string>
#include <vector> #include <vector>
namespace XCEngine::UI::Editor::App { namespace XCEngine::UI::Editor::App {
@@ -15,6 +19,21 @@ class ProductBuiltInIcons;
class ProductHierarchyPanel { class ProductHierarchyPanel {
public: public:
enum class EventKind : std::uint8_t {
None = 0,
SelectionChanged,
Reparented,
MovedToRoot,
RenameRequested
};
struct Event {
EventKind kind = EventKind::None;
std::string itemId = {};
std::string targetItemId = {};
std::string label = {};
};
void Initialize(); void Initialize();
void SetBuiltInIcons(const ProductBuiltInIcons* icons); void SetBuiltInIcons(const ProductBuiltInIcons* icons);
void Update( void Update(
@@ -23,18 +42,54 @@ public:
bool allowInteraction, bool allowInteraction,
bool panelActive); bool panelActive);
void Append(::XCEngine::UI::UIDrawList& drawList) const; void Append(::XCEngine::UI::UIDrawList& drawList) const;
bool WantsHostPointerCapture() const;
bool WantsHostPointerRelease() const;
bool HasActivePointerCapture() const;
const std::vector<Event>& GetFrameEvents() const;
private: private:
struct DragState {
std::string armedItemId = {};
std::string draggedItemId = {};
std::string dropTargetItemId = {};
::XCEngine::UI::UIPoint pressPosition = {};
bool armed = false;
bool dragging = false;
bool dropToRoot = false;
bool validDropTarget = false;
bool requestPointerCapture = false;
bool requestPointerRelease = false;
};
const UIEditorPanelContentHostPanelState* FindMountedHierarchyPanel( const UIEditorPanelContentHostPanelState* FindMountedHierarchyPanel(
const UIEditorPanelContentHostFrame& contentHostFrame) const; const UIEditorPanelContentHostFrame& contentHostFrame) const;
void ResetTransientState();
void RebuildItems(); void RebuildItems();
void ProcessDragAndFrameEvents(
const std::vector<::XCEngine::UI::UIInputEvent>& inputEvents,
const ::XCEngine::UI::UIRect& bounds,
bool allowInteraction,
bool panelActive);
void EmitSelectionEvent();
void EmitReparentEvent(
EventKind kind,
std::string itemId,
std::string targetItemId);
std::vector<::XCEngine::UI::UIInputEvent> BuildInteractionInputEvents(
const std::vector<::XCEngine::UI::UIInputEvent>& inputEvents,
const ::XCEngine::UI::UIRect& bounds,
bool allowInteraction,
bool panelActive) const;
const ProductBuiltInIcons* m_icons = nullptr; const ProductBuiltInIcons* m_icons = nullptr;
ProductHierarchyModel m_model = {};
std::vector<Widgets::UIEditorTreeViewItem> m_treeItems = {}; std::vector<Widgets::UIEditorTreeViewItem> m_treeItems = {};
::XCEngine::UI::Widgets::UISelectionModel m_selection = {}; ::XCEngine::UI::Widgets::UISelectionModel m_selection = {};
::XCEngine::UI::Widgets::UIExpansionModel m_expansion = {}; ::XCEngine::UI::Widgets::UIExpansionModel m_expansion = {};
UIEditorTreeViewInteractionState m_treeInteractionState = {}; UIEditorTreeViewInteractionState m_treeInteractionState = {};
UIEditorTreeViewInteractionFrame m_treeFrame = {}; UIEditorTreeViewInteractionFrame m_treeFrame = {};
std::vector<Event> m_frameEvents = {};
DragState m_dragState = {};
bool m_visible = false; bool m_visible = false;
}; };

View File

@@ -0,0 +1,224 @@
#include "ProductInspectorPanel.h"
#include <XCEditor/Foundation/UIEditorTheme.h>
#include <algorithm>
namespace XCEngine::UI::Editor::App {
namespace {
using ::XCEngine::UI::UIColor;
using ::XCEngine::UI::UIDrawList;
using ::XCEngine::UI::UIPoint;
using ::XCEngine::UI::UIRect;
constexpr std::string_view kInspectorPanelId = "inspector";
constexpr float kPanelPadding = 10.0f;
constexpr float kHeaderHeight = 22.0f;
constexpr float kSectionGap = 10.0f;
constexpr float kRowHeight = 21.0f;
constexpr float kLabelWidth = 88.0f;
constexpr float kTitleFontSize = 13.0f;
constexpr float kSubtitleFontSize = 11.0f;
constexpr float kSectionTitleFontSize = 11.0f;
constexpr float kRowFontSize = 11.0f;
constexpr UIColor kSurfaceColor(0.205f, 0.205f, 0.205f, 1.0f);
constexpr UIColor kSectionHeaderColor(0.225f, 0.225f, 0.225f, 1.0f);
constexpr UIColor kSectionBodyColor(0.215f, 0.215f, 0.215f, 1.0f);
constexpr UIColor kTitleColor(0.910f, 0.910f, 0.910f, 1.0f);
constexpr UIColor kSubtitleColor(0.650f, 0.650f, 0.650f, 1.0f);
constexpr UIColor kLabelColor(0.700f, 0.700f, 0.700f, 1.0f);
constexpr UIColor kValueColor(0.860f, 0.860f, 0.860f, 1.0f);
float ResolveTextTop(float rectY, float rectHeight, float fontSize) {
const float lineHeight = fontSize * 1.6f;
return rectY + std::floor((rectHeight - lineHeight) * 0.5f);
}
std::string PathToUtf8String(const std::filesystem::path& path) {
const std::u8string value = path.u8string();
return std::string(value.begin(), value.end());
}
} // namespace
const UIEditorPanelContentHostPanelState* ProductInspectorPanel::FindMountedInspectorPanel(
const UIEditorPanelContentHostFrame& contentHostFrame) const {
for (const UIEditorPanelContentHostPanelState& panelState : contentHostFrame.panelStates) {
if (panelState.panelId == kInspectorPanelId && panelState.mounted) {
return &panelState;
}
}
return nullptr;
}
void ProductInspectorPanel::BuildPresentation(const ProductEditorSession& session) {
m_sections.clear();
m_title.clear();
m_subtitle.clear();
m_hasSelection = false;
switch (session.selection.kind) {
case ProductEditorSelectionKind::HierarchyNode: {
m_hasSelection = true;
m_title = session.selection.displayName.empty()
? std::string("GameObject")
: session.selection.displayName;
m_subtitle = "GameObject";
Section identity = {};
identity.title = "Identity";
identity.rows = {
{ "Type", "GameObject" },
{ "Name", m_title },
{ "Id", session.selection.itemId }
};
m_sections.push_back(std::move(identity));
break;
}
case ProductEditorSelectionKind::ProjectItem: {
m_hasSelection = true;
m_title = session.selection.displayName.empty()
? (session.selection.directory ? std::string("Folder") : std::string("Asset"))
: session.selection.displayName;
m_subtitle = session.selection.directory ? "Folder" : "Asset";
Section identity = {};
identity.title = "Identity";
identity.rows = {
{ "Type", session.selection.directory ? std::string("Folder") : std::string("Asset") },
{ "Name", m_title },
{ "Id", session.selection.itemId }
};
m_sections.push_back(std::move(identity));
Section location = {};
location.title = "Location";
location.rows = {
{ "Path", PathToUtf8String(session.selection.absolutePath) }
};
m_sections.push_back(std::move(location));
break;
}
case ProductEditorSelectionKind::None:
default:
m_title = "Nothing selected";
m_subtitle = "Select a hierarchy item or project asset.";
break;
}
}
void ProductInspectorPanel::Update(
const ProductEditorSession& session,
const UIEditorPanelContentHostFrame& contentHostFrame) {
const UIEditorPanelContentHostPanelState* panelState =
FindMountedInspectorPanel(contentHostFrame);
if (panelState == nullptr) {
m_visible = false;
m_bounds = {};
m_sections.clear();
m_title.clear();
m_subtitle.clear();
m_hasSelection = false;
return;
}
m_visible = true;
m_bounds = panelState->bounds;
BuildPresentation(session);
}
void ProductInspectorPanel::Append(UIDrawList& drawList) const {
if (!m_visible || m_bounds.width <= 0.0f || m_bounds.height <= 0.0f) {
return;
}
const UIColor borderColor = ResolveUIEditorDockHostPalette().splitterColor;
drawList.AddFilledRect(m_bounds, kSurfaceColor);
const float contentX = m_bounds.x + kPanelPadding;
const float contentWidth = (std::max)(m_bounds.width - kPanelPadding * 2.0f, 0.0f);
float nextY = m_bounds.y + kPanelPadding;
const UIRect titleRect(contentX, nextY, contentWidth, 18.0f);
drawList.AddText(
UIPoint(titleRect.x, ResolveTextTop(titleRect.y, titleRect.height, kTitleFontSize)),
m_title,
kTitleColor,
kTitleFontSize);
nextY += titleRect.height;
const UIRect subtitleRect(contentX, nextY, contentWidth, 16.0f);
drawList.AddText(
UIPoint(subtitleRect.x, ResolveTextTop(subtitleRect.y, subtitleRect.height, kSubtitleFontSize)),
m_subtitle,
kSubtitleColor,
kSubtitleFontSize);
nextY += subtitleRect.height + kSectionGap;
if (!m_hasSelection) {
return;
}
for (const Section& section : m_sections) {
const float bodyHeight = static_cast<float>(section.rows.size()) * kRowHeight;
const UIRect headerRect(contentX, nextY, contentWidth, kHeaderHeight);
const UIRect bodyRect(contentX, headerRect.y + headerRect.height, contentWidth, bodyHeight);
drawList.AddFilledRect(headerRect, kSectionHeaderColor);
drawList.AddFilledRect(bodyRect, kSectionBodyColor);
drawList.AddRectOutline(
UIRect(headerRect.x, headerRect.y, headerRect.width, headerRect.height + bodyRect.height),
borderColor,
1.0f,
0.0f);
drawList.AddText(
UIPoint(headerRect.x + 8.0f, ResolveTextTop(headerRect.y, headerRect.height, kSectionTitleFontSize)),
section.title,
kTitleColor,
kSectionTitleFontSize);
float rowY = bodyRect.y;
for (std::size_t rowIndex = 0u; rowIndex < section.rows.size(); ++rowIndex) {
const SectionRow& row = section.rows[rowIndex];
const UIRect rowRect(contentX, rowY, contentWidth, kRowHeight);
const UIRect labelRect(rowRect.x + 8.0f, rowRect.y, kLabelWidth, rowRect.height);
const UIRect valueRect(
labelRect.x + labelRect.width + 8.0f,
rowRect.y,
(std::max)(rowRect.width - (labelRect.width + 24.0f), 0.0f),
rowRect.height);
if (rowIndex > 0u) {
drawList.AddFilledRect(
UIRect(rowRect.x, rowRect.y, rowRect.width, 1.0f),
borderColor);
}
drawList.AddText(
UIPoint(labelRect.x, ResolveTextTop(labelRect.y, labelRect.height, kRowFontSize)),
row.label,
kLabelColor,
kRowFontSize);
drawList.PushClipRect(valueRect);
drawList.AddText(
UIPoint(valueRect.x, ResolveTextTop(valueRect.y, valueRect.height, kRowFontSize)),
row.value,
kValueColor,
kRowFontSize);
drawList.PopClipRect();
rowY += kRowHeight;
}
nextY = bodyRect.y + bodyRect.height + kSectionGap;
}
}
} // namespace XCEngine::UI::Editor::App

View File

@@ -0,0 +1,44 @@
#pragma once
#include "Core/ProductEditorSession.h"
#include <XCEditor/Shell/UIEditorPanelContentHost.h>
#include <XCEngine/UI/DrawData.h>
#include <string>
#include <vector>
namespace XCEngine::UI::Editor::App {
class ProductInspectorPanel {
public:
void Update(
const ProductEditorSession& session,
const UIEditorPanelContentHostFrame& contentHostFrame);
void Append(::XCEngine::UI::UIDrawList& drawList) const;
private:
struct SectionRow {
std::string label = {};
std::string value = {};
};
struct Section {
std::string title = {};
std::vector<SectionRow> rows = {};
};
const UIEditorPanelContentHostPanelState* FindMountedInspectorPanel(
const UIEditorPanelContentHostFrame& contentHostFrame) const;
void BuildPresentation(const ProductEditorSession& session);
bool m_visible = false;
bool m_hasSelection = false;
::XCEngine::UI::UIRect m_bounds = {};
std::string m_title = {};
std::string m_subtitle = {};
std::vector<Section> m_sections = {};
};
} // namespace XCEngine::UI::Editor::App

View File

@@ -7,11 +7,9 @@
#include <XCEditor/Foundation/UIEditorTheme.h> #include <XCEditor/Foundation/UIEditorTheme.h>
#include <algorithm> #include <algorithm>
#include <cctype>
#include <cmath> #include <cmath>
#include <cstdint> #include <cstdint>
#include <string_view> #include <string_view>
#include <system_error>
#include <utility> #include <utility>
#include <windows.h> #include <windows.h>
@@ -32,7 +30,6 @@ using Widgets::AppendUIEditorTreeViewBackground;
using Widgets::AppendUIEditorTreeViewForeground; using Widgets::AppendUIEditorTreeViewForeground;
constexpr std::string_view kProjectPanelId = "project"; constexpr std::string_view kProjectPanelId = "project";
constexpr std::string_view kAssetsRootId = "Assets";
constexpr std::size_t kInvalidLayoutIndex = static_cast<std::size_t>(-1); constexpr std::size_t kInvalidLayoutIndex = static_cast<std::size_t>(-1);
constexpr float kBrowserHeaderHeight = 24.0f; constexpr float kBrowserHeaderHeight = 24.0f;
@@ -102,122 +99,6 @@ float MeasureTextWidth(
return static_cast<float>(text.size()) * fontSize * 0.56f; return static_cast<float>(text.size()) * fontSize * 0.56f;
} }
std::string ToLowerCopy(std::string value) {
std::transform(
value.begin(),
value.end(),
value.begin(),
[](unsigned char character) {
return static_cast<char>(std::tolower(character));
});
return value;
}
std::string WideToUtf8(std::wstring_view value) {
if (value.empty()) {
return {};
}
const int requiredSize = WideCharToMultiByte(
CP_UTF8,
0,
value.data(),
static_cast<int>(value.size()),
nullptr,
0,
nullptr,
nullptr);
if (requiredSize <= 0) {
return {};
}
std::string result(static_cast<std::size_t>(requiredSize), '\0');
WideCharToMultiByte(
CP_UTF8,
0,
value.data(),
static_cast<int>(value.size()),
result.data(),
requiredSize,
nullptr,
nullptr);
return result;
}
std::string PathToUtf8String(const std::filesystem::path& path) {
return WideToUtf8(path.native());
}
std::string NormalizePathSeparators(std::string value) {
std::replace(value.begin(), value.end(), '\\', '/');
return value;
}
std::string BuildRelativeItemId(
const std::filesystem::path& path,
const std::filesystem::path& assetsRoot) {
const std::filesystem::path relative =
std::filesystem::relative(path, assetsRoot.parent_path());
const std::string normalized =
NormalizePathSeparators(PathToUtf8String(relative.lexically_normal()));
return normalized.empty() ? std::string(kAssetsRootId) : normalized;
}
std::string BuildAssetDisplayName(const std::filesystem::path& path, bool directory) {
if (directory) {
return PathToUtf8String(path.filename());
}
const std::string filename = PathToUtf8String(path.filename());
const std::size_t extensionOffset = filename.find_last_of('.');
if (extensionOffset == std::string::npos || extensionOffset == 0u) {
return filename;
}
return filename.substr(0u, extensionOffset);
}
bool IsMetaFile(const std::filesystem::path& path) {
return ToLowerCopy(path.extension().string()) == ".meta";
}
bool HasChildDirectories(const std::filesystem::path& folderPath) {
std::error_code errorCode = {};
const std::filesystem::directory_iterator end = {};
for (std::filesystem::directory_iterator iterator(folderPath, errorCode);
!errorCode && iterator != end;
iterator.increment(errorCode)) {
if (iterator->is_directory(errorCode)) {
return true;
}
}
return false;
}
std::vector<std::filesystem::path> CollectSortedChildDirectories(
const std::filesystem::path& folderPath) {
std::vector<std::filesystem::path> paths = {};
std::error_code errorCode = {};
const std::filesystem::directory_iterator end = {};
for (std::filesystem::directory_iterator iterator(folderPath, errorCode);
!errorCode && iterator != end;
iterator.increment(errorCode)) {
if (iterator->is_directory(errorCode)) {
paths.push_back(iterator->path());
}
}
std::sort(
paths.begin(),
paths.end(),
[](const std::filesystem::path& lhs, const std::filesystem::path& rhs) {
return ToLowerCopy(PathToUtf8String(lhs.filename())) <
ToLowerCopy(PathToUtf8String(rhs.filename()));
});
return paths;
}
std::vector<UIInputEvent> FilterProjectPanelInputEvents( std::vector<UIInputEvent> FilterProjectPanelInputEvents(
const UIRect& bounds, const UIRect& bounds,
const std::vector<UIInputEvent>& inputEvents, const std::vector<UIInputEvent>& inputEvents,
@@ -303,35 +184,6 @@ float ClampNavigationWidth(float value, float totalWidth) {
return std::clamp(value, kNavigationMinWidth, maxWidth); return std::clamp(value, kNavigationMinWidth, maxWidth);
} }
std::vector<std::string> BuildBreadcrumbSegments(std::string_view currentFolderId) {
std::vector<std::string> segments = {};
if (currentFolderId.empty()) {
segments.push_back(std::string(kAssetsRootId));
return segments;
}
std::size_t segmentStart = 0u;
while (segmentStart < currentFolderId.size()) {
const std::size_t separator = currentFolderId.find('/', segmentStart);
const std::size_t segmentLength =
separator == std::string_view::npos
? currentFolderId.size() - segmentStart
: separator - segmentStart;
if (segmentLength > 0u) {
segments.emplace_back(currentFolderId.substr(segmentStart, segmentLength));
}
if (separator == std::string_view::npos) {
break;
}
segmentStart = separator + 1u;
}
if (segments.empty()) {
segments.push_back(std::string(kAssetsRootId));
}
return segments;
}
void AppendTilePreview( void AppendTilePreview(
UIDrawList& drawList, UIDrawList& drawList,
const UIRect& previewRect, const UIRect& previewRect,
@@ -371,18 +223,14 @@ void AppendTilePreview(
} // namespace } // namespace
void ProductProjectPanel::Initialize(const std::filesystem::path& repoRoot) { void ProductProjectPanel::Initialize(const std::filesystem::path& repoRoot) {
m_assetsRootPath = (repoRoot / "project/Assets").lexically_normal(); m_browserModel.Initialize(repoRoot);
RefreshFolderTree();
SyncCurrentFolderSelection(); SyncCurrentFolderSelection();
RefreshAssetList();
} }
void ProductProjectPanel::SetBuiltInIcons(const ProductBuiltInIcons* icons) { void ProductProjectPanel::SetBuiltInIcons(const ProductBuiltInIcons* icons) {
m_icons = icons; m_icons = icons;
if (!m_assetsRootPath.empty()) { m_browserModel.SetFolderIcon(ResolveFolderIcon(m_icons));
RefreshFolderTree(); SyncCurrentFolderSelection();
SyncCurrentFolderSelection();
}
} }
void ProductProjectPanel::SetTextMeasurer(const UIEditorTextMeasurer* textMeasurer) { void ProductProjectPanel::SetTextMeasurer(const UIEditorTextMeasurer* textMeasurer) {
@@ -411,24 +259,12 @@ const std::vector<ProductProjectPanel::Event>& ProductProjectPanel::GetFrameEven
const ProductProjectPanel::FolderEntry* ProductProjectPanel::FindFolderEntry( const ProductProjectPanel::FolderEntry* ProductProjectPanel::FindFolderEntry(
std::string_view itemId) const { std::string_view itemId) const {
for (const FolderEntry& entry : m_folderEntries) { return m_browserModel.FindFolderEntry(itemId);
if (entry.itemId == itemId) {
return &entry;
}
}
return nullptr;
} }
const ProductProjectPanel::AssetEntry* ProductProjectPanel::FindAssetEntry( const ProductProjectPanel::AssetEntry* ProductProjectPanel::FindAssetEntry(
std::string_view itemId) const { std::string_view itemId) const {
for (const AssetEntry& entry : m_assetEntries) { return m_browserModel.FindAssetEntry(itemId);
if (entry.itemId == itemId) {
return &entry;
}
}
return nullptr;
} }
const UIEditorPanelContentHostPanelState* ProductProjectPanel::FindMountedProjectPanel( const UIEditorPanelContentHostPanelState* ProductProjectPanel::FindMountedProjectPanel(
@@ -444,6 +280,9 @@ const UIEditorPanelContentHostPanelState* ProductProjectPanel::FindMountedProjec
ProductProjectPanel::Layout ProductProjectPanel::BuildLayout(const UIRect& bounds) const { ProductProjectPanel::Layout ProductProjectPanel::BuildLayout(const UIRect& bounds) const {
Layout layout = {}; Layout layout = {};
const auto& assetEntries = m_browserModel.GetAssetEntries();
const std::vector<ProductProjectPanel::BrowserModel::BreadcrumbSegment> breadcrumbSegments =
m_browserModel.BuildBreadcrumbSegments();
const float dividerThickness = ResolveUIEditorDockHostMetrics().splitterMetrics.thickness; const float dividerThickness = ResolveUIEditorDockHostMetrics().splitterMetrics.thickness;
layout.bounds = UIRect( layout.bounds = UIRect(
bounds.x, bounds.x,
@@ -496,9 +335,7 @@ ProductProjectPanel::Layout ProductProjectPanel::BuildLayout(const UIRect& bound
const float headerRight = const float headerRight =
layout.browserHeaderRect.x + layout.browserHeaderRect.width - kHeaderHorizontalPadding; layout.browserHeaderRect.x + layout.browserHeaderRect.width - kHeaderHorizontalPadding;
float nextItemX = layout.browserHeaderRect.x + kHeaderHorizontalPadding; float nextItemX = layout.browserHeaderRect.x + kHeaderHorizontalPadding;
std::string cumulativeFolderId = {}; for (std::size_t index = 0u; index < breadcrumbSegments.size(); ++index) {
const std::vector<std::string> segments = BuildBreadcrumbSegments(m_currentFolderId);
for (std::size_t index = 0u; index < segments.size(); ++index) {
if (index > 0u) { if (index > 0u) {
const float separatorWidth = const float separatorWidth =
MeasureTextWidth(m_textMeasurer, ">", kHeaderFontSize); MeasureTextWidth(m_textMeasurer, ">", kHeaderFontSize);
@@ -519,15 +356,9 @@ ProductProjectPanel::Layout ProductProjectPanel::BuildLayout(const UIRect& bound
nextItemX += separatorWidth + kBreadcrumbSpacing; nextItemX += separatorWidth + kBreadcrumbSpacing;
} }
if (index == 0u) { const ProductProjectPanel::BrowserModel::BreadcrumbSegment& segment = breadcrumbSegments[index];
cumulativeFolderId = segments[index];
} else {
cumulativeFolderId += "/";
cumulativeFolderId += segments[index];
}
const float labelWidth = const float labelWidth =
MeasureTextWidth(m_textMeasurer, segments[index], kHeaderFontSize); MeasureTextWidth(m_textMeasurer, segment.label, kHeaderFontSize);
const float itemWidth = labelWidth + kBreadcrumbItemPaddingX * 2.0f; const float itemWidth = labelWidth + kBreadcrumbItemPaddingX * 2.0f;
const float availableWidth = headerRight - nextItemX; const float availableWidth = headerRight - nextItemX;
if (availableWidth <= 0.0f) { if (availableWidth <= 0.0f) {
@@ -535,16 +366,16 @@ ProductProjectPanel::Layout ProductProjectPanel::BuildLayout(const UIRect& bound
} }
layout.breadcrumbItems.push_back({ layout.breadcrumbItems.push_back({
segments[index], segment.label,
cumulativeFolderId, segment.targetFolderId,
UIRect( UIRect(
nextItemX, nextItemX,
breadcrumbY, breadcrumbY,
ClampNonNegative((std::min)(itemWidth, availableWidth)), ClampNonNegative((std::min)(itemWidth, availableWidth)),
breadcrumbRowHeight), breadcrumbRowHeight),
false, false,
index + 1u != segments.size(), !segment.current,
index + 1u == segments.size() segment.current
}); });
nextItemX += itemWidth + kBreadcrumbSpacing; nextItemX += itemWidth + kBreadcrumbSpacing;
} }
@@ -557,8 +388,8 @@ ProductProjectPanel::Layout ProductProjectPanel::BuildLayout(const UIRect& bound
columnCount = 1; columnCount = 1;
} }
layout.assetTiles.reserve(m_assetEntries.size()); layout.assetTiles.reserve(assetEntries.size());
for (std::size_t index = 0; index < m_assetEntries.size(); ++index) { for (std::size_t index = 0; index < assetEntries.size(); ++index) {
const int column = static_cast<int>(index % static_cast<std::size_t>(columnCount)); const int column = static_cast<int>(index % static_cast<std::size_t>(columnCount));
const int row = static_cast<int>(index / static_cast<std::size_t>(columnCount)); const int row = static_cast<int>(index / static_cast<std::size_t>(columnCount));
const float tileX = layout.gridRect.x + static_cast<float>(column) * (kGridTileWidth + kGridTileGapX); const float tileX = layout.gridRect.x + static_cast<float>(column) * (kGridTileWidth + kGridTileGapX);
@@ -604,85 +435,31 @@ std::size_t ProductProjectPanel::HitTestAssetTile(const UIPoint& point) const {
return kInvalidLayoutIndex; return kInvalidLayoutIndex;
} }
void ProductProjectPanel::RefreshFolderTree() {
m_folderEntries.clear();
m_treeItems.clear();
if (m_assetsRootPath.empty() || !std::filesystem::exists(m_assetsRootPath)) {
return;
}
const auto appendFolderRecursive =
[&](auto&& self, const std::filesystem::path& folderPath, std::uint32_t depth) -> void {
const std::string itemId = BuildRelativeItemId(folderPath, m_assetsRootPath);
FolderEntry folderEntry = {};
folderEntry.itemId = itemId;
folderEntry.absolutePath = folderPath;
m_folderEntries.push_back(std::move(folderEntry));
Widgets::UIEditorTreeViewItem item = {};
item.itemId = itemId;
item.label = PathToUtf8String(folderPath.filename());
item.depth = depth;
item.forceLeaf = !HasChildDirectories(folderPath);
item.leadingIcon = ResolveFolderIcon(m_icons);
m_treeItems.push_back(std::move(item));
const std::vector<std::filesystem::path> childFolders =
CollectSortedChildDirectories(folderPath);
for (const std::filesystem::path& childPath : childFolders) {
self(self, childPath, depth + 1u);
}
};
appendFolderRecursive(appendFolderRecursive, m_assetsRootPath, 0u);
}
void ProductProjectPanel::EnsureValidCurrentFolder() {
if (m_currentFolderId.empty()) {
m_currentFolderId = std::string(kAssetsRootId);
}
if (FindFolderEntry(m_currentFolderId) == nullptr && !m_treeItems.empty()) {
m_currentFolderId = m_treeItems.front().itemId;
}
}
void ProductProjectPanel::ExpandFolderAncestors(std::string_view itemId) {
const FolderEntry* folderEntry = FindFolderEntry(itemId);
if (folderEntry == nullptr) {
return;
}
std::filesystem::path path = folderEntry->absolutePath;
while (true) {
m_folderExpansion.Expand(BuildRelativeItemId(path, m_assetsRootPath));
if (path == m_assetsRootPath) {
break;
}
path = path.parent_path();
}
}
void ProductProjectPanel::SyncCurrentFolderSelection() { void ProductProjectPanel::SyncCurrentFolderSelection() {
EnsureValidCurrentFolder(); const std::string& currentFolderId = m_browserModel.GetCurrentFolderId();
ExpandFolderAncestors(m_currentFolderId); if (currentFolderId.empty()) {
m_folderSelection.SetSelection(m_currentFolderId); m_folderSelection.ClearSelection();
return;
}
const std::vector<std::string> ancestorFolderIds =
m_browserModel.CollectCurrentFolderAncestorIds();
for (const std::string& ancestorFolderId : ancestorFolderIds) {
m_folderExpansion.Expand(ancestorFolderId);
}
m_folderSelection.SetSelection(currentFolderId);
} }
bool ProductProjectPanel::NavigateToFolder(std::string_view itemId, EventSource source) { bool ProductProjectPanel::NavigateToFolder(std::string_view itemId, EventSource source) {
if (itemId.empty() || FindFolderEntry(itemId) == nullptr || itemId == m_currentFolderId) { if (!m_browserModel.NavigateToFolder(itemId)) {
return false; return false;
} }
m_currentFolderId = std::string(itemId);
SyncCurrentFolderSelection(); SyncCurrentFolderSelection();
m_assetSelection.ClearSelection(); m_assetSelection.ClearSelection();
m_hoveredAssetItemId.clear(); m_hoveredAssetItemId.clear();
m_lastPrimaryClickedAssetId.clear(); m_lastPrimaryClickedAssetId.clear();
RefreshAssetList(); EmitEvent(EventKind::FolderNavigated, source, FindFolderEntry(m_browserModel.GetCurrentFolderId()));
EmitEvent(EventKind::FolderNavigated, source, FindFolderEntry(m_currentFolderId));
return true; return true;
} }
@@ -699,7 +476,7 @@ void ProductProjectPanel::EmitEvent(
event.source = source; event.source = source;
event.itemId = folder->itemId; event.itemId = folder->itemId;
event.absolutePath = folder->absolutePath; event.absolutePath = folder->absolutePath;
event.displayName = PathToUtf8String(folder->absolutePath.filename()); event.displayName = folder->label;
event.directory = true; event.directory = true;
m_frameEvents.push_back(std::move(event)); m_frameEvents.push_back(std::move(event));
} }
@@ -731,55 +508,6 @@ void ProductProjectPanel::EmitSelectionClearedEvent(EventSource source) {
m_frameEvents.push_back(std::move(event)); m_frameEvents.push_back(std::move(event));
} }
void ProductProjectPanel::RefreshAssetList() {
EnsureValidCurrentFolder();
m_assetEntries.clear();
const FolderEntry* currentFolder = FindFolderEntry(m_currentFolderId);
if (currentFolder == nullptr) {
return;
}
std::vector<std::filesystem::directory_entry> entries = {};
std::error_code errorCode = {};
const std::filesystem::directory_iterator end = {};
for (std::filesystem::directory_iterator iterator(currentFolder->absolutePath, errorCode);
!errorCode && iterator != end;
iterator.increment(errorCode)) {
if (!iterator->exists(errorCode) || IsMetaFile(iterator->path())) {
continue;
}
if (!iterator->is_directory(errorCode) && !iterator->is_regular_file(errorCode)) {
continue;
}
entries.push_back(*iterator);
}
std::sort(
entries.begin(),
entries.end(),
[](const std::filesystem::directory_entry& lhs, const std::filesystem::directory_entry& rhs) {
const bool lhsDirectory = lhs.is_directory();
const bool rhsDirectory = rhs.is_directory();
if (lhsDirectory != rhsDirectory) {
return lhsDirectory && !rhsDirectory;
}
return ToLowerCopy(PathToUtf8String(lhs.path().filename())) <
ToLowerCopy(PathToUtf8String(rhs.path().filename()));
});
for (const std::filesystem::directory_entry& entry : entries) {
AssetEntry assetEntry = {};
assetEntry.itemId = BuildRelativeItemId(entry.path(), m_assetsRootPath);
assetEntry.absolutePath = entry.path();
assetEntry.displayName = BuildAssetDisplayName(entry.path(), entry.is_directory());
assetEntry.directory = entry.is_directory();
m_assetEntries.push_back(std::move(assetEntry));
}
}
void ProductProjectPanel::ResetTransientFrames() { void ProductProjectPanel::ResetTransientFrames() {
m_treeFrame = {}; m_treeFrame = {};
m_frameEvents.clear(); m_frameEvents.clear();
@@ -811,10 +539,9 @@ void ProductProjectPanel::Update(
return; return;
} }
if (m_treeItems.empty()) { if (m_browserModel.GetTreeItems().empty()) {
RefreshFolderTree(); m_browserModel.Refresh();
SyncCurrentFolderSelection(); SyncCurrentFolderSelection();
RefreshAssetList();
} }
m_visible = true; m_visible = true;
@@ -836,13 +563,13 @@ void ProductProjectPanel::Update(
m_folderSelection, m_folderSelection,
m_folderExpansion, m_folderExpansion,
m_layout.treeRect, m_layout.treeRect,
m_treeItems, m_browserModel.GetTreeItems(),
treeEvents, treeEvents,
treeMetrics); treeMetrics);
if (m_treeFrame.result.selectionChanged && if (m_treeFrame.result.selectionChanged &&
!m_treeFrame.result.selectedItemId.empty() && !m_treeFrame.result.selectedItemId.empty() &&
m_treeFrame.result.selectedItemId != m_currentFolderId) { m_treeFrame.result.selectedItemId != m_browserModel.GetCurrentFolderId()) {
NavigateToFolder(m_treeFrame.result.selectedItemId, EventSource::Tree); NavigateToFolder(m_treeFrame.result.selectedItemId, EventSource::Tree);
m_layout = BuildLayout(panelState->bounds); m_layout = BuildLayout(panelState->bounds);
} }
@@ -871,9 +598,10 @@ void ProductProjectPanel::Update(
m_splitterDragging || ContainsPoint(m_layout.dividerRect, event.position); m_splitterDragging || ContainsPoint(m_layout.dividerRect, event.position);
m_hoveredBreadcrumbIndex = HitTestBreadcrumbItem(event.position); m_hoveredBreadcrumbIndex = HitTestBreadcrumbItem(event.position);
const std::size_t hoveredAssetIndex = HitTestAssetTile(event.position); const std::size_t hoveredAssetIndex = HitTestAssetTile(event.position);
const auto& assetEntries = m_browserModel.GetAssetEntries();
m_hoveredAssetItemId = m_hoveredAssetItemId =
hoveredAssetIndex < m_assetEntries.size() hoveredAssetIndex < assetEntries.size()
? m_assetEntries[hoveredAssetIndex].itemId ? assetEntries[hoveredAssetIndex].itemId
: std::string(); : std::string();
break; break;
} }
@@ -902,8 +630,9 @@ void ProductProjectPanel::Update(
break; break;
} }
const auto& assetEntries = m_browserModel.GetAssetEntries();
const std::size_t hitIndex = HitTestAssetTile(event.position); const std::size_t hitIndex = HitTestAssetTile(event.position);
if (hitIndex >= m_assetEntries.size()) { if (hitIndex >= assetEntries.size()) {
if (m_assetSelection.HasSelection()) { if (m_assetSelection.HasSelection()) {
m_assetSelection.ClearSelection(); m_assetSelection.ClearSelection();
EmitSelectionClearedEvent(EventSource::Background); EmitSelectionClearedEvent(EventSource::Background);
@@ -911,7 +640,7 @@ void ProductProjectPanel::Update(
break; break;
} }
const AssetEntry& assetEntry = m_assetEntries[hitIndex]; const AssetEntry& assetEntry = assetEntries[hitIndex];
const bool alreadySelected = m_assetSelection.IsSelected(assetEntry.itemId); const bool alreadySelected = m_assetSelection.IsSelected(assetEntry.itemId);
const bool selectionChanged = m_assetSelection.SetSelection(assetEntry.itemId); const bool selectionChanged = m_assetSelection.SetSelection(assetEntry.itemId);
if (selectionChanged) { if (selectionChanged) {
@@ -945,13 +674,17 @@ void ProductProjectPanel::Update(
if (event.pointerButton == ::XCEngine::UI::UIPointerButton::Right && if (event.pointerButton == ::XCEngine::UI::UIPointerButton::Right &&
ContainsPoint(m_layout.gridRect, event.position)) { ContainsPoint(m_layout.gridRect, event.position)) {
const auto& assetEntries = m_browserModel.GetAssetEntries();
const std::size_t hitIndex = HitTestAssetTile(event.position); const std::size_t hitIndex = HitTestAssetTile(event.position);
if (hitIndex >= m_assetEntries.size()) { if (hitIndex >= assetEntries.size()) {
EmitEvent(EventKind::ContextMenuRequested, EventSource::Background, static_cast<const AssetEntry*>(nullptr)); EmitEvent(
EventKind::ContextMenuRequested,
EventSource::Background,
static_cast<const AssetEntry*>(nullptr));
break; break;
} }
const AssetEntry& assetEntry = m_assetEntries[hitIndex]; const AssetEntry& assetEntry = assetEntries[hitIndex];
if (!m_assetSelection.IsSelected(assetEntry.itemId)) { if (!m_assetSelection.IsSelected(assetEntry.itemId)) {
m_assetSelection.SetSelection(assetEntry.itemId); m_assetSelection.SetSelection(assetEntry.itemId);
EmitEvent(EventKind::AssetSelected, EventSource::GridSecondary, &assetEntry); EmitEvent(EventKind::AssetSelected, EventSource::GridSecondary, &assetEntry);
@@ -1000,6 +733,8 @@ void ProductProjectPanel::Append(UIDrawList& drawList) const {
return; return;
} }
const auto& assetEntries = m_browserModel.GetAssetEntries();
drawList.AddFilledRect(m_layout.bounds, kSurfaceColor); drawList.AddFilledRect(m_layout.bounds, kSurfaceColor);
drawList.AddFilledRect(m_layout.leftPaneRect, kPaneColor); drawList.AddFilledRect(m_layout.leftPaneRect, kPaneColor);
drawList.AddFilledRect(m_layout.rightPaneRect, kPaneColor); drawList.AddFilledRect(m_layout.rightPaneRect, kPaneColor);
@@ -1021,7 +756,7 @@ void ProductProjectPanel::Append(UIDrawList& drawList) const {
AppendUIEditorTreeViewBackground( AppendUIEditorTreeViewBackground(
drawList, drawList,
m_treeFrame.layout, m_treeFrame.layout,
m_treeItems, m_browserModel.GetTreeItems(),
m_folderSelection, m_folderSelection,
m_treeInteractionState.treeViewState, m_treeInteractionState.treeViewState,
treePalette, treePalette,
@@ -1029,7 +764,7 @@ void ProductProjectPanel::Append(UIDrawList& drawList) const {
AppendUIEditorTreeViewForeground( AppendUIEditorTreeViewForeground(
drawList, drawList,
m_treeFrame.layout, m_treeFrame.layout,
m_treeItems, m_browserModel.GetTreeItems(),
treePalette, treePalette,
treeMetrics); treeMetrics);
@@ -1055,11 +790,11 @@ void ProductProjectPanel::Append(UIDrawList& drawList) const {
drawList.PopClipRect(); drawList.PopClipRect();
for (const AssetTileLayout& tile : m_layout.assetTiles) { for (const AssetTileLayout& tile : m_layout.assetTiles) {
if (tile.itemIndex >= m_assetEntries.size()) { if (tile.itemIndex >= assetEntries.size()) {
continue; continue;
} }
const AssetEntry& assetEntry = m_assetEntries[tile.itemIndex]; const AssetEntry& assetEntry = assetEntries[tile.itemIndex];
const bool selected = m_assetSelection.IsSelected(assetEntry.itemId); const bool selected = m_assetSelection.IsSelected(assetEntry.itemId);
const bool hovered = m_hoveredAssetItemId == assetEntry.itemId; const bool hovered = m_hoveredAssetItemId == assetEntry.itemId;
@@ -1084,7 +819,7 @@ void ProductProjectPanel::Append(UIDrawList& drawList) const {
drawList.PopClipRect(); drawList.PopClipRect();
} }
if (m_assetEntries.empty()) { if (assetEntries.empty()) {
const UIRect messageRect( const UIRect messageRect(
m_layout.gridRect.x, m_layout.gridRect.x,
m_layout.gridRect.y, m_layout.gridRect.y,

View File

@@ -1,5 +1,7 @@
#pragma once #pragma once
#include "Project/ProductProjectBrowserModel.h"
#include <XCEditor/Collections/UIEditorTreeViewInteraction.h> #include <XCEditor/Collections/UIEditorTreeViewInteraction.h>
#include <XCEditor/Foundation/UIEditorTextMeasurement.h> #include <XCEditor/Foundation/UIEditorTextMeasurement.h>
#include <XCEditor/Shell/UIEditorPanelContentHost.h> #include <XCEditor/Shell/UIEditorPanelContentHost.h>
@@ -70,17 +72,9 @@ public:
const std::vector<Event>& GetFrameEvents() const; const std::vector<Event>& GetFrameEvents() const;
private: private:
struct FolderEntry { using BrowserModel = ::XCEngine::UI::Editor::App::Project::ProductProjectBrowserModel;
std::string itemId = {}; using FolderEntry = BrowserModel::FolderEntry;
std::filesystem::path absolutePath = {}; using AssetEntry = BrowserModel::AssetEntry;
};
struct AssetEntry {
std::string itemId = {};
std::filesystem::path absolutePath = {};
std::string displayName = {};
bool directory = false;
};
struct BreadcrumbItemLayout { struct BreadcrumbItemLayout {
std::string label = {}; std::string label = {};
@@ -118,10 +112,6 @@ private:
Layout BuildLayout(const ::XCEngine::UI::UIRect& bounds) const; Layout BuildLayout(const ::XCEngine::UI::UIRect& bounds) const;
std::size_t HitTestBreadcrumbItem(const ::XCEngine::UI::UIPoint& point) const; std::size_t HitTestBreadcrumbItem(const ::XCEngine::UI::UIPoint& point) const;
std::size_t HitTestAssetTile(const ::XCEngine::UI::UIPoint& point) const; std::size_t HitTestAssetTile(const ::XCEngine::UI::UIPoint& point) const;
void RefreshFolderTree();
void RefreshAssetList();
void EnsureValidCurrentFolder();
void ExpandFolderAncestors(std::string_view itemId);
void SyncCurrentFolderSelection(); void SyncCurrentFolderSelection();
bool NavigateToFolder(std::string_view itemId, EventSource source = EventSource::None); bool NavigateToFolder(std::string_view itemId, EventSource source = EventSource::None);
void EmitEvent(EventKind kind, EventSource source, const FolderEntry* folder); void EmitEvent(EventKind kind, EventSource source, const FolderEntry* folder);
@@ -129,10 +119,7 @@ private:
void EmitSelectionClearedEvent(EventSource source); void EmitSelectionClearedEvent(EventSource source);
void ResetTransientFrames(); void ResetTransientFrames();
std::filesystem::path m_assetsRootPath = {}; BrowserModel m_browserModel = {};
std::vector<FolderEntry> m_folderEntries = {};
std::vector<Widgets::UIEditorTreeViewItem> m_treeItems = {};
std::vector<AssetEntry> m_assetEntries = {};
const ProductBuiltInIcons* m_icons = nullptr; const ProductBuiltInIcons* m_icons = nullptr;
const ::XCEngine::UI::Editor::UIEditorTextMeasurer* m_textMeasurer = nullptr; const ::XCEngine::UI::Editor::UIEditorTextMeasurer* m_textMeasurer = nullptr;
::XCEngine::UI::Widgets::UISelectionModel m_folderSelection = {}; ::XCEngine::UI::Widgets::UISelectionModel m_folderSelection = {};
@@ -142,7 +129,6 @@ private:
UIEditorTreeViewInteractionFrame m_treeFrame = {}; UIEditorTreeViewInteractionFrame m_treeFrame = {};
std::vector<Event> m_frameEvents = {}; std::vector<Event> m_frameEvents = {};
Layout m_layout = {}; Layout m_layout = {};
std::string m_currentFolderId = {};
std::string m_hoveredAssetItemId = {}; std::string m_hoveredAssetItemId = {};
std::string m_lastPrimaryClickedAssetId = {}; std::string m_lastPrimaryClickedAssetId = {};
float m_navigationWidth = 248.0f; float m_navigationWidth = 248.0f;

View File

@@ -0,0 +1,376 @@
#include "Project/ProductProjectBrowserModel.h"
#include <algorithm>
#include <cctype>
#include <string_view>
#include <system_error>
#include <windows.h>
namespace XCEngine::UI::Editor::App::Project {
namespace {
constexpr std::string_view kAssetsRootId = "Assets";
std::string ToLowerCopy(std::string value) {
std::transform(
value.begin(),
value.end(),
value.begin(),
[](unsigned char character) {
return static_cast<char>(std::tolower(character));
});
return value;
}
std::string WideToUtf8(std::wstring_view value) {
if (value.empty()) {
return {};
}
const int requiredSize = WideCharToMultiByte(
CP_UTF8,
0,
value.data(),
static_cast<int>(value.size()),
nullptr,
0,
nullptr,
nullptr);
if (requiredSize <= 0) {
return {};
}
std::string result(static_cast<std::size_t>(requiredSize), '\0');
WideCharToMultiByte(
CP_UTF8,
0,
value.data(),
static_cast<int>(value.size()),
result.data(),
requiredSize,
nullptr,
nullptr);
return result;
}
std::string PathToUtf8String(const std::filesystem::path& path) {
return WideToUtf8(path.native());
}
std::string NormalizePathSeparators(std::string value) {
std::replace(value.begin(), value.end(), '\\', '/');
return value;
}
std::string BuildRelativeItemId(
const std::filesystem::path& path,
const std::filesystem::path& assetsRoot) {
const std::filesystem::path relative =
std::filesystem::relative(path, assetsRoot.parent_path());
const std::string normalized =
NormalizePathSeparators(PathToUtf8String(relative.lexically_normal()));
return normalized.empty() ? std::string(kAssetsRootId) : normalized;
}
std::string BuildAssetDisplayName(const std::filesystem::path& path, bool directory) {
if (directory) {
return PathToUtf8String(path.filename());
}
const std::string filename = PathToUtf8String(path.filename());
const std::size_t extensionOffset = filename.find_last_of('.');
if (extensionOffset == std::string::npos || extensionOffset == 0u) {
return filename;
}
return filename.substr(0u, extensionOffset);
}
bool IsMetaFile(const std::filesystem::path& path) {
return ToLowerCopy(path.extension().string()) == ".meta";
}
bool HasChildDirectories(const std::filesystem::path& folderPath) {
std::error_code errorCode = {};
const std::filesystem::directory_iterator end = {};
for (std::filesystem::directory_iterator iterator(folderPath, errorCode);
!errorCode && iterator != end;
iterator.increment(errorCode)) {
if (iterator->is_directory(errorCode)) {
return true;
}
}
return false;
}
std::vector<std::filesystem::path> CollectSortedChildDirectories(
const std::filesystem::path& folderPath) {
std::vector<std::filesystem::path> paths = {};
std::error_code errorCode = {};
const std::filesystem::directory_iterator end = {};
for (std::filesystem::directory_iterator iterator(folderPath, errorCode);
!errorCode && iterator != end;
iterator.increment(errorCode)) {
if (iterator->is_directory(errorCode)) {
paths.push_back(iterator->path());
}
}
std::sort(
paths.begin(),
paths.end(),
[](const std::filesystem::path& lhs, const std::filesystem::path& rhs) {
return ToLowerCopy(PathToUtf8String(lhs.filename())) <
ToLowerCopy(PathToUtf8String(rhs.filename()));
});
return paths;
}
} // namespace
void ProductProjectBrowserModel::Initialize(const std::filesystem::path& repoRoot) {
m_assetsRootPath = (repoRoot / "project/Assets").lexically_normal();
Refresh();
}
void ProductProjectBrowserModel::SetFolderIcon(const ::XCEngine::UI::UITextureHandle& icon) {
m_folderIcon = icon;
if (!m_assetsRootPath.empty()) {
RefreshFolderTree();
}
}
void ProductProjectBrowserModel::Refresh() {
RefreshFolderTree();
EnsureValidCurrentFolder();
RefreshAssetList();
}
bool ProductProjectBrowserModel::Empty() const {
return m_treeItems.empty();
}
const std::filesystem::path& ProductProjectBrowserModel::GetAssetsRootPath() const {
return m_assetsRootPath;
}
const std::vector<ProductProjectBrowserModel::FolderEntry>& ProductProjectBrowserModel::GetFolderEntries() const {
return m_folderEntries;
}
const std::vector<Widgets::UIEditorTreeViewItem>& ProductProjectBrowserModel::GetTreeItems() const {
return m_treeItems;
}
const std::vector<ProductProjectBrowserModel::AssetEntry>& ProductProjectBrowserModel::GetAssetEntries() const {
return m_assetEntries;
}
const std::string& ProductProjectBrowserModel::GetCurrentFolderId() const {
return m_currentFolderId;
}
const ProductProjectBrowserModel::FolderEntry* ProductProjectBrowserModel::FindFolderEntry(
std::string_view itemId) const {
for (const FolderEntry& entry : m_folderEntries) {
if (entry.itemId == itemId) {
return &entry;
}
}
return nullptr;
}
const ProductProjectBrowserModel::AssetEntry* ProductProjectBrowserModel::FindAssetEntry(
std::string_view itemId) const {
for (const AssetEntry& entry : m_assetEntries) {
if (entry.itemId == itemId) {
return &entry;
}
}
return nullptr;
}
bool ProductProjectBrowserModel::NavigateToFolder(std::string_view itemId) {
if (itemId.empty() || FindFolderEntry(itemId) == nullptr || itemId == m_currentFolderId) {
return false;
}
m_currentFolderId = std::string(itemId);
EnsureValidCurrentFolder();
RefreshAssetList();
return true;
}
std::vector<ProductProjectBrowserModel::BreadcrumbSegment> ProductProjectBrowserModel::BuildBreadcrumbSegments() const {
std::vector<BreadcrumbSegment> segments = {};
if (m_currentFolderId.empty()) {
segments.push_back(BreadcrumbSegment{ std::string(kAssetsRootId), std::string(kAssetsRootId), true });
return segments;
}
std::string cumulativeFolderId = {};
std::size_t segmentStart = 0u;
while (segmentStart < m_currentFolderId.size()) {
const std::size_t separator = m_currentFolderId.find('/', segmentStart);
const std::size_t segmentLength =
separator == std::string_view::npos
? m_currentFolderId.size() - segmentStart
: separator - segmentStart;
if (segmentLength > 0u) {
std::string label = std::string(m_currentFolderId.substr(segmentStart, segmentLength));
if (cumulativeFolderId.empty()) {
cumulativeFolderId = label;
} else {
cumulativeFolderId += "/";
cumulativeFolderId += label;
}
segments.push_back(BreadcrumbSegment{
std::move(label),
cumulativeFolderId,
false
});
}
if (separator == std::string_view::npos) {
break;
}
segmentStart = separator + 1u;
}
if (segments.empty()) {
segments.push_back(BreadcrumbSegment{ std::string(kAssetsRootId), std::string(kAssetsRootId), true });
return segments;
}
segments.back().current = true;
return segments;
}
std::vector<std::string> ProductProjectBrowserModel::CollectCurrentFolderAncestorIds() const {
return BuildAncestorFolderIds(m_currentFolderId);
}
std::vector<std::string> ProductProjectBrowserModel::BuildAncestorFolderIds(
std::string_view itemId) const {
std::vector<std::string> ancestors = {};
const FolderEntry* folderEntry = FindFolderEntry(itemId);
if (folderEntry == nullptr) {
return ancestors;
}
std::filesystem::path path = folderEntry->absolutePath;
while (true) {
ancestors.push_back(BuildRelativeItemId(path, m_assetsRootPath));
if (path == m_assetsRootPath) {
break;
}
path = path.parent_path();
}
std::reverse(ancestors.begin(), ancestors.end());
return ancestors;
}
void ProductProjectBrowserModel::RefreshFolderTree() {
m_folderEntries.clear();
m_treeItems.clear();
if (m_assetsRootPath.empty() || !std::filesystem::exists(m_assetsRootPath)) {
return;
}
const auto appendFolderRecursive =
[&](auto&& self, const std::filesystem::path& folderPath, std::uint32_t depth) -> void {
const std::string itemId = BuildRelativeItemId(folderPath, m_assetsRootPath);
FolderEntry folderEntry = {};
folderEntry.itemId = itemId;
folderEntry.absolutePath = folderPath;
folderEntry.label = PathToUtf8String(folderPath.filename());
m_folderEntries.push_back(folderEntry);
Widgets::UIEditorTreeViewItem item = {};
item.itemId = itemId;
item.label = folderEntry.label;
item.depth = depth;
item.forceLeaf = !HasChildDirectories(folderPath);
item.leadingIcon = m_folderIcon;
m_treeItems.push_back(std::move(item));
const std::vector<std::filesystem::path> childFolders =
CollectSortedChildDirectories(folderPath);
for (const std::filesystem::path& childPath : childFolders) {
self(self, childPath, depth + 1u);
}
};
appendFolderRecursive(appendFolderRecursive, m_assetsRootPath, 0u);
}
void ProductProjectBrowserModel::RefreshAssetList() {
EnsureValidCurrentFolder();
m_assetEntries.clear();
const FolderEntry* currentFolder = FindFolderEntry(m_currentFolderId);
if (currentFolder == nullptr) {
return;
}
std::vector<std::filesystem::directory_entry> entries = {};
std::error_code errorCode = {};
const std::filesystem::directory_iterator end = {};
for (std::filesystem::directory_iterator iterator(currentFolder->absolutePath, errorCode);
!errorCode && iterator != end;
iterator.increment(errorCode)) {
if (!iterator->exists(errorCode) || IsMetaFile(iterator->path())) {
continue;
}
if (!iterator->is_directory(errorCode) && !iterator->is_regular_file(errorCode)) {
continue;
}
entries.push_back(*iterator);
}
std::sort(
entries.begin(),
entries.end(),
[](const std::filesystem::directory_entry& lhs, const std::filesystem::directory_entry& rhs) {
const bool lhsDirectory = lhs.is_directory();
const bool rhsDirectory = rhs.is_directory();
if (lhsDirectory != rhsDirectory) {
return lhsDirectory && !rhsDirectory;
}
return ToLowerCopy(PathToUtf8String(lhs.path().filename())) <
ToLowerCopy(PathToUtf8String(rhs.path().filename()));
});
for (const std::filesystem::directory_entry& entry : entries) {
AssetEntry assetEntry = {};
assetEntry.itemId = BuildRelativeItemId(entry.path(), m_assetsRootPath);
assetEntry.absolutePath = entry.path();
assetEntry.displayName = BuildAssetDisplayName(entry.path(), entry.is_directory());
assetEntry.directory = entry.is_directory();
m_assetEntries.push_back(std::move(assetEntry));
}
}
void ProductProjectBrowserModel::EnsureValidCurrentFolder() {
if (m_currentFolderId.empty()) {
m_currentFolderId = std::string(kAssetsRootId);
}
if (FindFolderEntry(m_currentFolderId) == nullptr) {
m_currentFolderId = m_treeItems.empty()
? std::string()
: m_treeItems.front().itemId;
}
}
} // namespace XCEngine::UI::Editor::App::Project

View File

@@ -0,0 +1,67 @@
#pragma once
#include <XCEditor/Collections/UIEditorTreeView.h>
#include <XCEngine/UI/Types.h>
#include <cstdint>
#include <filesystem>
#include <string>
#include <string_view>
#include <vector>
namespace XCEngine::UI::Editor::App::Project {
class ProductProjectBrowserModel {
public:
struct BreadcrumbSegment {
std::string label = {};
std::string targetFolderId = {};
bool current = false;
};
struct FolderEntry {
std::string itemId = {};
std::filesystem::path absolutePath = {};
std::string label = {};
};
struct AssetEntry {
std::string itemId = {};
std::filesystem::path absolutePath = {};
std::string displayName = {};
bool directory = false;
};
void Initialize(const std::filesystem::path& repoRoot);
void SetFolderIcon(const ::XCEngine::UI::UITextureHandle& icon);
void Refresh();
bool Empty() const;
const std::filesystem::path& GetAssetsRootPath() const;
const std::vector<FolderEntry>& GetFolderEntries() const;
const std::vector<Widgets::UIEditorTreeViewItem>& GetTreeItems() const;
const std::vector<AssetEntry>& GetAssetEntries() const;
const std::string& GetCurrentFolderId() const;
const FolderEntry* FindFolderEntry(std::string_view itemId) const;
const AssetEntry* FindAssetEntry(std::string_view itemId) const;
bool NavigateToFolder(std::string_view itemId);
std::vector<BreadcrumbSegment> BuildBreadcrumbSegments() const;
std::vector<std::string> CollectCurrentFolderAncestorIds() const;
std::vector<std::string> BuildAncestorFolderIds(std::string_view itemId) const;
private:
void RefreshFolderTree();
void RefreshAssetList();
void EnsureValidCurrentFolder();
std::filesystem::path m_assetsRootPath = {};
std::vector<FolderEntry> m_folderEntries = {};
std::vector<Widgets::UIEditorTreeViewItem> m_treeItems = {};
std::vector<AssetEntry> m_assetEntries = {};
::XCEngine::UI::UITextureHandle m_folderIcon = {};
std::string m_currentFolderId = {};
};
} // namespace XCEngine::UI::Editor::App::Project

View File

@@ -23,8 +23,8 @@ UIEditorPanelRegistry BuildPanelRegistry() {
{ "hierarchy", "Hierarchy", UIEditorPanelPresentationKind::HostedContent, true, false, false }, { "hierarchy", "Hierarchy", UIEditorPanelPresentationKind::HostedContent, true, false, false },
{ "scene", "Scene", UIEditorPanelPresentationKind::HostedContent, false, false, false }, { "scene", "Scene", UIEditorPanelPresentationKind::HostedContent, false, false, false },
{ "game", "Game", UIEditorPanelPresentationKind::HostedContent, false, false, false }, { "game", "Game", UIEditorPanelPresentationKind::HostedContent, false, false, false },
{ "inspector", "Inspector", UIEditorPanelPresentationKind::Placeholder, true, false, false }, { "inspector", "Inspector", UIEditorPanelPresentationKind::HostedContent, true, false, false },
{ "console", "Console", UIEditorPanelPresentationKind::Placeholder, true, false, false }, { "console", "Console", UIEditorPanelPresentationKind::HostedContent, true, false, false },
{ "project", "Project", UIEditorPanelPresentationKind::HostedContent, false, false, false } { "project", "Project", UIEditorPanelPresentationKind::HostedContent, false, false, false }
}; };
return registry; return registry;
@@ -434,8 +434,8 @@ UIEditorShellInteractionDefinition BuildBaseShellDefinition() {
BuildHostedContentPresentation("hierarchy"), BuildHostedContentPresentation("hierarchy"),
BuildHostedContentPresentation("scene"), BuildHostedContentPresentation("scene"),
BuildHostedContentPresentation("game"), BuildHostedContentPresentation("game"),
BuildPlaceholderPresentation("inspector"), BuildHostedContentPresentation("inspector"),
BuildPlaceholderPresentation("console"), BuildHostedContentPresentation("console"),
BuildHostedContentPresentation("project") BuildHostedContentPresentation("project")
}; };
return definition; return definition;

View File

@@ -1,5 +1,7 @@
#include "ProductEditorWorkspace.h" #include "ProductEditorWorkspace.h"
#include "Workspace/ProductEditorWorkspaceEventRouter.h"
#include <XCEditor/Shell/UIEditorShellCompose.h> #include <XCEditor/Shell/UIEditorShellCompose.h>
#include <XCEditor/Foundation/UIEditorTheme.h> #include <XCEditor/Foundation/UIEditorTheme.h>
@@ -90,6 +92,7 @@ void ProductEditorWorkspace::Initialize(
void ProductEditorWorkspace::Shutdown() { void ProductEditorWorkspace::Shutdown() {
m_shellFrame = {}; m_shellFrame = {};
m_shellInteractionState = {}; m_shellInteractionState = {};
m_traceEntries.clear();
m_builtInIcons.Shutdown(); m_builtInIcons.Shutdown();
} }
@@ -131,6 +134,13 @@ void ProductEditorWorkspace::Update(
hostedContentEvents, hostedContentEvents,
!m_shellFrame.result.workspaceInputSuppressed, !m_shellFrame.result.workspaceInputSuppressed,
activePanelId == "project"); activePanelId == "project");
m_traceEntries = ConsumeProductEditorWorkspaceEvents(context, *this);
m_inspectorPanel.Update(
context.GetSession(),
m_shellFrame.workspaceInteractionFrame.composeFrame.contentHostFrame);
m_consolePanel.Update(
context.GetSession(),
m_shellFrame.workspaceInteractionFrame.composeFrame.contentHostFrame);
} }
void ProductEditorWorkspace::Append(UIDrawList& drawList) const { void ProductEditorWorkspace::Append(UIDrawList& drawList) const {
@@ -145,7 +155,9 @@ void ProductEditorWorkspace::Append(UIDrawList& drawList) const {
m_shellInteractionState.composeState, m_shellInteractionState.composeState,
palette.shellPalette, palette.shellPalette,
metrics.shellMetrics); metrics.shellMetrics);
m_consolePanel.Append(drawList);
m_hierarchyPanel.Append(drawList); m_hierarchyPanel.Append(drawList);
m_inspectorPanel.Append(drawList);
m_projectPanel.Append(drawList); m_projectPanel.Append(drawList);
AppendShellPopups(drawList, m_shellFrame, palette, metrics); AppendShellPopups(drawList, m_shellFrame, palette, metrics);
} }
@@ -158,6 +170,14 @@ const UIEditorShellInteractionState& ProductEditorWorkspace::GetShellInteraction
return m_shellInteractionState; return m_shellInteractionState;
} }
const std::vector<ProductEditorWorkspaceTraceEntry>& ProductEditorWorkspace::GetTraceEntries() const {
return m_traceEntries;
}
const std::vector<ProductHierarchyPanel::Event>& ProductEditorWorkspace::GetHierarchyPanelEvents() const {
return m_hierarchyPanel.GetFrameEvents();
}
const std::vector<ProductProjectPanel::Event>& ProductEditorWorkspace::GetProjectPanelEvents() const { const std::vector<ProductProjectPanel::Event>& ProductEditorWorkspace::GetProjectPanelEvents() const {
return m_projectPanel.GetFrameEvents(); return m_projectPanel.GetFrameEvents();
} }
@@ -176,15 +196,19 @@ Widgets::UIEditorDockHostCursorKind ProductEditorWorkspace::GetDockCursorKind()
} }
bool ProductEditorWorkspace::WantsHostPointerCapture() const { bool ProductEditorWorkspace::WantsHostPointerCapture() const {
return m_projectPanel.WantsHostPointerCapture(); return m_hierarchyPanel.WantsHostPointerCapture() ||
m_projectPanel.WantsHostPointerCapture();
} }
bool ProductEditorWorkspace::WantsHostPointerRelease() const { bool ProductEditorWorkspace::WantsHostPointerRelease() const {
return m_projectPanel.WantsHostPointerRelease(); return (m_hierarchyPanel.WantsHostPointerRelease() ||
m_projectPanel.WantsHostPointerRelease()) &&
!HasHostedContentCapture();
} }
bool ProductEditorWorkspace::HasHostedContentCapture() const { bool ProductEditorWorkspace::HasHostedContentCapture() const {
return m_projectPanel.HasActivePointerCapture(); return m_hierarchyPanel.HasActivePointerCapture() ||
m_projectPanel.HasActivePointerCapture();
} }
bool ProductEditorWorkspace::HasShellInteractiveCapture() const { bool ProductEditorWorkspace::HasShellInteractiveCapture() const {

View File

@@ -1,9 +1,12 @@
#pragma once #pragma once
#include "Core/ProductEditorContext.h" #include "Core/ProductEditorContext.h"
#include "Panels/ProductConsolePanel.h"
#include "Icons/ProductBuiltInIcons.h" #include "Icons/ProductBuiltInIcons.h"
#include "Panels/ProductHierarchyPanel.h" #include "Panels/ProductHierarchyPanel.h"
#include "Panels/ProductInspectorPanel.h"
#include "Panels/ProductProjectPanel.h" #include "Panels/ProductProjectPanel.h"
#include "Workspace/ProductEditorWorkspaceEventRouter.h"
#include <Host/NativeRenderer.h> #include <Host/NativeRenderer.h>
@@ -33,6 +36,8 @@ public:
const UIEditorShellInteractionFrame& GetShellFrame() const; const UIEditorShellInteractionFrame& GetShellFrame() const;
const UIEditorShellInteractionState& GetShellInteractionState() const; const UIEditorShellInteractionState& GetShellInteractionState() const;
const std::vector<ProductEditorWorkspaceTraceEntry>& GetTraceEntries() const;
const std::vector<ProductHierarchyPanel::Event>& GetHierarchyPanelEvents() const;
const std::vector<ProductProjectPanel::Event>& GetProjectPanelEvents() const; const std::vector<ProductProjectPanel::Event>& GetProjectPanelEvents() const;
const std::string& GetBuiltInIconError() const; const std::string& GetBuiltInIconError() const;
@@ -46,10 +51,13 @@ public:
private: private:
ProductBuiltInIcons m_builtInIcons = {}; ProductBuiltInIcons m_builtInIcons = {};
ProductConsolePanel m_consolePanel = {};
ProductHierarchyPanel m_hierarchyPanel = {}; ProductHierarchyPanel m_hierarchyPanel = {};
ProductInspectorPanel m_inspectorPanel = {};
ProductProjectPanel m_projectPanel = {}; ProductProjectPanel m_projectPanel = {};
UIEditorShellInteractionState m_shellInteractionState = {}; UIEditorShellInteractionState m_shellInteractionState = {};
UIEditorShellInteractionFrame m_shellFrame = {}; UIEditorShellInteractionFrame m_shellFrame = {};
std::vector<ProductEditorWorkspaceTraceEntry> m_traceEntries = {};
}; };
} // namespace XCEngine::UI::Editor::App } // namespace XCEngine::UI::Editor::App

View File

@@ -0,0 +1,178 @@
#include "Workspace/ProductEditorWorkspaceEventRouter.h"
#include "Core/ProductEditorContext.h"
#include "Panels/ProductHierarchyPanel.h"
#include "Panels/ProductProjectPanel.h"
#include "Workspace/ProductEditorWorkspace.h"
#include <sstream>
#include <utility>
namespace XCEngine::UI::Editor::App {
namespace {
std::string DescribeProjectPanelEvent(const ProductProjectPanel::Event& event) {
std::ostringstream stream = {};
switch (event.kind) {
case ProductProjectPanel::EventKind::AssetSelected:
stream << "AssetSelected";
break;
case ProductProjectPanel::EventKind::AssetSelectionCleared:
stream << "AssetSelectionCleared";
break;
case ProductProjectPanel::EventKind::FolderNavigated:
stream << "FolderNavigated";
break;
case ProductProjectPanel::EventKind::AssetOpened:
stream << "AssetOpened";
break;
case ProductProjectPanel::EventKind::ContextMenuRequested:
stream << "ContextMenuRequested";
break;
case ProductProjectPanel::EventKind::None:
default:
stream << "None";
break;
}
stream << " source=";
switch (event.source) {
case ProductProjectPanel::EventSource::Tree:
stream << "Tree";
break;
case ProductProjectPanel::EventSource::Breadcrumb:
stream << "Breadcrumb";
break;
case ProductProjectPanel::EventSource::GridPrimary:
stream << "GridPrimary";
break;
case ProductProjectPanel::EventSource::GridDoubleClick:
stream << "GridDoubleClick";
break;
case ProductProjectPanel::EventSource::GridSecondary:
stream << "GridSecondary";
break;
case ProductProjectPanel::EventSource::Background:
stream << "Background";
break;
case ProductProjectPanel::EventSource::None:
default:
stream << "None";
break;
}
if (!event.itemId.empty()) {
stream << " item=" << event.itemId;
}
if (!event.displayName.empty()) {
stream << " label=" << event.displayName;
}
return stream.str();
}
std::string DescribeHierarchyPanelEvent(const ProductHierarchyPanel::Event& event) {
std::ostringstream stream = {};
switch (event.kind) {
case ProductHierarchyPanel::EventKind::SelectionChanged:
stream << "SelectionChanged";
break;
case ProductHierarchyPanel::EventKind::Reparented:
stream << "Reparented";
break;
case ProductHierarchyPanel::EventKind::MovedToRoot:
stream << "MovedToRoot";
break;
case ProductHierarchyPanel::EventKind::RenameRequested:
stream << "RenameRequested";
break;
case ProductHierarchyPanel::EventKind::None:
default:
stream << "None";
break;
}
if (!event.itemId.empty()) {
stream << " item=" << event.itemId;
}
if (!event.targetItemId.empty()) {
stream << " target=" << event.targetItemId;
}
if (!event.label.empty()) {
stream << " label=" << event.label;
}
return stream.str();
}
void ApplyHierarchySelection(
ProductEditorContext& context,
const ProductHierarchyPanel::Event& event) {
if (event.kind != ProductHierarchyPanel::EventKind::SelectionChanged) {
return;
}
if (event.itemId.empty()) {
context.ClearSelection();
return;
}
ProductEditorSelectionState selection = {};
selection.kind = ProductEditorSelectionKind::HierarchyNode;
selection.itemId = event.itemId;
selection.displayName = event.label.empty() ? event.itemId : event.label;
context.SetSelection(std::move(selection));
}
void ApplyProjectSelection(
ProductEditorContext& context,
const ProductProjectPanel::Event& event) {
switch (event.kind) {
case ProductProjectPanel::EventKind::AssetSelected: {
ProductEditorSelectionState selection = {};
selection.kind = ProductEditorSelectionKind::ProjectItem;
selection.itemId = event.itemId;
selection.displayName = event.displayName.empty() ? event.itemId : event.displayName;
selection.absolutePath = event.absolutePath;
selection.directory = event.directory;
context.SetSelection(std::move(selection));
return;
}
case ProductProjectPanel::EventKind::AssetSelectionCleared:
case ProductProjectPanel::EventKind::FolderNavigated:
if (context.GetSession().selection.kind == ProductEditorSelectionKind::ProjectItem) {
context.ClearSelection();
}
return;
case ProductProjectPanel::EventKind::AssetOpened:
case ProductProjectPanel::EventKind::ContextMenuRequested:
case ProductProjectPanel::EventKind::None:
default:
return;
}
}
} // namespace
std::vector<ProductEditorWorkspaceTraceEntry> ConsumeProductEditorWorkspaceEvents(
ProductEditorContext& context,
const ProductEditorWorkspace& workspace) {
std::vector<ProductEditorWorkspaceTraceEntry> entries = {};
for (const ProductHierarchyPanel::Event& event : workspace.GetHierarchyPanelEvents()) {
ApplyHierarchySelection(context, event);
const std::string message = DescribeHierarchyPanelEvent(event);
context.SetStatus("Hierarchy", message);
entries.push_back(ProductEditorWorkspaceTraceEntry{ "hierarchy", std::move(message) });
}
for (const ProductProjectPanel::Event& event : workspace.GetProjectPanelEvents()) {
ApplyProjectSelection(context, event);
const std::string message = DescribeProjectPanelEvent(event);
context.SetStatus("Project", message);
entries.push_back(ProductEditorWorkspaceTraceEntry{ "project", std::move(message) });
}
return entries;
}
} // namespace XCEngine::UI::Editor::App

View File

@@ -0,0 +1,20 @@
#pragma once
#include <string>
#include <vector>
namespace XCEngine::UI::Editor::App {
class ProductEditorContext;
class ProductEditorWorkspace;
struct ProductEditorWorkspaceTraceEntry {
std::string channel = {};
std::string message = {};
};
std::vector<ProductEditorWorkspaceTraceEntry> ConsumeProductEditorWorkspaceEvents(
ProductEditorContext& context,
const ProductEditorWorkspace& workspace);
} // namespace XCEngine::UI::Editor::App