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

@@ -7,11 +7,9 @@
#include <XCEditor/Foundation/UIEditorTheme.h>
#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstdint>
#include <string_view>
#include <system_error>
#include <utility>
#include <windows.h>
@@ -32,7 +30,6 @@ 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;
@@ -102,122 +99,6 @@ float MeasureTextWidth(
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,
@@ -303,35 +184,6 @@ float ClampNavigationWidth(float value, float totalWidth) {
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,
@@ -371,18 +223,14 @@ void AppendTilePreview(
} // namespace
void ProductProjectPanel::Initialize(const std::filesystem::path& repoRoot) {
m_assetsRootPath = (repoRoot / "project/Assets").lexically_normal();
RefreshFolderTree();
m_browserModel.Initialize(repoRoot);
SyncCurrentFolderSelection();
RefreshAssetList();
}
void ProductProjectPanel::SetBuiltInIcons(const ProductBuiltInIcons* icons) {
m_icons = icons;
if (!m_assetsRootPath.empty()) {
RefreshFolderTree();
SyncCurrentFolderSelection();
}
m_browserModel.SetFolderIcon(ResolveFolderIcon(m_icons));
SyncCurrentFolderSelection();
}
void ProductProjectPanel::SetTextMeasurer(const UIEditorTextMeasurer* textMeasurer) {
@@ -411,24 +259,12 @@ const std::vector<ProductProjectPanel::Event>& ProductProjectPanel::GetFrameEven
const ProductProjectPanel::FolderEntry* ProductProjectPanel::FindFolderEntry(
std::string_view itemId) const {
for (const FolderEntry& entry : m_folderEntries) {
if (entry.itemId == itemId) {
return &entry;
}
}
return nullptr;
return m_browserModel.FindFolderEntry(itemId);
}
const ProductProjectPanel::AssetEntry* ProductProjectPanel::FindAssetEntry(
std::string_view itemId) const {
for (const AssetEntry& entry : m_assetEntries) {
if (entry.itemId == itemId) {
return &entry;
}
}
return nullptr;
return m_browserModel.FindAssetEntry(itemId);
}
const UIEditorPanelContentHostPanelState* ProductProjectPanel::FindMountedProjectPanel(
@@ -444,6 +280,9 @@ const UIEditorPanelContentHostPanelState* ProductProjectPanel::FindMountedProjec
ProductProjectPanel::Layout ProductProjectPanel::BuildLayout(const UIRect& bounds) const {
Layout layout = {};
const auto& assetEntries = m_browserModel.GetAssetEntries();
const std::vector<ProductProjectPanel::BrowserModel::BreadcrumbSegment> breadcrumbSegments =
m_browserModel.BuildBreadcrumbSegments();
const float dividerThickness = ResolveUIEditorDockHostMetrics().splitterMetrics.thickness;
layout.bounds = UIRect(
bounds.x,
@@ -496,9 +335,7 @@ ProductProjectPanel::Layout ProductProjectPanel::BuildLayout(const UIRect& bound
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) {
for (std::size_t index = 0u; index < breadcrumbSegments.size(); ++index) {
if (index > 0u) {
const float separatorWidth =
MeasureTextWidth(m_textMeasurer, ">", kHeaderFontSize);
@@ -519,15 +356,9 @@ ProductProjectPanel::Layout ProductProjectPanel::BuildLayout(const UIRect& bound
nextItemX += separatorWidth + kBreadcrumbSpacing;
}
if (index == 0u) {
cumulativeFolderId = segments[index];
} else {
cumulativeFolderId += "/";
cumulativeFolderId += segments[index];
}
const ProductProjectPanel::BrowserModel::BreadcrumbSegment& segment = breadcrumbSegments[index];
const float labelWidth =
MeasureTextWidth(m_textMeasurer, segments[index], kHeaderFontSize);
MeasureTextWidth(m_textMeasurer, segment.label, kHeaderFontSize);
const float itemWidth = labelWidth + kBreadcrumbItemPaddingX * 2.0f;
const float availableWidth = headerRight - nextItemX;
if (availableWidth <= 0.0f) {
@@ -535,16 +366,16 @@ ProductProjectPanel::Layout ProductProjectPanel::BuildLayout(const UIRect& bound
}
layout.breadcrumbItems.push_back({
segments[index],
cumulativeFolderId,
segment.label,
segment.targetFolderId,
UIRect(
nextItemX,
breadcrumbY,
ClampNonNegative((std::min)(itemWidth, availableWidth)),
breadcrumbRowHeight),
false,
index + 1u != segments.size(),
index + 1u == segments.size()
!segment.current,
segment.current
});
nextItemX += itemWidth + kBreadcrumbSpacing;
}
@@ -557,8 +388,8 @@ ProductProjectPanel::Layout ProductProjectPanel::BuildLayout(const UIRect& bound
columnCount = 1;
}
layout.assetTiles.reserve(m_assetEntries.size());
for (std::size_t index = 0; index < m_assetEntries.size(); ++index) {
layout.assetTiles.reserve(assetEntries.size());
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 row = static_cast<int>(index / static_cast<std::size_t>(columnCount));
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;
}
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);
const std::string& currentFolderId = m_browserModel.GetCurrentFolderId();
if (currentFolderId.empty()) {
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) {
if (itemId.empty() || FindFolderEntry(itemId) == nullptr || itemId == m_currentFolderId) {
if (!m_browserModel.NavigateToFolder(itemId)) {
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));
EmitEvent(EventKind::FolderNavigated, source, FindFolderEntry(m_browserModel.GetCurrentFolderId()));
return true;
}
@@ -699,7 +476,7 @@ void ProductProjectPanel::EmitEvent(
event.source = source;
event.itemId = folder->itemId;
event.absolutePath = folder->absolutePath;
event.displayName = PathToUtf8String(folder->absolutePath.filename());
event.displayName = folder->label;
event.directory = true;
m_frameEvents.push_back(std::move(event));
}
@@ -731,55 +508,6 @@ void ProductProjectPanel::EmitSelectionClearedEvent(EventSource 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();
@@ -811,10 +539,9 @@ void ProductProjectPanel::Update(
return;
}
if (m_treeItems.empty()) {
RefreshFolderTree();
if (m_browserModel.GetTreeItems().empty()) {
m_browserModel.Refresh();
SyncCurrentFolderSelection();
RefreshAssetList();
}
m_visible = true;
@@ -836,13 +563,13 @@ void ProductProjectPanel::Update(
m_folderSelection,
m_folderExpansion,
m_layout.treeRect,
m_treeItems,
m_browserModel.GetTreeItems(),
treeEvents,
treeMetrics);
if (m_treeFrame.result.selectionChanged &&
!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);
m_layout = BuildLayout(panelState->bounds);
}
@@ -871,9 +598,10 @@ void ProductProjectPanel::Update(
m_splitterDragging || ContainsPoint(m_layout.dividerRect, event.position);
m_hoveredBreadcrumbIndex = HitTestBreadcrumbItem(event.position);
const std::size_t hoveredAssetIndex = HitTestAssetTile(event.position);
const auto& assetEntries = m_browserModel.GetAssetEntries();
m_hoveredAssetItemId =
hoveredAssetIndex < m_assetEntries.size()
? m_assetEntries[hoveredAssetIndex].itemId
hoveredAssetIndex < assetEntries.size()
? assetEntries[hoveredAssetIndex].itemId
: std::string();
break;
}
@@ -902,8 +630,9 @@ void ProductProjectPanel::Update(
break;
}
const auto& assetEntries = m_browserModel.GetAssetEntries();
const std::size_t hitIndex = HitTestAssetTile(event.position);
if (hitIndex >= m_assetEntries.size()) {
if (hitIndex >= assetEntries.size()) {
if (m_assetSelection.HasSelection()) {
m_assetSelection.ClearSelection();
EmitSelectionClearedEvent(EventSource::Background);
@@ -911,7 +640,7 @@ void ProductProjectPanel::Update(
break;
}
const AssetEntry& assetEntry = m_assetEntries[hitIndex];
const AssetEntry& assetEntry = assetEntries[hitIndex];
const bool alreadySelected = m_assetSelection.IsSelected(assetEntry.itemId);
const bool selectionChanged = m_assetSelection.SetSelection(assetEntry.itemId);
if (selectionChanged) {
@@ -945,13 +674,17 @@ void ProductProjectPanel::Update(
if (event.pointerButton == ::XCEngine::UI::UIPointerButton::Right &&
ContainsPoint(m_layout.gridRect, event.position)) {
const auto& assetEntries = m_browserModel.GetAssetEntries();
const std::size_t hitIndex = HitTestAssetTile(event.position);
if (hitIndex >= m_assetEntries.size()) {
EmitEvent(EventKind::ContextMenuRequested, EventSource::Background, static_cast<const AssetEntry*>(nullptr));
if (hitIndex >= assetEntries.size()) {
EmitEvent(
EventKind::ContextMenuRequested,
EventSource::Background,
static_cast<const AssetEntry*>(nullptr));
break;
}
const AssetEntry& assetEntry = m_assetEntries[hitIndex];
const AssetEntry& assetEntry = assetEntries[hitIndex];
if (!m_assetSelection.IsSelected(assetEntry.itemId)) {
m_assetSelection.SetSelection(assetEntry.itemId);
EmitEvent(EventKind::AssetSelected, EventSource::GridSecondary, &assetEntry);
@@ -1000,6 +733,8 @@ void ProductProjectPanel::Append(UIDrawList& drawList) const {
return;
}
const auto& assetEntries = m_browserModel.GetAssetEntries();
drawList.AddFilledRect(m_layout.bounds, kSurfaceColor);
drawList.AddFilledRect(m_layout.leftPaneRect, kPaneColor);
drawList.AddFilledRect(m_layout.rightPaneRect, kPaneColor);
@@ -1021,7 +756,7 @@ void ProductProjectPanel::Append(UIDrawList& drawList) const {
AppendUIEditorTreeViewBackground(
drawList,
m_treeFrame.layout,
m_treeItems,
m_browserModel.GetTreeItems(),
m_folderSelection,
m_treeInteractionState.treeViewState,
treePalette,
@@ -1029,7 +764,7 @@ void ProductProjectPanel::Append(UIDrawList& drawList) const {
AppendUIEditorTreeViewForeground(
drawList,
m_treeFrame.layout,
m_treeItems,
m_browserModel.GetTreeItems(),
treePalette,
treeMetrics);
@@ -1055,11 +790,11 @@ void ProductProjectPanel::Append(UIDrawList& drawList) const {
drawList.PopClipRect();
for (const AssetTileLayout& tile : m_layout.assetTiles) {
if (tile.itemIndex >= m_assetEntries.size()) {
if (tile.itemIndex >= assetEntries.size()) {
continue;
}
const AssetEntry& assetEntry = m_assetEntries[tile.itemIndex];
const AssetEntry& assetEntry = assetEntries[tile.itemIndex];
const bool selected = m_assetSelection.IsSelected(assetEntry.itemId);
const bool hovered = m_hoveredAssetItemId == assetEntry.itemId;
@@ -1084,7 +819,7 @@ void ProductProjectPanel::Append(UIDrawList& drawList) const {
drawList.PopClipRect();
}
if (m_assetEntries.empty()) {
if (assetEntries.empty()) {
const UIRect messageRect(
m_layout.gridRect.x,
m_layout.gridRect.y,