2026-04-15 22:47:42 +08:00
|
|
|
#include "ProjectPanelInternal.h"
|
|
|
|
|
|
|
|
|
|
#include <windows.h>
|
2026-04-11 20:20:30 +08:00
|
|
|
|
|
|
|
|
#include <utility>
|
|
|
|
|
|
|
|
|
|
namespace XCEngine::UI::Editor::App {
|
|
|
|
|
|
2026-04-15 22:47:42 +08:00
|
|
|
using namespace ProjectPanelInternal;
|
|
|
|
|
|
|
|
|
|
namespace {
|
|
|
|
|
|
|
|
|
|
UIEditorHostCommandEvaluationResult BuildEvaluationResult(
|
|
|
|
|
bool executable,
|
|
|
|
|
std::string message) {
|
|
|
|
|
UIEditorHostCommandEvaluationResult result = {};
|
|
|
|
|
result.executable = executable;
|
|
|
|
|
result.message = std::move(message);
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
UIEditorHostCommandDispatchResult BuildDispatchResult(
|
|
|
|
|
bool commandExecuted,
|
|
|
|
|
std::string message) {
|
|
|
|
|
UIEditorHostCommandDispatchResult result = {};
|
|
|
|
|
result.commandExecuted = commandExecuted;
|
|
|
|
|
result.message = std::move(message);
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
} // namespace
|
2026-04-11 20:20:30 +08:00
|
|
|
|
2026-04-15 08:24:06 +08:00
|
|
|
void ProjectPanel::Initialize(const std::filesystem::path& repoRoot) {
|
2026-04-12 11:12:27 +08:00
|
|
|
m_browserModel.Initialize(repoRoot);
|
2026-04-11 20:20:30 +08:00
|
|
|
SyncCurrentFolderSelection();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 08:24:06 +08:00
|
|
|
void ProjectPanel::SetBuiltInIcons(const BuiltInIcons* icons) {
|
2026-04-11 22:14:02 +08:00
|
|
|
m_icons = icons;
|
2026-04-12 11:12:27 +08:00
|
|
|
m_browserModel.SetFolderIcon(ResolveFolderIcon(m_icons));
|
|
|
|
|
SyncCurrentFolderSelection();
|
2026-04-11 22:14:02 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-15 08:24:06 +08:00
|
|
|
void ProjectPanel::SetTextMeasurer(const UIEditorTextMeasurer* textMeasurer) {
|
2026-04-11 20:20:30 +08:00
|
|
|
m_textMeasurer = textMeasurer;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 08:24:06 +08:00
|
|
|
void ProjectPanel::ResetInteractionState() {
|
2026-04-14 16:19:23 +08:00
|
|
|
m_treeInteractionState = {};
|
|
|
|
|
m_treeFrame = {};
|
|
|
|
|
m_frameEvents.clear();
|
|
|
|
|
m_layout = {};
|
|
|
|
|
m_hoveredAssetItemId.clear();
|
|
|
|
|
m_lastPrimaryClickedAssetId.clear();
|
|
|
|
|
m_hoveredBreadcrumbIndex = kInvalidLayoutIndex;
|
|
|
|
|
m_pressedBreadcrumbIndex = kInvalidLayoutIndex;
|
|
|
|
|
m_visible = false;
|
|
|
|
|
m_splitterHovered = false;
|
|
|
|
|
m_splitterDragging = false;
|
|
|
|
|
m_requestPointerCapture = false;
|
|
|
|
|
m_requestPointerRelease = false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 08:24:06 +08:00
|
|
|
ProjectPanel::CursorKind ProjectPanel::GetCursorKind() const {
|
2026-04-11 20:20:30 +08:00
|
|
|
return (m_splitterHovered || m_splitterDragging) ? CursorKind::ResizeEW : CursorKind::Arrow;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 08:24:06 +08:00
|
|
|
bool ProjectPanel::WantsHostPointerCapture() const {
|
2026-04-11 20:20:30 +08:00
|
|
|
return m_requestPointerCapture;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 08:24:06 +08:00
|
|
|
bool ProjectPanel::WantsHostPointerRelease() const {
|
2026-04-11 20:20:30 +08:00
|
|
|
return m_requestPointerRelease;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 08:24:06 +08:00
|
|
|
bool ProjectPanel::HasActivePointerCapture() const {
|
2026-04-11 20:20:30 +08:00
|
|
|
return m_splitterDragging;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 08:24:06 +08:00
|
|
|
const std::vector<ProjectPanel::Event>& ProjectPanel::GetFrameEvents() const {
|
2026-04-11 22:31:14 +08:00
|
|
|
return m_frameEvents;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 08:24:06 +08:00
|
|
|
const ProjectPanel::FolderEntry* ProjectPanel::FindFolderEntry(std::string_view itemId) const {
|
2026-04-12 11:12:27 +08:00
|
|
|
return m_browserModel.FindFolderEntry(itemId);
|
2026-04-11 20:20:30 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-15 08:24:06 +08:00
|
|
|
const ProjectPanel::AssetEntry* ProjectPanel::FindAssetEntry(std::string_view itemId) const {
|
2026-04-12 11:12:27 +08:00
|
|
|
return m_browserModel.FindAssetEntry(itemId);
|
2026-04-11 22:31:14 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-15 19:30:58 +08:00
|
|
|
std::optional<ProjectPanel::EditCommandTarget> ProjectPanel::ResolveEditCommandTarget() const {
|
|
|
|
|
if (m_assetSelection.HasSelection()) {
|
|
|
|
|
const AssetEntry* asset = FindAssetEntry(m_assetSelection.GetSelectedId());
|
|
|
|
|
if (asset != nullptr) {
|
|
|
|
|
EditCommandTarget target = {};
|
|
|
|
|
target.itemId = asset->itemId;
|
|
|
|
|
target.absolutePath = asset->absolutePath;
|
|
|
|
|
target.displayName = asset->displayName;
|
|
|
|
|
target.directory = asset->directory;
|
|
|
|
|
return target;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!m_folderSelection.HasSelection()) {
|
|
|
|
|
return std::nullopt;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const FolderEntry* folder = FindFolderEntry(m_folderSelection.GetSelectedId());
|
|
|
|
|
if (folder == nullptr) {
|
|
|
|
|
return std::nullopt;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
EditCommandTarget target = {};
|
|
|
|
|
target.itemId = folder->itemId;
|
|
|
|
|
target.absolutePath = folder->absolutePath;
|
|
|
|
|
target.displayName = folder->label;
|
|
|
|
|
target.directory = true;
|
|
|
|
|
target.assetsRoot = folder->absolutePath == m_browserModel.GetAssetsRootPath();
|
|
|
|
|
return target;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 08:24:06 +08:00
|
|
|
const UIEditorPanelContentHostPanelState* ProjectPanel::FindMountedProjectPanel(
|
2026-04-11 20:20:30 +08:00
|
|
|
const UIEditorPanelContentHostFrame& contentHostFrame) const {
|
|
|
|
|
for (const UIEditorPanelContentHostPanelState& panelState : contentHostFrame.panelStates) {
|
|
|
|
|
if (panelState.panelId == kProjectPanelId && panelState.mounted) {
|
|
|
|
|
return &panelState;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nullptr;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 08:24:06 +08:00
|
|
|
void ProjectPanel::SyncCurrentFolderSelection() {
|
2026-04-12 11:12:27 +08:00
|
|
|
const std::string& currentFolderId = m_browserModel.GetCurrentFolderId();
|
|
|
|
|
if (currentFolderId.empty()) {
|
|
|
|
|
m_folderSelection.ClearSelection();
|
2026-04-11 20:20:30 +08:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 11:12:27 +08:00
|
|
|
const std::vector<std::string> ancestorFolderIds =
|
|
|
|
|
m_browserModel.CollectCurrentFolderAncestorIds();
|
|
|
|
|
for (const std::string& ancestorFolderId : ancestorFolderIds) {
|
|
|
|
|
m_folderExpansion.Expand(ancestorFolderId);
|
2026-04-11 20:20:30 +08:00
|
|
|
}
|
2026-04-12 11:12:27 +08:00
|
|
|
m_folderSelection.SetSelection(currentFolderId);
|
2026-04-11 20:20:30 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-15 08:24:06 +08:00
|
|
|
bool ProjectPanel::NavigateToFolder(std::string_view itemId, EventSource source) {
|
2026-04-12 11:12:27 +08:00
|
|
|
if (!m_browserModel.NavigateToFolder(itemId)) {
|
2026-04-11 22:31:14 +08:00
|
|
|
return false;
|
2026-04-11 20:20:30 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
SyncCurrentFolderSelection();
|
|
|
|
|
m_assetSelection.ClearSelection();
|
|
|
|
|
m_hoveredAssetItemId.clear();
|
|
|
|
|
m_lastPrimaryClickedAssetId.clear();
|
2026-04-12 11:12:27 +08:00
|
|
|
EmitEvent(EventKind::FolderNavigated, source, FindFolderEntry(m_browserModel.GetCurrentFolderId()));
|
2026-04-11 22:31:14 +08:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 08:24:06 +08:00
|
|
|
void ProjectPanel::EmitEvent(
|
2026-04-11 22:31:14 +08:00
|
|
|
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;
|
2026-04-12 11:12:27 +08:00
|
|
|
event.displayName = folder->label;
|
2026-04-11 22:31:14 +08:00
|
|
|
event.directory = true;
|
|
|
|
|
m_frameEvents.push_back(std::move(event));
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 08:24:06 +08:00
|
|
|
void ProjectPanel::EmitEvent(
|
2026-04-11 22:31:14 +08:00
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 08:24:06 +08:00
|
|
|
void ProjectPanel::EmitSelectionClearedEvent(EventSource source) {
|
2026-04-11 22:31:14 +08:00
|
|
|
Event event = {};
|
|
|
|
|
event.kind = EventKind::AssetSelectionCleared;
|
|
|
|
|
event.source = source;
|
|
|
|
|
m_frameEvents.push_back(std::move(event));
|
2026-04-11 20:20:30 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-15 22:47:42 +08:00
|
|
|
UIEditorHostCommandEvaluationResult ProjectPanel::EvaluateEditCommand(
|
|
|
|
|
std::string_view commandId) const {
|
|
|
|
|
const std::optional<EditCommandTarget> target = ResolveEditCommandTarget();
|
|
|
|
|
if (!target.has_value()) {
|
|
|
|
|
return BuildEvaluationResult(false, "Select an asset or folder in Project first.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (target->assetsRoot &&
|
|
|
|
|
(commandId == "edit.rename" || commandId == "edit.delete")) {
|
|
|
|
|
return BuildEvaluationResult(false, "The Assets root cannot be renamed or deleted.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (commandId == "edit.rename") {
|
|
|
|
|
return BuildEvaluationResult(
|
|
|
|
|
true,
|
|
|
|
|
"Rename project item '" + target->displayName + "'.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (commandId == "edit.delete") {
|
|
|
|
|
return BuildEvaluationResult(
|
|
|
|
|
false,
|
|
|
|
|
"Project delete is blocked until asset metadata ownership is wired.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (commandId == "edit.duplicate") {
|
|
|
|
|
return BuildEvaluationResult(
|
|
|
|
|
false,
|
|
|
|
|
"Project duplicate is blocked until asset metadata rewrite is wired.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (commandId == "edit.cut" ||
|
|
|
|
|
commandId == "edit.copy" ||
|
|
|
|
|
commandId == "edit.paste") {
|
|
|
|
|
return BuildEvaluationResult(
|
|
|
|
|
false,
|
|
|
|
|
"Project clipboard has no bound asset transfer owner in the current shell.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return BuildEvaluationResult(false, "Project does not expose this edit command.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
UIEditorHostCommandDispatchResult ProjectPanel::DispatchEditCommand(
|
|
|
|
|
std::string_view commandId) {
|
|
|
|
|
const UIEditorHostCommandEvaluationResult evaluation = EvaluateEditCommand(commandId);
|
|
|
|
|
if (!evaluation.executable) {
|
|
|
|
|
return BuildDispatchResult(false, evaluation.message);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (commandId == "edit.rename") {
|
|
|
|
|
if (m_assetSelection.HasSelection()) {
|
|
|
|
|
if (const AssetEntry* asset = FindAssetEntry(m_assetSelection.GetSelectedId());
|
|
|
|
|
asset != nullptr) {
|
|
|
|
|
EmitEvent(EventKind::RenameRequested, EventSource::None, asset);
|
|
|
|
|
return BuildDispatchResult(
|
|
|
|
|
true,
|
|
|
|
|
"Project rename requested for '" + asset->displayName + "'.");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (m_folderSelection.HasSelection()) {
|
|
|
|
|
if (const FolderEntry* folder = FindFolderEntry(m_folderSelection.GetSelectedId());
|
|
|
|
|
folder != nullptr) {
|
|
|
|
|
EmitEvent(EventKind::RenameRequested, EventSource::None, folder);
|
|
|
|
|
return BuildDispatchResult(
|
|
|
|
|
true,
|
|
|
|
|
"Project rename requested for '" + folder->label + "'.");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return BuildDispatchResult(false, "Select an asset or folder in Project first.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return BuildDispatchResult(false, "Project does not expose this edit command.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ProjectPanel::ResetTransientFrames() {
|
|
|
|
|
m_treeFrame = {};
|
|
|
|
|
m_frameEvents.clear();
|
|
|
|
|
m_layout = {};
|
|
|
|
|
m_hoveredAssetItemId.clear();
|
|
|
|
|
m_hoveredBreadcrumbIndex = kInvalidLayoutIndex;
|
|
|
|
|
m_pressedBreadcrumbIndex = kInvalidLayoutIndex;
|
|
|
|
|
m_splitterHovered = false;
|
|
|
|
|
m_splitterDragging = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ProjectPanel::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_browserModel.GetTreeItems().empty()) {
|
|
|
|
|
m_browserModel.Refresh();
|
|
|
|
|
SyncCurrentFolderSelection();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 = ResolveUIEditorTreeViewMetrics();
|
|
|
|
|
const std::vector<UIInputEvent> treeEvents =
|
|
|
|
|
FilterTreeInputEvents(filteredEvents, m_splitterDragging);
|
|
|
|
|
m_treeFrame = UpdateUIEditorTreeViewInteraction(
|
|
|
|
|
m_treeInteractionState,
|
|
|
|
|
m_folderSelection,
|
|
|
|
|
m_folderExpansion,
|
|
|
|
|
m_layout.treeRect,
|
|
|
|
|
m_browserModel.GetTreeItems(),
|
|
|
|
|
treeEvents,
|
|
|
|
|
treeMetrics);
|
|
|
|
|
|
|
|
|
|
if (m_treeFrame.result.selectionChanged &&
|
|
|
|
|
!m_treeFrame.result.selectedItemId.empty() &&
|
|
|
|
|
m_treeFrame.result.selectedItemId != m_browserModel.GetCurrentFolderId()) {
|
|
|
|
|
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);
|
|
|
|
|
const auto& assetEntries = m_browserModel.GetAssetEntries();
|
|
|
|
|
m_hoveredAssetItemId =
|
|
|
|
|
hoveredAssetIndex < assetEntries.size()
|
|
|
|
|
? 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 auto& assetEntries = m_browserModel.GetAssetEntries();
|
|
|
|
|
const std::size_t hitIndex = HitTestAssetTile(event.position);
|
|
|
|
|
if (hitIndex >= assetEntries.size()) {
|
|
|
|
|
if (m_assetSelection.HasSelection()) {
|
|
|
|
|
m_assetSelection.ClearSelection();
|
|
|
|
|
EmitSelectionClearedEvent(EventSource::Background);
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const AssetEntry& assetEntry = 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 auto& assetEntries = m_browserModel.GetAssetEntries();
|
|
|
|
|
const std::size_t hitIndex = HitTestAssetTile(event.position);
|
|
|
|
|
if (hitIndex >= assetEntries.size()) {
|
|
|
|
|
EmitEvent(
|
|
|
|
|
EventKind::ContextMenuRequested,
|
|
|
|
|
EventSource::Background,
|
|
|
|
|
static_cast<const AssetEntry*>(nullptr));
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const AssetEntry& assetEntry = 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ProjectPanel::Layout ProjectPanel::BuildLayout(const UIRect& bounds) const {
|
|
|
|
|
Layout layout = {};
|
|
|
|
|
const auto& assetEntries = m_browserModel.GetAssetEntries();
|
|
|
|
|
const std::vector<ProjectBrowserModel::BreadcrumbSegment> breadcrumbSegments =
|
|
|
|
|
m_browserModel.BuildBreadcrumbSegments();
|
|
|
|
|
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;
|
|
|
|
|
for (std::size_t index = 0u; index < breadcrumbSegments.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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ProjectBrowserModel::BreadcrumbSegment& segment = breadcrumbSegments[index];
|
|
|
|
|
const float labelWidth = MeasureTextWidth(m_textMeasurer, segment.label, kHeaderFontSize);
|
|
|
|
|
const float itemWidth = labelWidth + kBreadcrumbItemPaddingX * 2.0f;
|
|
|
|
|
const float availableWidth = headerRight - nextItemX;
|
|
|
|
|
if (availableWidth <= 0.0f) {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
layout.breadcrumbItems.push_back({
|
|
|
|
|
segment.label,
|
|
|
|
|
segment.targetFolderId,
|
|
|
|
|
UIRect(
|
|
|
|
|
nextItemX,
|
|
|
|
|
breadcrumbY,
|
|
|
|
|
ClampNonNegative((std::min)(itemWidth, availableWidth)),
|
|
|
|
|
breadcrumbRowHeight),
|
|
|
|
|
false,
|
|
|
|
|
!segment.current,
|
|
|
|
|
segment.current
|
|
|
|
|
});
|
|
|
|
|
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(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);
|
|
|
|
|
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 ProjectPanel::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 ProjectPanel::HitTestAssetTile(const UIPoint& point) const {
|
|
|
|
|
for (const AssetTileLayout& tile : m_layout.assetTiles) {
|
|
|
|
|
if (ContainsPoint(tile.tileRect, point)) {
|
|
|
|
|
return tile.itemIndex;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return kInvalidLayoutIndex;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ProjectPanel::Append(UIDrawList& drawList) const {
|
|
|
|
|
if (!m_visible || m_layout.bounds.width <= 0.0f || m_layout.bounds.height <= 0.0f) {
|
|
|
|
|
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);
|
|
|
|
|
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 = ResolveUIEditorTreeViewPalette();
|
|
|
|
|
const Widgets::UIEditorTreeViewMetrics treeMetrics = ResolveUIEditorTreeViewMetrics();
|
|
|
|
|
AppendUIEditorTreeViewBackground(
|
|
|
|
|
drawList,
|
|
|
|
|
m_treeFrame.layout,
|
|
|
|
|
m_browserModel.GetTreeItems(),
|
|
|
|
|
m_folderSelection,
|
|
|
|
|
m_treeInteractionState.treeViewState,
|
|
|
|
|
treePalette,
|
|
|
|
|
treeMetrics);
|
|
|
|
|
AppendUIEditorTreeViewForeground(
|
|
|
|
|
drawList,
|
|
|
|
|
m_treeFrame.layout,
|
|
|
|
|
m_browserModel.GetTreeItems(),
|
|
|
|
|
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 >= assetEntries.size()) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const AssetEntry& assetEntry = 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 (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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 20:20:30 +08:00
|
|
|
} // namespace XCEngine::UI::Editor::App
|