1102 lines
38 KiB
C++
1102 lines
38 KiB
C++
#include "ProductProjectPanel.h"
|
|
|
|
#include "Icons/ProductBuiltInIcons.h"
|
|
#include "Panels/ProductTreeViewStyle.h"
|
|
|
|
#include <XCEditor/Collections/UIEditorTreeView.h>
|
|
#include <XCEditor/Foundation/UIEditorTheme.h>
|
|
|
|
#include <algorithm>
|
|
#include <cctype>
|
|
#include <cmath>
|
|
#include <cstdint>
|
|
#include <string_view>
|
|
#include <system_error>
|
|
#include <utility>
|
|
|
|
#include <windows.h>
|
|
|
|
namespace XCEngine::UI::Editor::App {
|
|
|
|
namespace {
|
|
|
|
using ::XCEngine::UI::Editor::UIEditorTextMeasureRequest;
|
|
using ::XCEngine::UI::Editor::UIEditorTextMeasurer;
|
|
using ::XCEngine::UI::UIColor;
|
|
using ::XCEngine::UI::UIDrawList;
|
|
using ::XCEngine::UI::UIInputEvent;
|
|
using ::XCEngine::UI::UIInputEventType;
|
|
using ::XCEngine::UI::UIPoint;
|
|
using ::XCEngine::UI::UIRect;
|
|
using Widgets::AppendUIEditorTreeViewBackground;
|
|
using Widgets::AppendUIEditorTreeViewForeground;
|
|
|
|
constexpr std::string_view kProjectPanelId = "project";
|
|
constexpr std::string_view kAssetsRootId = "Assets";
|
|
constexpr std::size_t kInvalidLayoutIndex = static_cast<std::size_t>(-1);
|
|
|
|
constexpr float kBrowserHeaderHeight = 24.0f;
|
|
constexpr float kNavigationMinWidth = 180.0f;
|
|
constexpr float kBrowserMinWidth = 260.0f;
|
|
constexpr float kHeaderHorizontalPadding = 10.0f;
|
|
constexpr float kHeaderBottomBorderThickness = 1.0f;
|
|
constexpr float kBreadcrumbItemPaddingX = 4.0f;
|
|
constexpr float kBreadcrumbItemPaddingY = 1.0f;
|
|
constexpr float kBreadcrumbSpacing = 3.0f;
|
|
constexpr float kTreeTopPadding = 0.0f;
|
|
constexpr float kGridInsetX = 16.0f;
|
|
constexpr float kGridInsetY = 12.0f;
|
|
constexpr float kGridTileWidth = 92.0f;
|
|
constexpr float kGridTileHeight = 92.0f;
|
|
constexpr float kGridTileGapX = 12.0f;
|
|
constexpr float kGridTileGapY = 12.0f;
|
|
constexpr float kGridPreviewWidth = 68.0f;
|
|
constexpr float kGridPreviewHeight = 54.0f;
|
|
constexpr float kHeaderFontSize = 12.0f;
|
|
constexpr float kTileLabelFontSize = 11.0f;
|
|
|
|
constexpr UIColor kSurfaceColor(0.200f, 0.200f, 0.200f, 1.0f);
|
|
constexpr UIColor kPaneColor(0.205f, 0.205f, 0.205f, 1.0f);
|
|
constexpr UIColor kHeaderColor(0.215f, 0.215f, 0.215f, 1.0f);
|
|
constexpr UIColor kTextPrimary(0.830f, 0.830f, 0.830f, 1.0f);
|
|
constexpr UIColor kTextStrong(0.910f, 0.910f, 0.910f, 1.0f);
|
|
constexpr UIColor kTextMuted(0.560f, 0.560f, 0.560f, 1.0f);
|
|
constexpr UIColor kTileHoverColor(0.245f, 0.245f, 0.245f, 1.0f);
|
|
constexpr UIColor kTileSelectedColor(0.300f, 0.300f, 0.300f, 1.0f);
|
|
constexpr UIColor kTilePreviewFillColor(0.700f, 0.700f, 0.700f, 1.0f);
|
|
constexpr UIColor kTilePreviewShadeColor(0.610f, 0.610f, 0.610f, 1.0f);
|
|
constexpr UIColor kTilePreviewOutlineColor(0.860f, 0.860f, 0.860f, 0.35f);
|
|
|
|
bool ContainsPoint(const UIRect& rect, const UIPoint& point) {
|
|
return point.x >= rect.x &&
|
|
point.x <= rect.x + rect.width &&
|
|
point.y >= rect.y &&
|
|
point.y <= rect.y + rect.height;
|
|
}
|
|
|
|
float ClampNonNegative(float value) {
|
|
return (std::max)(value, 0.0f);
|
|
}
|
|
|
|
float ResolveTextTop(float rectY, float rectHeight, float fontSize) {
|
|
const float lineHeight = fontSize * 1.6f;
|
|
return rectY + std::floor((rectHeight - lineHeight) * 0.5f);
|
|
}
|
|
|
|
float MeasureTextWidth(
|
|
const UIEditorTextMeasurer* textMeasurer,
|
|
std::string_view text,
|
|
float fontSize) {
|
|
if (text.empty()) {
|
|
return 0.0f;
|
|
}
|
|
|
|
if (textMeasurer != nullptr) {
|
|
const float measuredWidth =
|
|
textMeasurer->MeasureTextWidth(UIEditorTextMeasureRequest{ text, fontSize });
|
|
if (measuredWidth > 0.0f) {
|
|
return measuredWidth;
|
|
}
|
|
}
|
|
|
|
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(
|
|
const UIRect& bounds,
|
|
const std::vector<UIInputEvent>& inputEvents,
|
|
bool allowInteraction,
|
|
bool panelActive,
|
|
bool captureActive) {
|
|
if (!allowInteraction && !captureActive) {
|
|
return {};
|
|
}
|
|
|
|
std::vector<UIInputEvent> filteredEvents = {};
|
|
filteredEvents.reserve(inputEvents.size());
|
|
for (const UIInputEvent& event : inputEvents) {
|
|
switch (event.type) {
|
|
case UIInputEventType::PointerMove:
|
|
case UIInputEventType::PointerButtonDown:
|
|
case UIInputEventType::PointerButtonUp:
|
|
case UIInputEventType::PointerWheel:
|
|
if (captureActive || ContainsPoint(bounds, event.position)) {
|
|
filteredEvents.push_back(event);
|
|
}
|
|
break;
|
|
case UIInputEventType::PointerLeave:
|
|
filteredEvents.push_back(event);
|
|
break;
|
|
case UIInputEventType::FocusGained:
|
|
case UIInputEventType::FocusLost:
|
|
if (panelActive || captureActive) {
|
|
filteredEvents.push_back(event);
|
|
}
|
|
break;
|
|
case UIInputEventType::KeyDown:
|
|
case UIInputEventType::KeyUp:
|
|
case UIInputEventType::Character:
|
|
if (panelActive) {
|
|
filteredEvents.push_back(event);
|
|
}
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
return filteredEvents;
|
|
}
|
|
|
|
std::vector<UIInputEvent> FilterTreeInputEvents(
|
|
const std::vector<UIInputEvent>& inputEvents,
|
|
bool suppressPointerInput) {
|
|
if (!suppressPointerInput) {
|
|
return inputEvents;
|
|
}
|
|
|
|
std::vector<UIInputEvent> filteredEvents = {};
|
|
filteredEvents.reserve(inputEvents.size());
|
|
for (const UIInputEvent& event : inputEvents) {
|
|
switch (event.type) {
|
|
case UIInputEventType::PointerMove:
|
|
case UIInputEventType::PointerButtonDown:
|
|
case UIInputEventType::PointerButtonUp:
|
|
case UIInputEventType::PointerWheel:
|
|
case UIInputEventType::PointerEnter:
|
|
break;
|
|
default:
|
|
filteredEvents.push_back(event);
|
|
break;
|
|
}
|
|
}
|
|
return filteredEvents;
|
|
}
|
|
|
|
::XCEngine::UI::UITextureHandle ResolveFolderIcon(const ProductBuiltInIcons* icons) {
|
|
return icons != nullptr
|
|
? icons->Resolve(ProductBuiltInIconKind::Folder)
|
|
: ::XCEngine::UI::UITextureHandle {};
|
|
}
|
|
|
|
float ClampNavigationWidth(float value, float totalWidth) {
|
|
const float maxWidth =
|
|
(std::max)(
|
|
kNavigationMinWidth,
|
|
totalWidth - kBrowserMinWidth - ResolveUIEditorDockHostMetrics().splitterMetrics.thickness);
|
|
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(
|
|
UIDrawList& drawList,
|
|
const UIRect& previewRect,
|
|
bool directory) {
|
|
if (directory) {
|
|
const UIRect tabRect(
|
|
previewRect.x + 8.0f,
|
|
previewRect.y + 7.0f,
|
|
22.0f,
|
|
7.0f);
|
|
const UIRect bodyRect(
|
|
previewRect.x + 4.0f,
|
|
previewRect.y + 13.0f,
|
|
previewRect.width - 8.0f,
|
|
previewRect.height - 18.0f);
|
|
drawList.AddFilledRect(tabRect, kTilePreviewShadeColor, 1.0f);
|
|
drawList.AddFilledRect(bodyRect, kTilePreviewFillColor, 1.0f);
|
|
drawList.AddRectOutline(bodyRect, kTilePreviewOutlineColor, 1.0f, 1.0f);
|
|
return;
|
|
}
|
|
|
|
const UIRect sheetRect(
|
|
previewRect.x + 10.0f,
|
|
previewRect.y + 4.0f,
|
|
previewRect.width - 20.0f,
|
|
previewRect.height - 8.0f);
|
|
const UIRect accentRect(
|
|
sheetRect.x,
|
|
sheetRect.y,
|
|
sheetRect.width,
|
|
7.0f);
|
|
drawList.AddFilledRect(sheetRect, kTilePreviewFillColor, 1.0f);
|
|
drawList.AddFilledRect(accentRect, kTilePreviewShadeColor, 1.0f);
|
|
drawList.AddRectOutline(sheetRect, kTilePreviewOutlineColor, 1.0f, 1.0f);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
void ProductProjectPanel::Initialize(const std::filesystem::path& repoRoot) {
|
|
m_assetsRootPath = (repoRoot / "project/Assets").lexically_normal();
|
|
RefreshFolderTree();
|
|
SyncCurrentFolderSelection();
|
|
RefreshAssetList();
|
|
}
|
|
|
|
void ProductProjectPanel::SetBuiltInIcons(const ProductBuiltInIcons* icons) {
|
|
m_icons = icons;
|
|
if (!m_assetsRootPath.empty()) {
|
|
RefreshFolderTree();
|
|
SyncCurrentFolderSelection();
|
|
}
|
|
}
|
|
|
|
void ProductProjectPanel::SetTextMeasurer(const UIEditorTextMeasurer* textMeasurer) {
|
|
m_textMeasurer = textMeasurer;
|
|
}
|
|
|
|
ProductProjectPanel::CursorKind ProductProjectPanel::GetCursorKind() const {
|
|
return (m_splitterHovered || m_splitterDragging) ? CursorKind::ResizeEW : CursorKind::Arrow;
|
|
}
|
|
|
|
bool ProductProjectPanel::WantsHostPointerCapture() const {
|
|
return m_requestPointerCapture;
|
|
}
|
|
|
|
bool ProductProjectPanel::WantsHostPointerRelease() const {
|
|
return m_requestPointerRelease;
|
|
}
|
|
|
|
bool ProductProjectPanel::HasActivePointerCapture() const {
|
|
return m_splitterDragging;
|
|
}
|
|
|
|
const std::vector<ProductProjectPanel::Event>& ProductProjectPanel::GetFrameEvents() const {
|
|
return m_frameEvents;
|
|
}
|
|
|
|
const ProductProjectPanel::FolderEntry* ProductProjectPanel::FindFolderEntry(
|
|
std::string_view itemId) const {
|
|
for (const FolderEntry& entry : m_folderEntries) {
|
|
if (entry.itemId == itemId) {
|
|
return &entry;
|
|
}
|
|
}
|
|
|
|
return nullptr;
|
|
}
|
|
|
|
const ProductProjectPanel::AssetEntry* ProductProjectPanel::FindAssetEntry(
|
|
std::string_view itemId) const {
|
|
for (const AssetEntry& entry : m_assetEntries) {
|
|
if (entry.itemId == itemId) {
|
|
return &entry;
|
|
}
|
|
}
|
|
|
|
return nullptr;
|
|
}
|
|
|
|
const UIEditorPanelContentHostPanelState* ProductProjectPanel::FindMountedProjectPanel(
|
|
const UIEditorPanelContentHostFrame& contentHostFrame) const {
|
|
for (const UIEditorPanelContentHostPanelState& panelState : contentHostFrame.panelStates) {
|
|
if (panelState.panelId == kProjectPanelId && panelState.mounted) {
|
|
return &panelState;
|
|
}
|
|
}
|
|
|
|
return nullptr;
|
|
}
|
|
|
|
ProductProjectPanel::Layout ProductProjectPanel::BuildLayout(const UIRect& bounds) const {
|
|
Layout layout = {};
|
|
const float dividerThickness = ResolveUIEditorDockHostMetrics().splitterMetrics.thickness;
|
|
layout.bounds = UIRect(
|
|
bounds.x,
|
|
bounds.y,
|
|
ClampNonNegative(bounds.width),
|
|
ClampNonNegative(bounds.height));
|
|
|
|
const float leftWidth = ClampNavigationWidth(m_navigationWidth, layout.bounds.width);
|
|
layout.leftPaneRect = UIRect(
|
|
layout.bounds.x,
|
|
layout.bounds.y,
|
|
leftWidth,
|
|
layout.bounds.height);
|
|
layout.dividerRect = UIRect(
|
|
layout.leftPaneRect.x + layout.leftPaneRect.width,
|
|
layout.bounds.y,
|
|
dividerThickness,
|
|
layout.bounds.height);
|
|
layout.rightPaneRect = UIRect(
|
|
layout.dividerRect.x + layout.dividerRect.width,
|
|
layout.bounds.y,
|
|
ClampNonNegative(layout.bounds.width - layout.leftPaneRect.width - layout.dividerRect.width),
|
|
layout.bounds.height);
|
|
|
|
layout.treeRect = UIRect(
|
|
layout.leftPaneRect.x,
|
|
layout.leftPaneRect.y + kTreeTopPadding,
|
|
layout.leftPaneRect.width,
|
|
ClampNonNegative(layout.leftPaneRect.height - kTreeTopPadding));
|
|
|
|
layout.browserHeaderRect = UIRect(
|
|
layout.rightPaneRect.x,
|
|
layout.rightPaneRect.y,
|
|
layout.rightPaneRect.width,
|
|
(std::min)(kBrowserHeaderHeight, layout.rightPaneRect.height));
|
|
layout.browserBodyRect = UIRect(
|
|
layout.rightPaneRect.x,
|
|
layout.browserHeaderRect.y + layout.browserHeaderRect.height,
|
|
layout.rightPaneRect.width,
|
|
ClampNonNegative(layout.rightPaneRect.height - layout.browserHeaderRect.height));
|
|
layout.gridRect = UIRect(
|
|
layout.browserBodyRect.x + kGridInsetX,
|
|
layout.browserBodyRect.y + kGridInsetY,
|
|
ClampNonNegative(layout.browserBodyRect.width - kGridInsetX * 2.0f),
|
|
ClampNonNegative(layout.browserBodyRect.height - kGridInsetY * 2.0f));
|
|
|
|
const float breadcrumbRowHeight = kHeaderFontSize + kBreadcrumbItemPaddingY * 2.0f;
|
|
const float breadcrumbY =
|
|
layout.browserHeaderRect.y + std::floor((layout.browserHeaderRect.height - breadcrumbRowHeight) * 0.5f);
|
|
const float headerRight =
|
|
layout.browserHeaderRect.x + layout.browserHeaderRect.width - kHeaderHorizontalPadding;
|
|
float nextItemX = layout.browserHeaderRect.x + kHeaderHorizontalPadding;
|
|
std::string cumulativeFolderId = {};
|
|
const std::vector<std::string> segments = BuildBreadcrumbSegments(m_currentFolderId);
|
|
for (std::size_t index = 0u; index < segments.size(); ++index) {
|
|
if (index > 0u) {
|
|
const float separatorWidth =
|
|
MeasureTextWidth(m_textMeasurer, ">", kHeaderFontSize);
|
|
if (nextItemX < headerRight && separatorWidth > 0.0f) {
|
|
layout.breadcrumbItems.push_back({
|
|
">",
|
|
{},
|
|
UIRect(
|
|
nextItemX,
|
|
breadcrumbY,
|
|
ClampNonNegative((std::min)(separatorWidth, headerRight - nextItemX)),
|
|
breadcrumbRowHeight),
|
|
true,
|
|
false,
|
|
false
|
|
});
|
|
}
|
|
nextItemX += separatorWidth + kBreadcrumbSpacing;
|
|
}
|
|
|
|
if (index == 0u) {
|
|
cumulativeFolderId = segments[index];
|
|
} else {
|
|
cumulativeFolderId += "/";
|
|
cumulativeFolderId += segments[index];
|
|
}
|
|
|
|
const float labelWidth =
|
|
MeasureTextWidth(m_textMeasurer, segments[index], kHeaderFontSize);
|
|
const float itemWidth = labelWidth + kBreadcrumbItemPaddingX * 2.0f;
|
|
const float availableWidth = headerRight - nextItemX;
|
|
if (availableWidth <= 0.0f) {
|
|
break;
|
|
}
|
|
|
|
layout.breadcrumbItems.push_back({
|
|
segments[index],
|
|
cumulativeFolderId,
|
|
UIRect(
|
|
nextItemX,
|
|
breadcrumbY,
|
|
ClampNonNegative((std::min)(itemWidth, availableWidth)),
|
|
breadcrumbRowHeight),
|
|
false,
|
|
index + 1u != segments.size(),
|
|
index + 1u == segments.size()
|
|
});
|
|
nextItemX += itemWidth + kBreadcrumbSpacing;
|
|
}
|
|
|
|
const float effectiveTileWidth = kGridTileWidth + kGridTileGapX;
|
|
int columnCount = effectiveTileWidth > 0.0f
|
|
? static_cast<int>((layout.gridRect.width + kGridTileGapX) / effectiveTileWidth)
|
|
: 1;
|
|
if (columnCount < 1) {
|
|
columnCount = 1;
|
|
}
|
|
|
|
layout.assetTiles.reserve(m_assetEntries.size());
|
|
for (std::size_t index = 0; index < m_assetEntries.size(); ++index) {
|
|
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 float tileX = layout.gridRect.x + static_cast<float>(column) * (kGridTileWidth + kGridTileGapX);
|
|
const float tileY = layout.gridRect.y + static_cast<float>(row) * (kGridTileHeight + kGridTileGapY);
|
|
|
|
AssetTileLayout tile = {};
|
|
tile.itemIndex = index;
|
|
tile.tileRect = UIRect(tileX, tileY, kGridTileWidth, kGridTileHeight);
|
|
tile.previewRect = UIRect(
|
|
tile.tileRect.x + (tile.tileRect.width - kGridPreviewWidth) * 0.5f,
|
|
tile.tileRect.y + 6.0f,
|
|
kGridPreviewWidth,
|
|
kGridPreviewHeight);
|
|
tile.labelRect = UIRect(
|
|
tile.tileRect.x + 4.0f,
|
|
tile.previewRect.y + tile.previewRect.height + 8.0f,
|
|
tile.tileRect.width - 8.0f,
|
|
18.0f);
|
|
layout.assetTiles.push_back(tile);
|
|
}
|
|
|
|
return layout;
|
|
}
|
|
|
|
std::size_t ProductProjectPanel::HitTestBreadcrumbItem(const UIPoint& point) const {
|
|
for (std::size_t index = 0u; index < m_layout.breadcrumbItems.size(); ++index) {
|
|
const BreadcrumbItemLayout& item = m_layout.breadcrumbItems[index];
|
|
if (!item.separator && ContainsPoint(item.rect, point)) {
|
|
return index;
|
|
}
|
|
}
|
|
|
|
return kInvalidLayoutIndex;
|
|
}
|
|
|
|
std::size_t ProductProjectPanel::HitTestAssetTile(const UIPoint& point) const {
|
|
for (const AssetTileLayout& tile : m_layout.assetTiles) {
|
|
if (ContainsPoint(tile.tileRect, point)) {
|
|
return tile.itemIndex;
|
|
}
|
|
}
|
|
|
|
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() {
|
|
EnsureValidCurrentFolder();
|
|
ExpandFolderAncestors(m_currentFolderId);
|
|
m_folderSelection.SetSelection(m_currentFolderId);
|
|
}
|
|
|
|
bool ProductProjectPanel::NavigateToFolder(std::string_view itemId, EventSource source) {
|
|
if (itemId.empty() || FindFolderEntry(itemId) == nullptr || itemId == m_currentFolderId) {
|
|
return false;
|
|
}
|
|
|
|
m_currentFolderId = std::string(itemId);
|
|
SyncCurrentFolderSelection();
|
|
m_assetSelection.ClearSelection();
|
|
m_hoveredAssetItemId.clear();
|
|
m_lastPrimaryClickedAssetId.clear();
|
|
RefreshAssetList();
|
|
EmitEvent(EventKind::FolderNavigated, source, FindFolderEntry(m_currentFolderId));
|
|
return true;
|
|
}
|
|
|
|
void ProductProjectPanel::EmitEvent(
|
|
EventKind kind,
|
|
EventSource source,
|
|
const FolderEntry* folder) {
|
|
if (kind == EventKind::None || folder == nullptr) {
|
|
return;
|
|
}
|
|
|
|
Event event = {};
|
|
event.kind = kind;
|
|
event.source = source;
|
|
event.itemId = folder->itemId;
|
|
event.absolutePath = folder->absolutePath;
|
|
event.displayName = PathToUtf8String(folder->absolutePath.filename());
|
|
event.directory = true;
|
|
m_frameEvents.push_back(std::move(event));
|
|
}
|
|
|
|
void ProductProjectPanel::EmitEvent(
|
|
EventKind kind,
|
|
EventSource source,
|
|
const AssetEntry* asset) {
|
|
if (kind == EventKind::None) {
|
|
return;
|
|
}
|
|
|
|
Event event = {};
|
|
event.kind = kind;
|
|
event.source = source;
|
|
if (asset != nullptr) {
|
|
event.itemId = asset->itemId;
|
|
event.absolutePath = asset->absolutePath;
|
|
event.displayName = asset->displayName;
|
|
event.directory = asset->directory;
|
|
}
|
|
m_frameEvents.push_back(std::move(event));
|
|
}
|
|
|
|
void ProductProjectPanel::EmitSelectionClearedEvent(EventSource source) {
|
|
Event event = {};
|
|
event.kind = EventKind::AssetSelectionCleared;
|
|
event.source = source;
|
|
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() {
|
|
m_treeFrame = {};
|
|
m_frameEvents.clear();
|
|
m_layout = {};
|
|
m_hoveredAssetItemId.clear();
|
|
m_hoveredBreadcrumbIndex = kInvalidLayoutIndex;
|
|
m_pressedBreadcrumbIndex = kInvalidLayoutIndex;
|
|
m_splitterHovered = false;
|
|
m_splitterDragging = false;
|
|
}
|
|
|
|
void ProductProjectPanel::Update(
|
|
const UIEditorPanelContentHostFrame& contentHostFrame,
|
|
const std::vector<UIInputEvent>& inputEvents,
|
|
bool allowInteraction,
|
|
bool panelActive) {
|
|
m_requestPointerCapture = false;
|
|
m_requestPointerRelease = false;
|
|
m_frameEvents.clear();
|
|
|
|
const UIEditorPanelContentHostPanelState* panelState =
|
|
FindMountedProjectPanel(contentHostFrame);
|
|
if (panelState == nullptr) {
|
|
if (m_splitterDragging) {
|
|
m_requestPointerRelease = true;
|
|
}
|
|
m_visible = false;
|
|
ResetTransientFrames();
|
|
return;
|
|
}
|
|
|
|
if (m_treeItems.empty()) {
|
|
RefreshFolderTree();
|
|
SyncCurrentFolderSelection();
|
|
RefreshAssetList();
|
|
}
|
|
|
|
m_visible = true;
|
|
const std::vector<UIInputEvent> filteredEvents =
|
|
FilterProjectPanelInputEvents(
|
|
panelState->bounds,
|
|
inputEvents,
|
|
allowInteraction,
|
|
panelActive,
|
|
m_splitterDragging);
|
|
|
|
m_navigationWidth = ClampNavigationWidth(m_navigationWidth, panelState->bounds.width);
|
|
m_layout = BuildLayout(panelState->bounds);
|
|
const Widgets::UIEditorTreeViewMetrics treeMetrics = BuildProductTreeViewMetrics();
|
|
const std::vector<UIInputEvent> treeEvents =
|
|
FilterTreeInputEvents(filteredEvents, m_splitterDragging);
|
|
m_treeFrame = UpdateUIEditorTreeViewInteraction(
|
|
m_treeInteractionState,
|
|
m_folderSelection,
|
|
m_folderExpansion,
|
|
m_layout.treeRect,
|
|
m_treeItems,
|
|
treeEvents,
|
|
treeMetrics);
|
|
|
|
if (m_treeFrame.result.selectionChanged &&
|
|
!m_treeFrame.result.selectedItemId.empty() &&
|
|
m_treeFrame.result.selectedItemId != m_currentFolderId) {
|
|
NavigateToFolder(m_treeFrame.result.selectedItemId, EventSource::Tree);
|
|
m_layout = BuildLayout(panelState->bounds);
|
|
}
|
|
|
|
for (const UIInputEvent& event : filteredEvents) {
|
|
switch (event.type) {
|
|
case UIInputEventType::FocusLost:
|
|
m_hoveredAssetItemId.clear();
|
|
m_hoveredBreadcrumbIndex = kInvalidLayoutIndex;
|
|
m_pressedBreadcrumbIndex = kInvalidLayoutIndex;
|
|
m_splitterHovered = false;
|
|
if (m_splitterDragging) {
|
|
m_splitterDragging = false;
|
|
m_requestPointerRelease = true;
|
|
}
|
|
break;
|
|
|
|
case UIInputEventType::PointerMove: {
|
|
if (m_splitterDragging) {
|
|
m_navigationWidth =
|
|
ClampNavigationWidth(event.position.x - panelState->bounds.x, panelState->bounds.width);
|
|
m_layout = BuildLayout(panelState->bounds);
|
|
}
|
|
|
|
m_splitterHovered =
|
|
m_splitterDragging || ContainsPoint(m_layout.dividerRect, event.position);
|
|
m_hoveredBreadcrumbIndex = HitTestBreadcrumbItem(event.position);
|
|
const std::size_t hoveredAssetIndex = HitTestAssetTile(event.position);
|
|
m_hoveredAssetItemId =
|
|
hoveredAssetIndex < m_assetEntries.size()
|
|
? m_assetEntries[hoveredAssetIndex].itemId
|
|
: std::string();
|
|
break;
|
|
}
|
|
|
|
case UIInputEventType::PointerLeave:
|
|
if (!m_splitterDragging) {
|
|
m_splitterHovered = false;
|
|
}
|
|
m_hoveredBreadcrumbIndex = kInvalidLayoutIndex;
|
|
m_hoveredAssetItemId.clear();
|
|
break;
|
|
|
|
case UIInputEventType::PointerButtonDown:
|
|
if (event.pointerButton == ::XCEngine::UI::UIPointerButton::Left) {
|
|
if (ContainsPoint(m_layout.dividerRect, event.position)) {
|
|
m_splitterDragging = true;
|
|
m_splitterHovered = true;
|
|
m_pressedBreadcrumbIndex = kInvalidLayoutIndex;
|
|
m_requestPointerCapture = true;
|
|
break;
|
|
}
|
|
|
|
m_pressedBreadcrumbIndex = HitTestBreadcrumbItem(event.position);
|
|
|
|
if (!ContainsPoint(m_layout.gridRect, event.position)) {
|
|
break;
|
|
}
|
|
|
|
const std::size_t hitIndex = HitTestAssetTile(event.position);
|
|
if (hitIndex >= m_assetEntries.size()) {
|
|
if (m_assetSelection.HasSelection()) {
|
|
m_assetSelection.ClearSelection();
|
|
EmitSelectionClearedEvent(EventSource::Background);
|
|
}
|
|
break;
|
|
}
|
|
|
|
const AssetEntry& assetEntry = m_assetEntries[hitIndex];
|
|
const bool alreadySelected = m_assetSelection.IsSelected(assetEntry.itemId);
|
|
const bool selectionChanged = m_assetSelection.SetSelection(assetEntry.itemId);
|
|
if (selectionChanged) {
|
|
EmitEvent(EventKind::AssetSelected, EventSource::GridPrimary, &assetEntry);
|
|
}
|
|
|
|
const std::uint64_t nowMs = GetTickCount64();
|
|
const std::uint64_t doubleClickThresholdMs =
|
|
static_cast<std::uint64_t>(GetDoubleClickTime());
|
|
const bool doubleClicked =
|
|
alreadySelected &&
|
|
m_lastPrimaryClickedAssetId == assetEntry.itemId &&
|
|
nowMs >= m_lastPrimaryClickTimeMs &&
|
|
nowMs - m_lastPrimaryClickTimeMs <= doubleClickThresholdMs;
|
|
|
|
m_lastPrimaryClickedAssetId = assetEntry.itemId;
|
|
m_lastPrimaryClickTimeMs = nowMs;
|
|
if (!doubleClicked) {
|
|
break;
|
|
}
|
|
|
|
if (assetEntry.directory) {
|
|
NavigateToFolder(assetEntry.itemId, EventSource::GridDoubleClick);
|
|
m_layout = BuildLayout(panelState->bounds);
|
|
m_hoveredAssetItemId.clear();
|
|
} else {
|
|
EmitEvent(EventKind::AssetOpened, EventSource::GridDoubleClick, &assetEntry);
|
|
}
|
|
break;
|
|
}
|
|
|
|
if (event.pointerButton == ::XCEngine::UI::UIPointerButton::Right &&
|
|
ContainsPoint(m_layout.gridRect, event.position)) {
|
|
const std::size_t hitIndex = HitTestAssetTile(event.position);
|
|
if (hitIndex >= m_assetEntries.size()) {
|
|
EmitEvent(EventKind::ContextMenuRequested, EventSource::Background, static_cast<const AssetEntry*>(nullptr));
|
|
break;
|
|
}
|
|
|
|
const AssetEntry& assetEntry = m_assetEntries[hitIndex];
|
|
if (!m_assetSelection.IsSelected(assetEntry.itemId)) {
|
|
m_assetSelection.SetSelection(assetEntry.itemId);
|
|
EmitEvent(EventKind::AssetSelected, EventSource::GridSecondary, &assetEntry);
|
|
}
|
|
EmitEvent(EventKind::ContextMenuRequested, EventSource::GridSecondary, &assetEntry);
|
|
}
|
|
break;
|
|
|
|
case UIInputEventType::PointerButtonUp:
|
|
if (event.pointerButton != ::XCEngine::UI::UIPointerButton::Left) {
|
|
break;
|
|
}
|
|
|
|
if (m_splitterDragging) {
|
|
m_splitterDragging = false;
|
|
m_splitterHovered = ContainsPoint(m_layout.dividerRect, event.position);
|
|
m_requestPointerRelease = true;
|
|
break;
|
|
}
|
|
|
|
{
|
|
const std::size_t releasedBreadcrumbIndex =
|
|
HitTestBreadcrumbItem(event.position);
|
|
if (m_pressedBreadcrumbIndex != kInvalidLayoutIndex &&
|
|
m_pressedBreadcrumbIndex == releasedBreadcrumbIndex &&
|
|
releasedBreadcrumbIndex < m_layout.breadcrumbItems.size()) {
|
|
const BreadcrumbItemLayout& item =
|
|
m_layout.breadcrumbItems[releasedBreadcrumbIndex];
|
|
if (item.clickable) {
|
|
NavigateToFolder(item.targetFolderId, EventSource::Breadcrumb);
|
|
m_layout = BuildLayout(panelState->bounds);
|
|
}
|
|
}
|
|
m_pressedBreadcrumbIndex = kInvalidLayoutIndex;
|
|
}
|
|
break;
|
|
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
void ProductProjectPanel::Append(UIDrawList& drawList) const {
|
|
if (!m_visible || m_layout.bounds.width <= 0.0f || m_layout.bounds.height <= 0.0f) {
|
|
return;
|
|
}
|
|
|
|
drawList.AddFilledRect(m_layout.bounds, kSurfaceColor);
|
|
drawList.AddFilledRect(m_layout.leftPaneRect, kPaneColor);
|
|
drawList.AddFilledRect(m_layout.rightPaneRect, kPaneColor);
|
|
drawList.AddFilledRect(
|
|
m_layout.dividerRect,
|
|
ResolveUIEditorDockHostPalette().splitterColor);
|
|
|
|
drawList.AddFilledRect(m_layout.browserHeaderRect, kHeaderColor);
|
|
drawList.AddFilledRect(
|
|
UIRect(
|
|
m_layout.browserHeaderRect.x,
|
|
m_layout.browserHeaderRect.y + m_layout.browserHeaderRect.height - kHeaderBottomBorderThickness,
|
|
m_layout.browserHeaderRect.width,
|
|
kHeaderBottomBorderThickness),
|
|
ResolveUIEditorDockHostPalette().splitterColor);
|
|
|
|
const Widgets::UIEditorTreeViewPalette treePalette = BuildProductTreeViewPalette();
|
|
const Widgets::UIEditorTreeViewMetrics treeMetrics = BuildProductTreeViewMetrics();
|
|
AppendUIEditorTreeViewBackground(
|
|
drawList,
|
|
m_treeFrame.layout,
|
|
m_treeItems,
|
|
m_folderSelection,
|
|
m_treeInteractionState.treeViewState,
|
|
treePalette,
|
|
treeMetrics);
|
|
AppendUIEditorTreeViewForeground(
|
|
drawList,
|
|
m_treeFrame.layout,
|
|
m_treeItems,
|
|
treePalette,
|
|
treeMetrics);
|
|
|
|
drawList.PushClipRect(m_layout.browserHeaderRect);
|
|
for (std::size_t index = 0u; index < m_layout.breadcrumbItems.size(); ++index) {
|
|
const BreadcrumbItemLayout& item = m_layout.breadcrumbItems[index];
|
|
const UIColor textColor =
|
|
item.separator
|
|
? kTextMuted
|
|
: (index == m_hoveredBreadcrumbIndex && item.clickable
|
|
? kTextStrong
|
|
: (item.current ? kTextPrimary : kTextMuted));
|
|
const float textWidth = MeasureTextWidth(m_textMeasurer, item.label, kHeaderFontSize);
|
|
const float textX = item.separator
|
|
? item.rect.x
|
|
: item.rect.x + (item.rect.width - textWidth) * 0.5f;
|
|
drawList.AddText(
|
|
UIPoint(textX, ResolveTextTop(item.rect.y, item.rect.height, kHeaderFontSize)),
|
|
item.label,
|
|
textColor,
|
|
kHeaderFontSize);
|
|
}
|
|
drawList.PopClipRect();
|
|
|
|
for (const AssetTileLayout& tile : m_layout.assetTiles) {
|
|
if (tile.itemIndex >= m_assetEntries.size()) {
|
|
continue;
|
|
}
|
|
|
|
const AssetEntry& assetEntry = m_assetEntries[tile.itemIndex];
|
|
const bool selected = m_assetSelection.IsSelected(assetEntry.itemId);
|
|
const bool hovered = m_hoveredAssetItemId == assetEntry.itemId;
|
|
|
|
if (selected || hovered) {
|
|
drawList.AddFilledRect(
|
|
tile.tileRect,
|
|
selected ? kTileSelectedColor : kTileHoverColor);
|
|
}
|
|
|
|
AppendTilePreview(drawList, tile.previewRect, assetEntry.directory);
|
|
|
|
drawList.PushClipRect(tile.labelRect);
|
|
const float textWidth =
|
|
MeasureTextWidth(m_textMeasurer, assetEntry.displayName, kTileLabelFontSize);
|
|
drawList.AddText(
|
|
UIPoint(
|
|
tile.labelRect.x + (tile.labelRect.width - textWidth) * 0.5f,
|
|
ResolveTextTop(tile.labelRect.y, tile.labelRect.height, kTileLabelFontSize)),
|
|
assetEntry.displayName,
|
|
kTextPrimary,
|
|
kTileLabelFontSize);
|
|
drawList.PopClipRect();
|
|
}
|
|
|
|
if (m_assetEntries.empty()) {
|
|
const UIRect messageRect(
|
|
m_layout.gridRect.x,
|
|
m_layout.gridRect.y,
|
|
m_layout.gridRect.width,
|
|
18.0f);
|
|
drawList.AddText(
|
|
UIPoint(messageRect.x, ResolveTextTop(messageRect.y, messageRect.height, kHeaderFontSize)),
|
|
"Current folder is empty.",
|
|
kTextMuted,
|
|
kHeaderFontSize);
|
|
}
|
|
}
|
|
|
|
} // namespace XCEngine::UI::Editor::App
|