2217 lines
77 KiB
C++
2217 lines
77 KiB
C++
#include "ProjectPanelInternal.h"
|
|
|
|
#include "Project/EditorProjectRuntime.h"
|
|
#include "State/EditorCommandFocusService.h"
|
|
|
|
#include <XCEditor/Collections/UIEditorTreePanelBehavior.h>
|
|
#include <XCEditor/Fields/UIEditorFieldStyle.h>
|
|
#include <XCEditor/Foundation/UIEditorPanelInputFilter.h>
|
|
#include <XCEditor/Fields/UIEditorTextField.h>
|
|
|
|
#include "Internal/StringEncoding.h"
|
|
|
|
#include <XCEngine/UI/Widgets/UIPopupOverlayModel.h>
|
|
|
|
#include <windows.h>
|
|
#include <shellapi.h>
|
|
|
|
#include <cstring>
|
|
#include <functional>
|
|
#include <memory>
|
|
#include <utility>
|
|
|
|
namespace XCEngine::UI::Editor::App {
|
|
|
|
using namespace ProjectPanelInternal;
|
|
namespace GridDrag = XCEngine::UI::Editor::Collections::GridDragDrop;
|
|
namespace TreeDrag = XCEngine::UI::Editor::Collections::TreeDragDrop;
|
|
|
|
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;
|
|
}
|
|
|
|
bool HasValidBounds(const UIRect& bounds) {
|
|
return bounds.width > 0.0f && bounds.height > 0.0f;
|
|
}
|
|
|
|
bool CopyTextToClipboard(std::string_view text) {
|
|
if (text.empty()) {
|
|
return false;
|
|
}
|
|
|
|
const std::wstring wideText =
|
|
App::Internal::Utf8ToWide(std::string(text));
|
|
const std::size_t byteCount =
|
|
(wideText.size() + 1u) * sizeof(wchar_t);
|
|
if (!OpenClipboard(nullptr)) {
|
|
return false;
|
|
}
|
|
|
|
struct ClipboardCloser final {
|
|
~ClipboardCloser() {
|
|
CloseClipboard();
|
|
}
|
|
} clipboardCloser = {};
|
|
|
|
if (!EmptyClipboard()) {
|
|
return false;
|
|
}
|
|
|
|
HGLOBAL handle = GlobalAlloc(GMEM_MOVEABLE, byteCount);
|
|
if (handle == nullptr) {
|
|
return false;
|
|
}
|
|
|
|
void* locked = GlobalLock(handle);
|
|
if (locked == nullptr) {
|
|
GlobalFree(handle);
|
|
return false;
|
|
}
|
|
|
|
std::memcpy(locked, wideText.c_str(), byteCount);
|
|
GlobalUnlock(handle);
|
|
|
|
if (SetClipboardData(CF_UNICODETEXT, handle) == nullptr) {
|
|
GlobalFree(handle);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
bool ShowPathInExplorer(
|
|
const std::filesystem::path& path,
|
|
bool selectTarget) {
|
|
if (path.empty()) {
|
|
return false;
|
|
}
|
|
|
|
namespace fs = std::filesystem;
|
|
|
|
std::error_code errorCode = {};
|
|
const fs::path targetPath = path.lexically_normal();
|
|
if (!fs::exists(targetPath, errorCode) || errorCode) {
|
|
return false;
|
|
}
|
|
|
|
HINSTANCE result = nullptr;
|
|
if (selectTarget) {
|
|
const std::wstring parameters =
|
|
L"/select,\"" + targetPath.native() + L"\"";
|
|
const std::wstring workingDirectory =
|
|
targetPath.parent_path().native();
|
|
result = ShellExecuteW(
|
|
nullptr,
|
|
L"open",
|
|
L"explorer.exe",
|
|
parameters.c_str(),
|
|
workingDirectory.empty() ? nullptr : workingDirectory.c_str(),
|
|
SW_SHOWNORMAL);
|
|
} else {
|
|
const std::wstring workingDirectory =
|
|
targetPath.parent_path().native();
|
|
result = ShellExecuteW(
|
|
nullptr,
|
|
L"open",
|
|
targetPath.c_str(),
|
|
nullptr,
|
|
workingDirectory.empty() ? nullptr : workingDirectory.c_str(),
|
|
SW_SHOWNORMAL);
|
|
}
|
|
|
|
return reinterpret_cast<INT_PTR>(result) > 32;
|
|
}
|
|
|
|
Widgets::UIEditorMenuPopupItem BuildContextMenuCommandItem(
|
|
std::string itemId,
|
|
std::string label,
|
|
bool enabled = true) {
|
|
Widgets::UIEditorMenuPopupItem item = {};
|
|
item.itemId = std::move(itemId);
|
|
item.kind = UIEditorMenuItemKind::Command;
|
|
item.label = std::move(label);
|
|
item.enabled = enabled;
|
|
return item;
|
|
}
|
|
|
|
Widgets::UIEditorMenuPopupItem BuildContextMenuSeparatorItem(std::string itemId) {
|
|
Widgets::UIEditorMenuPopupItem item = {};
|
|
item.itemId = std::move(itemId);
|
|
item.kind = UIEditorMenuItemKind::Separator;
|
|
item.enabled = false;
|
|
return item;
|
|
}
|
|
|
|
void AppendContextMenuSeparator(
|
|
std::vector<Widgets::UIEditorMenuPopupItem>& items,
|
|
std::string itemId) {
|
|
if (items.empty() || items.back().kind == UIEditorMenuItemKind::Separator) {
|
|
return;
|
|
}
|
|
|
|
items.push_back(BuildContextMenuSeparatorItem(std::move(itemId)));
|
|
}
|
|
|
|
} // namespace
|
|
|
|
EditorProjectRuntime* ProjectPanel::ResolveProjectRuntime() {
|
|
return m_projectRuntime != nullptr
|
|
? m_projectRuntime
|
|
: m_ownedProjectRuntime.get();
|
|
}
|
|
|
|
const EditorProjectRuntime* ProjectPanel::ResolveProjectRuntime() const {
|
|
return m_projectRuntime != nullptr
|
|
? m_projectRuntime
|
|
: m_ownedProjectRuntime.get();
|
|
}
|
|
|
|
bool ProjectPanel::HasProjectRuntime() const {
|
|
return ResolveProjectRuntime() != nullptr;
|
|
}
|
|
|
|
ProjectPanel::BrowserModel& ProjectPanel::GetBrowserModel() {
|
|
return ResolveProjectRuntime()->GetBrowserModel();
|
|
}
|
|
|
|
const ProjectPanel::BrowserModel& ProjectPanel::GetBrowserModel() const {
|
|
return ResolveProjectRuntime()->GetBrowserModel();
|
|
}
|
|
|
|
void ProjectPanel::Initialize(const std::filesystem::path& repoRoot) {
|
|
m_ownedProjectRuntime = std::make_unique<EditorProjectRuntime>();
|
|
m_ownedProjectRuntime->Initialize(repoRoot);
|
|
if (m_icons != nullptr) {
|
|
m_ownedProjectRuntime->SetFolderIcon(ResolveFolderIcon(m_icons));
|
|
}
|
|
SyncCurrentFolderSelection();
|
|
SyncAssetSelectionFromRuntime();
|
|
}
|
|
|
|
void ProjectPanel::SetProjectRuntime(EditorProjectRuntime* projectRuntime) {
|
|
m_projectRuntime = projectRuntime;
|
|
if (m_projectRuntime != nullptr && m_icons != nullptr) {
|
|
m_projectRuntime->SetFolderIcon(ResolveFolderIcon(m_icons));
|
|
}
|
|
SyncCurrentFolderSelection();
|
|
SyncAssetSelectionFromRuntime();
|
|
}
|
|
|
|
void ProjectPanel::SetCommandFocusService(
|
|
EditorCommandFocusService* commandFocusService) {
|
|
m_commandFocusService = commandFocusService;
|
|
}
|
|
|
|
void ProjectPanel::SetBuiltInIcons(const BuiltInIcons* icons) {
|
|
m_icons = icons;
|
|
if (EditorProjectRuntime* runtime = ResolveProjectRuntime();
|
|
runtime != nullptr) {
|
|
runtime->SetFolderIcon(ResolveFolderIcon(m_icons));
|
|
}
|
|
}
|
|
|
|
void ProjectPanel::SetTextMeasurer(const UIEditorTextMeasurer* textMeasurer) {
|
|
m_textMeasurer = textMeasurer;
|
|
}
|
|
|
|
void ProjectPanel::ResetInteractionState() {
|
|
m_assetDragState = {};
|
|
m_treeDragState = {};
|
|
m_treeInteractionState = {};
|
|
m_treeFrame = {};
|
|
m_contextMenu = {};
|
|
ClearRenameState();
|
|
m_frameEvents.clear();
|
|
m_layout = {};
|
|
m_hoveredAssetItemId.clear();
|
|
m_lastPrimaryClickedAssetId.clear();
|
|
m_hoveredBreadcrumbIndex = kInvalidLayoutIndex;
|
|
m_pressedBreadcrumbIndex = kInvalidLayoutIndex;
|
|
m_assetDropTargetSurface = DropTargetSurface::None;
|
|
m_visible = false;
|
|
m_splitterHovered = false;
|
|
m_splitterDragging = false;
|
|
m_requestPointerCapture = false;
|
|
m_requestPointerRelease = false;
|
|
}
|
|
|
|
ProjectPanel::CursorKind ProjectPanel::GetCursorKind() const {
|
|
return (m_splitterHovered || m_splitterDragging) ? CursorKind::ResizeEW : CursorKind::Arrow;
|
|
}
|
|
|
|
bool ProjectPanel::WantsHostPointerCapture() const {
|
|
return m_requestPointerCapture ||
|
|
m_assetDragState.requestPointerCapture ||
|
|
m_treeDragState.requestPointerCapture;
|
|
}
|
|
|
|
bool ProjectPanel::WantsHostPointerRelease() const {
|
|
return m_requestPointerRelease ||
|
|
m_assetDragState.requestPointerRelease ||
|
|
m_treeDragState.requestPointerRelease;
|
|
}
|
|
|
|
bool ProjectPanel::HasActivePointerCapture() const {
|
|
return m_splitterDragging ||
|
|
GridDrag::HasActivePointerCapture(m_assetDragState) ||
|
|
TreeDrag::HasActivePointerCapture(m_treeDragState);
|
|
}
|
|
|
|
const std::vector<ProjectPanel::Event>& ProjectPanel::GetFrameEvents() const {
|
|
return m_frameEvents;
|
|
}
|
|
|
|
const ProjectPanel::FolderEntry* ProjectPanel::FindFolderEntry(std::string_view itemId) const {
|
|
const EditorProjectRuntime* runtime = ResolveProjectRuntime();
|
|
return runtime != nullptr
|
|
? runtime->FindFolderEntry(itemId)
|
|
: nullptr;
|
|
}
|
|
|
|
const ProjectPanel::AssetEntry* ProjectPanel::FindAssetEntry(std::string_view itemId) const {
|
|
const EditorProjectRuntime* runtime = ResolveProjectRuntime();
|
|
return runtime != nullptr
|
|
? runtime->FindAssetEntry(itemId)
|
|
: nullptr;
|
|
}
|
|
|
|
ProjectPanel::AssetCommandTarget ProjectPanel::ResolveAssetCommandTarget(
|
|
std::string_view explicitItemId,
|
|
bool forceCurrentFolder) const {
|
|
if (!HasProjectRuntime()) {
|
|
return {};
|
|
}
|
|
|
|
return ResolveProjectRuntime()->ResolveAssetCommandTarget(
|
|
explicitItemId,
|
|
forceCurrentFolder);
|
|
}
|
|
|
|
const ProjectPanel::AssetEntry* ProjectPanel::GetSelectedAssetEntry() const {
|
|
const EditorProjectRuntime* runtime = ResolveProjectRuntime();
|
|
return runtime != nullptr && runtime->HasSelection()
|
|
? runtime->FindAssetEntry(runtime->GetSelection().itemId)
|
|
: nullptr;
|
|
}
|
|
|
|
const ProjectPanel::FolderEntry* ProjectPanel::GetSelectedFolderEntry() const {
|
|
if (!HasProjectRuntime()) {
|
|
return nullptr;
|
|
}
|
|
|
|
return FindFolderEntry(GetBrowserModel().GetCurrentFolderId());
|
|
}
|
|
|
|
void ProjectPanel::ClearRenameState() {
|
|
m_renameState = {};
|
|
m_renameFrame = {};
|
|
m_pendingRenameItemId.clear();
|
|
m_pendingRenameSurface = RenameSurface::None;
|
|
m_activeRenameSurface = RenameSurface::None;
|
|
}
|
|
|
|
void ProjectPanel::QueueRenameSession(
|
|
std::string_view itemId,
|
|
RenameSurface surface) {
|
|
if (itemId.empty() || surface == RenameSurface::None) {
|
|
return;
|
|
}
|
|
|
|
if (surface == RenameSurface::Tree &&
|
|
FindFolderEntry(itemId) == nullptr) {
|
|
return;
|
|
}
|
|
|
|
if (surface == RenameSurface::Grid &&
|
|
FindAssetEntry(itemId) == nullptr) {
|
|
return;
|
|
}
|
|
|
|
if (m_renameState.active &&
|
|
m_renameState.itemId == itemId &&
|
|
m_activeRenameSurface == surface) {
|
|
return;
|
|
}
|
|
|
|
m_pendingRenameItemId = std::string(itemId);
|
|
m_pendingRenameSurface = surface;
|
|
}
|
|
|
|
UIRect ProjectPanel::BuildRenameBounds(
|
|
std::string_view itemId,
|
|
RenameSurface surface) const {
|
|
if (itemId.empty() || surface == RenameSurface::None) {
|
|
return {};
|
|
}
|
|
|
|
const Widgets::UIEditorTextFieldMetrics hostedMetrics =
|
|
BuildUIEditorPropertyGridTextFieldMetrics(
|
|
ResolveUIEditorPropertyGridMetrics(),
|
|
ResolveUIEditorTextFieldMetrics());
|
|
|
|
if (surface == RenameSurface::Tree) {
|
|
return BuildUIEditorTreePanelInlineRenameBounds(
|
|
m_treeFrame.layout,
|
|
GetBrowserModel().GetTreeItems(),
|
|
itemId,
|
|
hostedMetrics);
|
|
}
|
|
|
|
if (surface == RenameSurface::Grid) {
|
|
for (const AssetTileLayout& tile : m_layout.assetTiles) {
|
|
if (tile.itemIndex >= GetBrowserModel().GetAssetEntries().size()) {
|
|
continue;
|
|
}
|
|
|
|
const AssetEntry& assetEntry =
|
|
GetBrowserModel().GetAssetEntries()[tile.itemIndex];
|
|
if (assetEntry.itemId != itemId) {
|
|
continue;
|
|
}
|
|
|
|
const float x = (std::max)(
|
|
tile.tileRect.x + 4.0f,
|
|
tile.labelRect.x - hostedMetrics.valueTextInsetX);
|
|
const float right = tile.tileRect.x + tile.tileRect.width - 4.0f;
|
|
const float width = (std::max)(72.0f, right - x);
|
|
return UIRect(x, tile.labelRect.y - 2.0f, width, tile.labelRect.height + 4.0f);
|
|
}
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
bool ProjectPanel::TryStartQueuedRenameSession() {
|
|
if (m_pendingRenameItemId.empty() ||
|
|
m_pendingRenameSurface == RenameSurface::None) {
|
|
return false;
|
|
}
|
|
|
|
std::string initialText = {};
|
|
if (m_pendingRenameSurface == RenameSurface::Grid) {
|
|
const AssetEntry* asset = FindAssetEntry(m_pendingRenameItemId);
|
|
if (asset == nullptr) {
|
|
m_pendingRenameItemId.clear();
|
|
m_pendingRenameSurface = RenameSurface::None;
|
|
return false;
|
|
}
|
|
initialText = asset->displayName;
|
|
} else {
|
|
const FolderEntry* folder = FindFolderEntry(m_pendingRenameItemId);
|
|
if (folder == nullptr) {
|
|
m_pendingRenameItemId.clear();
|
|
m_pendingRenameSurface = RenameSurface::None;
|
|
return false;
|
|
}
|
|
initialText = folder->label;
|
|
}
|
|
|
|
const UIRect bounds =
|
|
BuildRenameBounds(m_pendingRenameItemId, m_pendingRenameSurface);
|
|
if (!HasValidBounds(bounds)) {
|
|
return false;
|
|
}
|
|
|
|
const Widgets::UIEditorTextFieldMetrics textFieldMetrics =
|
|
BuildUIEditorInlineRenameTextFieldMetrics(
|
|
bounds,
|
|
BuildUIEditorPropertyGridTextFieldMetrics(
|
|
ResolveUIEditorPropertyGridMetrics(),
|
|
ResolveUIEditorTextFieldMetrics()));
|
|
|
|
UIEditorInlineRenameSessionRequest request = {};
|
|
request.beginSession = true;
|
|
request.itemId = m_pendingRenameItemId;
|
|
request.initialText = initialText;
|
|
request.bounds = bounds;
|
|
m_renameFrame = UpdateUIEditorInlineRenameSession(
|
|
m_renameState,
|
|
request,
|
|
{},
|
|
textFieldMetrics);
|
|
if (!m_renameFrame.result.sessionStarted) {
|
|
return false;
|
|
}
|
|
|
|
m_activeRenameSurface = m_pendingRenameSurface;
|
|
m_pendingRenameItemId.clear();
|
|
m_pendingRenameSurface = RenameSurface::None;
|
|
return true;
|
|
}
|
|
|
|
void ProjectPanel::UpdateRenameSession(
|
|
const std::vector<UIInputEvent>& inputEvents) {
|
|
if (!m_renameState.active || m_activeRenameSurface == RenameSurface::None) {
|
|
return;
|
|
}
|
|
|
|
const UIRect bounds = BuildRenameBounds(m_renameState.itemId, m_activeRenameSurface);
|
|
if (!HasValidBounds(bounds)) {
|
|
ClearRenameState();
|
|
return;
|
|
}
|
|
|
|
const Widgets::UIEditorTextFieldMetrics textFieldMetrics =
|
|
BuildUIEditorInlineRenameTextFieldMetrics(
|
|
bounds,
|
|
BuildUIEditorPropertyGridTextFieldMetrics(
|
|
ResolveUIEditorPropertyGridMetrics(),
|
|
ResolveUIEditorTextFieldMetrics()));
|
|
|
|
UIEditorInlineRenameSessionRequest request = {};
|
|
request.itemId = m_renameState.itemId;
|
|
request.initialText = m_renameState.textFieldSpec.value;
|
|
request.bounds = bounds;
|
|
m_renameFrame = UpdateUIEditorInlineRenameSession(
|
|
m_renameState,
|
|
request,
|
|
inputEvents,
|
|
textFieldMetrics);
|
|
if (!m_renameFrame.result.sessionCommitted) {
|
|
if (m_renameFrame.result.sessionCanceled) {
|
|
m_activeRenameSurface = RenameSurface::None;
|
|
}
|
|
return;
|
|
}
|
|
|
|
RenameSurface committedSurface = m_activeRenameSurface;
|
|
m_activeRenameSurface = RenameSurface::None;
|
|
|
|
std::string renamedItemId = {};
|
|
if (m_renameFrame.result.valueChanged &&
|
|
!ResolveProjectRuntime()->RenameItem(
|
|
m_renameFrame.result.itemId,
|
|
m_renameFrame.result.valueAfter,
|
|
&renamedItemId)) {
|
|
return;
|
|
}
|
|
|
|
if (renamedItemId.empty()) {
|
|
renamedItemId = m_renameFrame.result.itemId;
|
|
}
|
|
|
|
SyncCurrentFolderSelection();
|
|
SyncAssetSelectionFromRuntime();
|
|
m_hoveredAssetItemId.clear();
|
|
m_lastPrimaryClickedAssetId = renamedItemId;
|
|
if (committedSurface == RenameSurface::Grid) {
|
|
if (FindAssetEntry(renamedItemId) != nullptr) {
|
|
EmitEvent(EventKind::AssetSelected, EventSource::GridPrimary, FindAssetEntry(renamedItemId));
|
|
} else if (m_assetSelection.HasSelection()) {
|
|
m_assetSelection.ClearSelection();
|
|
EmitSelectionClearedEvent(EventSource::GridPrimary);
|
|
}
|
|
} else if (committedSurface == RenameSurface::Tree) {
|
|
m_assetSelection.ClearSelection();
|
|
EmitEvent(
|
|
EventKind::FolderNavigated,
|
|
EventSource::Tree,
|
|
FindFolderEntry(GetBrowserModel().GetCurrentFolderId()));
|
|
}
|
|
}
|
|
|
|
std::optional<ProjectPanel::EditCommandTarget> ProjectPanel::ResolveEditCommandTarget(
|
|
std::string_view explicitItemId,
|
|
bool forceCurrentFolder) const {
|
|
if (!HasProjectRuntime()) {
|
|
return std::nullopt;
|
|
}
|
|
|
|
return ResolveProjectRuntime()->ResolveEditCommandTarget(
|
|
explicitItemId,
|
|
forceCurrentFolder);
|
|
}
|
|
|
|
const UIEditorPanelContentHostPanelState* ProjectPanel::FindMountedProjectPanel(
|
|
const UIEditorPanelContentHostFrame& contentHostFrame) const {
|
|
for (const UIEditorPanelContentHostPanelState& panelState : contentHostFrame.panelStates) {
|
|
if (panelState.panelId == kProjectPanelId && panelState.mounted) {
|
|
return &panelState;
|
|
}
|
|
}
|
|
|
|
return nullptr;
|
|
}
|
|
|
|
void ProjectPanel::SyncCurrentFolderSelection() {
|
|
if (!HasProjectRuntime()) {
|
|
m_folderSelection.ClearSelection();
|
|
return;
|
|
}
|
|
|
|
const std::string& currentFolderId = GetBrowserModel().GetCurrentFolderId();
|
|
if (currentFolderId.empty()) {
|
|
m_folderSelection.ClearSelection();
|
|
return;
|
|
}
|
|
|
|
const std::vector<std::string> ancestorFolderIds =
|
|
GetBrowserModel().CollectCurrentFolderAncestorIds();
|
|
for (const std::string& ancestorFolderId : ancestorFolderIds) {
|
|
m_folderExpansion.Expand(ancestorFolderId);
|
|
}
|
|
m_folderSelection.SetSelection(currentFolderId);
|
|
}
|
|
|
|
void ProjectPanel::SyncAssetSelectionFromRuntime() {
|
|
const EditorProjectRuntime* runtime = ResolveProjectRuntime();
|
|
if (runtime == nullptr || !runtime->HasSelection()) {
|
|
m_assetSelection.ClearSelection();
|
|
return;
|
|
}
|
|
|
|
if (FindAssetEntry(runtime->GetSelection().itemId) != nullptr) {
|
|
m_assetSelection.SetSelection(runtime->GetSelection().itemId);
|
|
return;
|
|
}
|
|
|
|
m_assetSelection.ClearSelection();
|
|
}
|
|
|
|
bool ProjectPanel::NavigateToFolder(std::string_view itemId, EventSource source) {
|
|
if (!ResolveProjectRuntime()->NavigateToFolder(itemId)) {
|
|
return false;
|
|
}
|
|
|
|
SyncCurrentFolderSelection();
|
|
SyncAssetSelectionFromRuntime();
|
|
m_hoveredAssetItemId.clear();
|
|
m_lastPrimaryClickedAssetId.clear();
|
|
EmitEvent(
|
|
EventKind::FolderNavigated,
|
|
source,
|
|
FindFolderEntry(GetBrowserModel().GetCurrentFolderId()));
|
|
return true;
|
|
}
|
|
|
|
bool ProjectPanel::OpenProjectItem(std::string_view itemId, EventSource source) {
|
|
const AssetEntry* asset = FindAssetEntry(itemId);
|
|
if (asset == nullptr) {
|
|
return false;
|
|
}
|
|
|
|
if (asset->directory) {
|
|
const bool navigated = ResolveProjectRuntime()->OpenItem(asset->itemId);
|
|
if (navigated && HasValidBounds(m_layout.bounds)) {
|
|
SyncCurrentFolderSelection();
|
|
SyncAssetSelectionFromRuntime();
|
|
m_layout = BuildLayout(m_layout.bounds);
|
|
m_hoveredAssetItemId.clear();
|
|
EmitEvent(
|
|
EventKind::FolderNavigated,
|
|
source,
|
|
FindFolderEntry(GetBrowserModel().GetCurrentFolderId()));
|
|
}
|
|
return navigated;
|
|
}
|
|
|
|
if (!ResolveProjectRuntime()->OpenItem(asset->itemId)) {
|
|
return false;
|
|
}
|
|
|
|
EmitEvent(EventKind::AssetOpened, source, asset);
|
|
return true;
|
|
}
|
|
|
|
void ProjectPanel::OpenContextMenu(
|
|
const UIPoint& anchorPosition,
|
|
std::string_view targetItemId,
|
|
bool forceCurrentFolder) {
|
|
CloseContextMenu();
|
|
ClearRenameState();
|
|
|
|
m_contextMenu.open = true;
|
|
m_contextMenu.forceCurrentFolder = forceCurrentFolder;
|
|
m_contextMenu.anchorPosition = anchorPosition;
|
|
m_contextMenu.targetItemId = std::string(targetItemId);
|
|
RebuildContextMenu();
|
|
}
|
|
|
|
void ProjectPanel::CloseContextMenu() {
|
|
m_contextMenu = {};
|
|
}
|
|
|
|
void ProjectPanel::RebuildContextMenu() {
|
|
if (!m_contextMenu.open || !HasValidBounds(m_layout.bounds)) {
|
|
CloseContextMenu();
|
|
return;
|
|
}
|
|
|
|
const AssetCommandTarget assetTarget =
|
|
ResolveAssetCommandTarget(
|
|
m_contextMenu.targetItemId,
|
|
m_contextMenu.forceCurrentFolder);
|
|
const std::optional<EditCommandTarget> editTarget =
|
|
ResolveEditCommandTarget(
|
|
m_contextMenu.targetItemId,
|
|
m_contextMenu.forceCurrentFolder);
|
|
|
|
std::vector<Widgets::UIEditorMenuPopupItem> items = {};
|
|
const bool canOpen =
|
|
assetTarget.subjectAsset != nullptr &&
|
|
(assetTarget.subjectAsset->directory || assetTarget.subjectAsset->canOpen);
|
|
const bool canCreate = assetTarget.containerFolder != nullptr;
|
|
const UIEditorHostCommandEvaluationResult showInExplorerEvaluation =
|
|
EvaluateAssetCommand(
|
|
"assets.show_in_explorer",
|
|
m_contextMenu.targetItemId,
|
|
m_contextMenu.forceCurrentFolder);
|
|
const UIEditorHostCommandEvaluationResult copyPathEvaluation =
|
|
EvaluateAssetCommand(
|
|
"assets.copy_path",
|
|
m_contextMenu.targetItemId,
|
|
m_contextMenu.forceCurrentFolder);
|
|
const UIEditorHostCommandEvaluationResult createFolderEvaluation =
|
|
EvaluateAssetCommand(
|
|
"assets.create_folder",
|
|
m_contextMenu.targetItemId,
|
|
m_contextMenu.forceCurrentFolder);
|
|
const UIEditorHostCommandEvaluationResult createMaterialEvaluation =
|
|
EvaluateAssetCommand(
|
|
"assets.create_material",
|
|
m_contextMenu.targetItemId,
|
|
m_contextMenu.forceCurrentFolder);
|
|
const UIEditorHostCommandEvaluationResult renameEvaluation =
|
|
EvaluateEditCommand(
|
|
"edit.rename",
|
|
m_contextMenu.targetItemId,
|
|
m_contextMenu.forceCurrentFolder);
|
|
const UIEditorHostCommandEvaluationResult deleteEvaluation =
|
|
EvaluateEditCommand(
|
|
"edit.delete",
|
|
m_contextMenu.targetItemId,
|
|
m_contextMenu.forceCurrentFolder);
|
|
|
|
if (canOpen) {
|
|
items.push_back(
|
|
BuildContextMenuCommandItem(
|
|
"project.context.open",
|
|
"Open"));
|
|
}
|
|
|
|
if (canCreate) {
|
|
AppendContextMenuSeparator(items, "project.context.separator.open_create");
|
|
items.push_back(
|
|
BuildContextMenuCommandItem(
|
|
"assets.create_folder",
|
|
"Create Folder",
|
|
createFolderEvaluation.executable));
|
|
items.push_back(
|
|
BuildContextMenuCommandItem(
|
|
"assets.create_material",
|
|
"Create Material",
|
|
createMaterialEvaluation.executable));
|
|
}
|
|
|
|
if (showInExplorerEvaluation.executable || copyPathEvaluation.executable) {
|
|
AppendContextMenuSeparator(items, "project.context.separator.create_util");
|
|
items.push_back(
|
|
BuildContextMenuCommandItem(
|
|
"assets.show_in_explorer",
|
|
"Show in Explorer",
|
|
showInExplorerEvaluation.executable));
|
|
items.push_back(
|
|
BuildContextMenuCommandItem(
|
|
"assets.copy_path",
|
|
"Copy Path",
|
|
copyPathEvaluation.executable));
|
|
}
|
|
|
|
if (editTarget.has_value()) {
|
|
AppendContextMenuSeparator(items, "project.context.separator.util_edit");
|
|
items.push_back(
|
|
BuildContextMenuCommandItem(
|
|
"edit.rename",
|
|
"Rename",
|
|
renameEvaluation.executable));
|
|
items.push_back(
|
|
BuildContextMenuCommandItem(
|
|
"edit.delete",
|
|
"Delete",
|
|
deleteEvaluation.executable));
|
|
}
|
|
|
|
if (items.empty()) {
|
|
CloseContextMenu();
|
|
return;
|
|
}
|
|
|
|
m_contextMenu.items = std::move(items);
|
|
const Widgets::UIEditorMenuPopupMetrics& popupMetrics =
|
|
ResolveUIEditorMenuPopupMetrics();
|
|
const float popupWidth = (std::max)(
|
|
156.0f,
|
|
Widgets::ResolveUIEditorMenuPopupDesiredWidth(
|
|
m_contextMenu.items,
|
|
popupMetrics));
|
|
const float popupHeight =
|
|
Widgets::MeasureUIEditorMenuPopupHeight(
|
|
m_contextMenu.items,
|
|
popupMetrics);
|
|
const ::XCEngine::UI::Widgets::UIPopupPlacementResult placement =
|
|
::XCEngine::UI::Widgets::ResolvePopupPlacementRect(
|
|
UIRect(
|
|
m_contextMenu.anchorPosition.x,
|
|
m_contextMenu.anchorPosition.y,
|
|
1.0f,
|
|
1.0f),
|
|
UISize(popupWidth, popupHeight),
|
|
m_layout.bounds,
|
|
::XCEngine::UI::Widgets::UIPopupPlacement::BottomStart);
|
|
m_contextMenu.layout = Widgets::BuildUIEditorMenuPopupLayout(
|
|
placement.rect,
|
|
m_contextMenu.items,
|
|
popupMetrics);
|
|
m_contextMenu.widgetState = {};
|
|
m_contextMenu.widgetState.focused = true;
|
|
}
|
|
|
|
bool ProjectPanel::HandleContextMenuEvent(const UIInputEvent& event) {
|
|
if (!m_contextMenu.open) {
|
|
return false;
|
|
}
|
|
|
|
const Widgets::UIEditorMenuPopupHitTarget hitTarget =
|
|
Widgets::HitTestUIEditorMenuPopup(
|
|
m_contextMenu.layout,
|
|
m_contextMenu.items,
|
|
event.position);
|
|
|
|
switch (event.type) {
|
|
case UIInputEventType::PointerMove:
|
|
case UIInputEventType::PointerEnter:
|
|
m_contextMenu.widgetState.hoveredIndex =
|
|
hitTarget.kind == Widgets::UIEditorMenuPopupHitTargetKind::Item &&
|
|
hitTarget.index < m_contextMenu.items.size() &&
|
|
m_contextMenu.items[hitTarget.index].enabled
|
|
? hitTarget.index
|
|
: Widgets::UIEditorMenuPopupInvalidIndex;
|
|
return hitTarget.kind != Widgets::UIEditorMenuPopupHitTargetKind::None;
|
|
|
|
case UIInputEventType::PointerLeave:
|
|
m_contextMenu.widgetState.hoveredIndex =
|
|
Widgets::UIEditorMenuPopupInvalidIndex;
|
|
return false;
|
|
|
|
case UIInputEventType::PointerButtonDown:
|
|
if (event.pointerButton == ::XCEngine::UI::UIPointerButton::Right) {
|
|
if (hitTarget.kind != Widgets::UIEditorMenuPopupHitTargetKind::None) {
|
|
return true;
|
|
}
|
|
|
|
CloseContextMenu();
|
|
return false;
|
|
}
|
|
|
|
if (event.pointerButton != ::XCEngine::UI::UIPointerButton::Left) {
|
|
return hitTarget.kind != Widgets::UIEditorMenuPopupHitTargetKind::None;
|
|
}
|
|
|
|
if (hitTarget.kind == Widgets::UIEditorMenuPopupHitTargetKind::Item &&
|
|
hitTarget.index < m_contextMenu.items.size() &&
|
|
m_contextMenu.items[hitTarget.index].enabled) {
|
|
const std::string itemId = m_contextMenu.items[hitTarget.index].itemId;
|
|
DispatchContextMenuItem(itemId);
|
|
CloseContextMenu();
|
|
return true;
|
|
}
|
|
|
|
if (hitTarget.kind == Widgets::UIEditorMenuPopupHitTargetKind::PopupSurface) {
|
|
return true;
|
|
}
|
|
|
|
CloseContextMenu();
|
|
return true;
|
|
|
|
case UIInputEventType::FocusLost:
|
|
CloseContextMenu();
|
|
return false;
|
|
|
|
case UIInputEventType::KeyDown:
|
|
if (event.keyCode == VK_ESCAPE) {
|
|
CloseContextMenu();
|
|
return true;
|
|
}
|
|
return false;
|
|
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
bool ProjectPanel::DispatchContextMenuItem(std::string_view itemId) {
|
|
if (itemId == "project.context.open") {
|
|
return OpenProjectItem(
|
|
m_contextMenu.targetItemId,
|
|
EventSource::GridSecondary);
|
|
}
|
|
|
|
if (itemId.rfind("assets.", 0u) == 0u) {
|
|
return DispatchAssetCommand(
|
|
itemId,
|
|
m_contextMenu.targetItemId,
|
|
m_contextMenu.forceCurrentFolder)
|
|
.commandExecuted;
|
|
}
|
|
|
|
if (itemId.rfind("edit.", 0u) == 0u) {
|
|
return DispatchEditCommand(
|
|
itemId,
|
|
m_contextMenu.targetItemId,
|
|
m_contextMenu.forceCurrentFolder)
|
|
.commandExecuted;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
void ProjectPanel::AppendContextMenu(UIDrawList& drawList) const {
|
|
if (!m_contextMenu.open || m_contextMenu.items.empty()) {
|
|
return;
|
|
}
|
|
|
|
const Widgets::UIEditorMenuPopupMetrics& popupMetrics =
|
|
ResolveUIEditorMenuPopupMetrics();
|
|
const Widgets::UIEditorMenuPopupPalette& popupPalette =
|
|
ResolveUIEditorMenuPopupPalette();
|
|
Widgets::AppendUIEditorMenuPopupBackground(
|
|
drawList,
|
|
m_contextMenu.layout,
|
|
m_contextMenu.items,
|
|
m_contextMenu.widgetState,
|
|
popupPalette,
|
|
popupMetrics);
|
|
Widgets::AppendUIEditorMenuPopupForeground(
|
|
drawList,
|
|
m_contextMenu.layout,
|
|
m_contextMenu.items,
|
|
m_contextMenu.widgetState,
|
|
popupPalette,
|
|
popupMetrics);
|
|
}
|
|
|
|
void ProjectPanel::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 = folder->label;
|
|
event.itemKind = BrowserModel::ItemKind::Folder;
|
|
event.directory = true;
|
|
m_frameEvents.push_back(std::move(event));
|
|
}
|
|
|
|
void ProjectPanel::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.itemKind = asset->kind;
|
|
event.directory = asset->directory;
|
|
}
|
|
m_frameEvents.push_back(std::move(event));
|
|
}
|
|
|
|
void ProjectPanel::EmitSelectionClearedEvent(EventSource source) {
|
|
Event event = {};
|
|
event.kind = EventKind::AssetSelectionCleared;
|
|
event.source = source;
|
|
m_frameEvents.push_back(std::move(event));
|
|
}
|
|
|
|
std::vector<UIInputEvent> ProjectPanel::BuildTreeInteractionInputEvents(
|
|
const std::vector<UIInputEvent>& inputEvents,
|
|
const UIRect& bounds,
|
|
bool allowInteraction,
|
|
bool panelActive) const {
|
|
const std::vector<UIInputEvent> rawEvents =
|
|
FilterUIEditorPanelInputEvents(
|
|
bounds,
|
|
inputEvents,
|
|
UIEditorPanelInputFilterOptions{
|
|
.allowPointerInBounds = allowInteraction,
|
|
.allowPointerWhileCaptured = HasActivePointerCapture(),
|
|
.allowKeyboardInput = panelActive,
|
|
.allowFocusEvents = panelActive || HasActivePointerCapture(),
|
|
.includePointerLeave = allowInteraction || HasActivePointerCapture()
|
|
});
|
|
|
|
const Widgets::UIEditorTreeViewLayout layout =
|
|
m_treeFrame.layout.bounds.width > 0.0f
|
|
? m_treeFrame.layout
|
|
: Widgets::BuildUIEditorTreeViewLayout(
|
|
m_layout.treeRect,
|
|
GetBrowserModel().GetTreeItems(),
|
|
m_folderExpansion,
|
|
ResolveUIEditorTreeViewMetrics());
|
|
return BuildUIEditorTreePanelInteractionInputEvents(
|
|
m_treeDragState,
|
|
layout,
|
|
GetBrowserModel().GetTreeItems(),
|
|
rawEvents,
|
|
m_splitterDragging || m_assetDragState.dragging);
|
|
}
|
|
|
|
UIEditorHostCommandEvaluationResult ProjectPanel::EvaluateAssetCommand(
|
|
std::string_view commandId) const {
|
|
return EvaluateAssetCommand(commandId, {}, false);
|
|
}
|
|
|
|
UIEditorHostCommandEvaluationResult ProjectPanel::EvaluateAssetCommand(
|
|
std::string_view commandId,
|
|
std::string_view explicitItemId,
|
|
bool forceCurrentFolder) const {
|
|
const AssetCommandTarget target =
|
|
ResolveAssetCommandTarget(explicitItemId, forceCurrentFolder);
|
|
|
|
if (commandId == "assets.create_folder") {
|
|
if (target.containerFolder == nullptr) {
|
|
return BuildEvaluationResult(false, "Project has no active folder.");
|
|
}
|
|
|
|
return BuildEvaluationResult(
|
|
true,
|
|
"Create a folder under '" + target.containerFolder->label + "'.");
|
|
}
|
|
|
|
if (commandId == "assets.create_material") {
|
|
if (target.containerFolder == nullptr) {
|
|
return BuildEvaluationResult(false, "Project has no active folder.");
|
|
}
|
|
|
|
return BuildEvaluationResult(
|
|
true,
|
|
"Create a material under '" + target.containerFolder->label + "'.");
|
|
}
|
|
|
|
if (commandId == "assets.copy_path") {
|
|
if (target.subjectRelativePath.empty()) {
|
|
return BuildEvaluationResult(false, "Project has no selected item or current folder path.");
|
|
}
|
|
|
|
return BuildEvaluationResult(
|
|
true,
|
|
"Copy project path '" + target.subjectRelativePath + "'.");
|
|
}
|
|
|
|
if (commandId == "assets.show_in_explorer") {
|
|
if (target.subjectItemId.empty() || target.subjectDisplayName.empty()) {
|
|
return BuildEvaluationResult(false, "Project has no selected item or current folder.");
|
|
}
|
|
|
|
return BuildEvaluationResult(
|
|
true,
|
|
"Reveal '" + target.subjectDisplayName + "' in Explorer.");
|
|
}
|
|
|
|
return BuildEvaluationResult(false, "Project does not expose this asset command.");
|
|
}
|
|
|
|
UIEditorHostCommandDispatchResult ProjectPanel::DispatchAssetCommand(
|
|
std::string_view commandId) {
|
|
return DispatchAssetCommand(commandId, {}, false);
|
|
}
|
|
|
|
UIEditorHostCommandDispatchResult ProjectPanel::DispatchAssetCommand(
|
|
std::string_view commandId,
|
|
std::string_view explicitItemId,
|
|
bool forceCurrentFolder) {
|
|
const UIEditorHostCommandEvaluationResult evaluation =
|
|
EvaluateAssetCommand(commandId, explicitItemId, forceCurrentFolder);
|
|
if (!evaluation.executable) {
|
|
return BuildDispatchResult(false, evaluation.message);
|
|
}
|
|
|
|
const AssetCommandTarget target =
|
|
ResolveAssetCommandTarget(explicitItemId, forceCurrentFolder);
|
|
|
|
const auto finalizeCreatedAsset =
|
|
[this](std::string_view createdItemId) {
|
|
ClearRenameState();
|
|
SyncCurrentFolderSelection();
|
|
m_hoveredAssetItemId.clear();
|
|
m_lastPrimaryClickedAssetId = std::string(createdItemId);
|
|
m_lastPrimaryClickTimeMs = 0u;
|
|
ResolveProjectRuntime()->SetSelection(createdItemId);
|
|
SyncAssetSelectionFromRuntime();
|
|
|
|
const AssetEntry* createdAsset = FindAssetEntry(createdItemId);
|
|
if (createdAsset == nullptr) {
|
|
return false;
|
|
}
|
|
|
|
EmitEvent(EventKind::AssetSelected, EventSource::Command, createdAsset);
|
|
QueueRenameSession(createdItemId, RenameSurface::Grid);
|
|
EmitEvent(EventKind::RenameRequested, EventSource::Command, createdAsset);
|
|
if (m_visible) {
|
|
TryStartQueuedRenameSession();
|
|
}
|
|
return true;
|
|
};
|
|
|
|
if (commandId == "assets.create_folder") {
|
|
if (target.containerFolder == nullptr) {
|
|
return BuildDispatchResult(false, "Project has no active folder.");
|
|
}
|
|
|
|
std::string createdFolderId = {};
|
|
if (!ResolveProjectRuntime()->CreateFolder(
|
|
target.containerFolder->itemId,
|
|
"New Folder",
|
|
&createdFolderId)) {
|
|
return BuildDispatchResult(false, "Failed to create a folder in the current Project directory.");
|
|
}
|
|
|
|
if (target.containerFolder->itemId != GetBrowserModel().GetCurrentFolderId()) {
|
|
NavigateToFolder(target.containerFolder->itemId, EventSource::GridSecondary);
|
|
if (HasValidBounds(m_layout.bounds)) {
|
|
m_layout = BuildLayout(m_layout.bounds);
|
|
}
|
|
}
|
|
|
|
if (finalizeCreatedAsset(createdFolderId)) {
|
|
if (const AssetEntry* createdFolder = FindAssetEntry(createdFolderId);
|
|
createdFolder != nullptr) {
|
|
return BuildDispatchResult(
|
|
true,
|
|
"Created folder '" + createdFolder->displayName + "'.");
|
|
}
|
|
}
|
|
|
|
return BuildDispatchResult(true, "Created a new folder in the current Project directory.");
|
|
}
|
|
|
|
if (commandId == "assets.create_material") {
|
|
if (target.containerFolder == nullptr) {
|
|
return BuildDispatchResult(false, "Project has no active folder.");
|
|
}
|
|
|
|
std::string createdItemId = {};
|
|
if (!ResolveProjectRuntime()->CreateMaterial(
|
|
target.containerFolder->itemId,
|
|
"New Material",
|
|
&createdItemId)) {
|
|
return BuildDispatchResult(false, "Failed to create a material in the current Project directory.");
|
|
}
|
|
|
|
if (target.containerFolder->itemId != GetBrowserModel().GetCurrentFolderId()) {
|
|
NavigateToFolder(target.containerFolder->itemId, EventSource::GridSecondary);
|
|
if (HasValidBounds(m_layout.bounds)) {
|
|
m_layout = BuildLayout(m_layout.bounds);
|
|
}
|
|
}
|
|
|
|
if (finalizeCreatedAsset(createdItemId)) {
|
|
if (const AssetEntry* createdMaterial = FindAssetEntry(createdItemId);
|
|
createdMaterial != nullptr) {
|
|
return BuildDispatchResult(
|
|
true,
|
|
"Created material '" + createdMaterial->nameWithExtension + "'.");
|
|
}
|
|
}
|
|
|
|
return BuildDispatchResult(true, "Created a new material in the current Project directory.");
|
|
}
|
|
|
|
if (commandId == "assets.copy_path") {
|
|
if (target.subjectRelativePath.empty()) {
|
|
return BuildDispatchResult(false, "Project has no selected item or current folder path.");
|
|
}
|
|
|
|
if (!CopyTextToClipboard(target.subjectRelativePath)) {
|
|
return BuildDispatchResult(false, "Failed to copy the project path to the clipboard.");
|
|
}
|
|
|
|
return BuildDispatchResult(
|
|
true,
|
|
"Copied project path '" + target.subjectRelativePath + "'.");
|
|
}
|
|
|
|
if (commandId == "assets.show_in_explorer") {
|
|
if (target.subjectPath.empty()) {
|
|
return BuildDispatchResult(false, "Project has no selected item or current folder.");
|
|
}
|
|
|
|
if (!ShowPathInExplorer(target.subjectPath, target.showInExplorerSelectTarget)) {
|
|
return BuildDispatchResult(false, "Failed to reveal the target path in Explorer.");
|
|
}
|
|
|
|
return BuildDispatchResult(
|
|
true,
|
|
target.showInExplorerSelectTarget
|
|
? "Revealed '" + target.subjectRelativePath + "' in Explorer."
|
|
: "Opened current Project folder in Explorer.");
|
|
}
|
|
|
|
return BuildDispatchResult(false, "Project does not expose this asset command.");
|
|
}
|
|
|
|
UIEditorHostCommandEvaluationResult ProjectPanel::EvaluateEditCommand(
|
|
std::string_view commandId) const {
|
|
return EvaluateEditCommand(commandId, {}, false);
|
|
}
|
|
|
|
UIEditorHostCommandEvaluationResult ProjectPanel::EvaluateEditCommand(
|
|
std::string_view commandId,
|
|
std::string_view explicitItemId,
|
|
bool forceCurrentFolder) const {
|
|
const std::optional<EditCommandTarget> target =
|
|
ResolveEditCommandTarget(explicitItemId, forceCurrentFolder);
|
|
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(
|
|
true,
|
|
"Delete project item '" + target->displayName + "'.");
|
|
}
|
|
|
|
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) {
|
|
return DispatchEditCommand(commandId, {}, false);
|
|
}
|
|
|
|
UIEditorHostCommandDispatchResult ProjectPanel::DispatchEditCommand(
|
|
std::string_view commandId,
|
|
std::string_view explicitItemId,
|
|
bool forceCurrentFolder) {
|
|
const UIEditorHostCommandEvaluationResult evaluation =
|
|
EvaluateEditCommand(commandId, explicitItemId, forceCurrentFolder);
|
|
if (!evaluation.executable) {
|
|
return BuildDispatchResult(false, evaluation.message);
|
|
}
|
|
|
|
const std::optional<EditCommandTarget> target =
|
|
ResolveEditCommandTarget(explicitItemId, forceCurrentFolder);
|
|
if (!target.has_value()) {
|
|
return BuildDispatchResult(false, "Select an asset or folder in Project first.");
|
|
}
|
|
|
|
if (commandId == "edit.rename") {
|
|
const AssetEntry* renameAsset =
|
|
!explicitItemId.empty() ? FindAssetEntry(target->itemId) : GetSelectedAssetEntry();
|
|
const FolderEntry* renameFolder =
|
|
!explicitItemId.empty() ? FindFolderEntry(target->itemId) : GetSelectedFolderEntry();
|
|
const RenameSurface surface =
|
|
!explicitItemId.empty() && FindAssetEntry(explicitItemId) != nullptr
|
|
? RenameSurface::Grid
|
|
: (GetSelectedAssetEntry() != nullptr ? RenameSurface::Grid : RenameSurface::Tree);
|
|
QueueRenameSession(target->itemId, surface);
|
|
if (surface == RenameSurface::Grid) {
|
|
EmitEvent(EventKind::RenameRequested, EventSource::GridPrimary, renameAsset);
|
|
} else {
|
|
EmitEvent(EventKind::RenameRequested, EventSource::Tree, renameFolder);
|
|
}
|
|
if (m_visible) {
|
|
TryStartQueuedRenameSession();
|
|
}
|
|
return BuildDispatchResult(
|
|
true,
|
|
"Project rename requested for '" + target->displayName + "'.");
|
|
}
|
|
|
|
if (commandId == "edit.delete") {
|
|
const std::string previousCurrentFolderId = GetBrowserModel().GetCurrentFolderId();
|
|
const bool hadAssetSelection = ResolveProjectRuntime()->HasSelection();
|
|
if (!ResolveProjectRuntime()->DeleteItem(target->itemId)) {
|
|
return BuildDispatchResult(false, "Failed to delete the selected project item.");
|
|
}
|
|
|
|
ClearRenameState();
|
|
SyncCurrentFolderSelection();
|
|
SyncAssetSelectionFromRuntime();
|
|
m_hoveredAssetItemId.clear();
|
|
m_lastPrimaryClickedAssetId.clear();
|
|
if (hadAssetSelection && !ResolveProjectRuntime()->HasSelection()) {
|
|
EmitSelectionClearedEvent(EventSource::GridPrimary);
|
|
}
|
|
if (previousCurrentFolderId != GetBrowserModel().GetCurrentFolderId()) {
|
|
EmitEvent(
|
|
EventKind::FolderNavigated,
|
|
EventSource::Tree,
|
|
FindFolderEntry(GetBrowserModel().GetCurrentFolderId()));
|
|
}
|
|
return BuildDispatchResult(
|
|
true,
|
|
"Deleted project item '" + target->displayName + "'.");
|
|
}
|
|
|
|
return BuildDispatchResult(false, "Project does not expose this edit command.");
|
|
}
|
|
|
|
void ProjectPanel::ResetTransientFrames() {
|
|
m_treeFrame = {};
|
|
m_frameEvents.clear();
|
|
m_layout = {};
|
|
m_assetDropTargetSurface = DropTargetSurface::None;
|
|
m_hoveredAssetItemId.clear();
|
|
m_hoveredBreadcrumbIndex = kInvalidLayoutIndex;
|
|
m_pressedBreadcrumbIndex = kInvalidLayoutIndex;
|
|
m_splitterHovered = false;
|
|
m_splitterDragging = false;
|
|
}
|
|
|
|
void ProjectPanel::ClaimCommandFocus(
|
|
const std::vector<UIInputEvent>& inputEvents,
|
|
const UIRect& bounds,
|
|
bool allowInteraction) {
|
|
if (m_commandFocusService == nullptr) {
|
|
return;
|
|
}
|
|
|
|
for (const UIInputEvent& event : inputEvents) {
|
|
if (event.type == UIInputEventType::FocusGained) {
|
|
m_commandFocusService->ClaimFocus(EditorActionRoute::Project);
|
|
return;
|
|
}
|
|
|
|
if (!allowInteraction ||
|
|
event.type != UIInputEventType::PointerButtonDown ||
|
|
!ContainsPoint(bounds, event.position)) {
|
|
continue;
|
|
}
|
|
|
|
m_commandFocusService->ClaimFocus(EditorActionRoute::Project);
|
|
return;
|
|
}
|
|
}
|
|
|
|
void ProjectPanel::Update(
|
|
const UIEditorPanelContentHostFrame& contentHostFrame,
|
|
const std::vector<UIInputEvent>& inputEvents,
|
|
bool allowInteraction,
|
|
bool panelActive) {
|
|
m_requestPointerCapture = false;
|
|
m_requestPointerRelease = false;
|
|
m_frameEvents.clear();
|
|
GridDrag::ResetTransientRequests(m_assetDragState);
|
|
TreeDrag::ResetTransientRequests(m_treeDragState);
|
|
|
|
const UIEditorPanelContentHostPanelState* panelState =
|
|
FindMountedProjectPanel(contentHostFrame);
|
|
if (panelState == nullptr) {
|
|
if (m_splitterDragging ||
|
|
m_assetDragState.dragging ||
|
|
m_treeDragState.dragging ||
|
|
m_renameState.active) {
|
|
m_requestPointerRelease = true;
|
|
}
|
|
m_visible = false;
|
|
m_assetDragState = {};
|
|
m_treeDragState = {};
|
|
CloseContextMenu();
|
|
ClearRenameState();
|
|
ResetTransientFrames();
|
|
return;
|
|
}
|
|
|
|
if (!HasProjectRuntime()) {
|
|
m_visible = false;
|
|
CloseContextMenu();
|
|
ClearRenameState();
|
|
ResetTransientFrames();
|
|
return;
|
|
}
|
|
|
|
if (GetBrowserModel().GetTreeItems().empty()) {
|
|
ResolveProjectRuntime()->Refresh();
|
|
SyncCurrentFolderSelection();
|
|
SyncAssetSelectionFromRuntime();
|
|
}
|
|
|
|
m_visible = true;
|
|
SyncAssetSelectionFromRuntime();
|
|
const std::vector<UIInputEvent> filteredEvents =
|
|
FilterUIEditorPanelInputEvents(
|
|
panelState->bounds,
|
|
inputEvents,
|
|
UIEditorPanelInputFilterOptions{
|
|
.allowPointerInBounds = allowInteraction,
|
|
.allowPointerWhileCaptured = HasActivePointerCapture(),
|
|
.allowKeyboardInput = panelActive,
|
|
.allowFocusEvents = panelActive || HasActivePointerCapture(),
|
|
.includePointerLeave = allowInteraction || HasActivePointerCapture()
|
|
});
|
|
ClaimCommandFocus(filteredEvents, panelState->bounds, allowInteraction);
|
|
|
|
m_navigationWidth = ClampNavigationWidth(m_navigationWidth, panelState->bounds.width);
|
|
m_layout = BuildLayout(panelState->bounds);
|
|
if (m_contextMenu.open) {
|
|
RebuildContextMenu();
|
|
}
|
|
const Widgets::UIEditorTreeViewMetrics treeMetrics = ResolveUIEditorTreeViewMetrics();
|
|
m_treeFrame.layout = Widgets::BuildUIEditorTreeViewLayout(
|
|
m_layout.treeRect,
|
|
GetBrowserModel().GetTreeItems(),
|
|
m_folderExpansion,
|
|
treeMetrics);
|
|
m_treeFrame.result = {};
|
|
|
|
if ((m_renameState.active || !m_pendingRenameItemId.empty()) &&
|
|
(m_assetDragState.dragging || m_treeDragState.dragging)) {
|
|
m_assetDragState = {};
|
|
m_treeDragState = {};
|
|
}
|
|
|
|
if (m_renameState.active || !m_pendingRenameItemId.empty()) {
|
|
TryStartQueuedRenameSession();
|
|
UpdateRenameSession(filteredEvents);
|
|
return;
|
|
}
|
|
|
|
const std::vector<UIInputEvent> treeEvents =
|
|
BuildTreeInteractionInputEvents(
|
|
inputEvents,
|
|
panelState->bounds,
|
|
allowInteraction,
|
|
panelActive);
|
|
m_treeFrame = UpdateUIEditorTreeViewInteraction(
|
|
m_treeInteractionState,
|
|
m_folderSelection,
|
|
m_folderExpansion,
|
|
m_layout.treeRect,
|
|
GetBrowserModel().GetTreeItems(),
|
|
treeEvents,
|
|
treeMetrics);
|
|
|
|
if (m_treeFrame.result.selectionChanged &&
|
|
!m_treeFrame.result.selectedItemId.empty() &&
|
|
m_treeFrame.result.selectedItemId != GetBrowserModel().GetCurrentFolderId()) {
|
|
CloseContextMenu();
|
|
NavigateToFolder(m_treeFrame.result.selectedItemId, EventSource::Tree);
|
|
m_layout = BuildLayout(panelState->bounds);
|
|
}
|
|
if (m_treeFrame.result.renameRequested &&
|
|
!m_treeFrame.result.renameItemId.empty()) {
|
|
QueueRenameSession(m_treeFrame.result.renameItemId, RenameSurface::Tree);
|
|
EmitEvent(
|
|
EventKind::RenameRequested,
|
|
EventSource::Tree,
|
|
FindFolderEntry(m_treeFrame.result.renameItemId));
|
|
TryStartQueuedRenameSession();
|
|
return;
|
|
}
|
|
|
|
struct ProjectTreeDragCallbacks {
|
|
::XCEngine::UI::Widgets::UISelectionModel& folderSelection;
|
|
::XCEngine::UI::Widgets::UIExpansionModel& folderExpansion;
|
|
EditorProjectRuntime& projectRuntime;
|
|
|
|
bool IsItemSelected(std::string_view itemId) const {
|
|
return folderSelection.IsSelected(itemId);
|
|
}
|
|
|
|
bool SelectDraggedItem(std::string_view itemId) {
|
|
return folderSelection.SetSelection(std::string(itemId));
|
|
}
|
|
|
|
bool CanDropOnItem(
|
|
std::string_view draggedItemId,
|
|
std::string_view targetItemId) const {
|
|
return projectRuntime.CanReparentFolder(draggedItemId, targetItemId);
|
|
}
|
|
|
|
bool CanDropToRoot(std::string_view draggedItemId) const {
|
|
const std::optional<std::string> parentId =
|
|
projectRuntime.GetParentFolderId(draggedItemId);
|
|
return parentId.has_value() && parentId.value() != "Assets";
|
|
}
|
|
|
|
bool CommitDropOnItem(
|
|
std::string_view draggedItemId,
|
|
std::string_view targetItemId) {
|
|
std::string movedFolderId = {};
|
|
if (!projectRuntime.ReparentFolder(draggedItemId, targetItemId, &movedFolderId)) {
|
|
return false;
|
|
}
|
|
|
|
folderExpansion.Expand(std::string(targetItemId));
|
|
if (!movedFolderId.empty()) {
|
|
folderSelection.SetSelection(movedFolderId);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool CommitDropToRoot(std::string_view draggedItemId) {
|
|
std::string movedFolderId = {};
|
|
if (!projectRuntime.MoveFolderToRoot(draggedItemId, &movedFolderId)) {
|
|
return false;
|
|
}
|
|
|
|
if (!movedFolderId.empty()) {
|
|
folderSelection.SetSelection(movedFolderId);
|
|
}
|
|
return true;
|
|
}
|
|
} treeDragCallbacks{ m_folderSelection, m_folderExpansion, *ResolveProjectRuntime() };
|
|
const TreeDrag::ProcessResult treeDragResult =
|
|
TreeDrag::ProcessInputEvents(
|
|
m_treeDragState,
|
|
m_treeFrame.layout,
|
|
GetBrowserModel().GetTreeItems(),
|
|
FilterUIEditorTreePanelPointerInputEvents(
|
|
filteredEvents,
|
|
m_splitterDragging || m_assetDragState.dragging),
|
|
m_layout.treeRect,
|
|
treeDragCallbacks);
|
|
if (treeDragResult.dropCommitted) {
|
|
const bool hadAssetSelection = ResolveProjectRuntime()->HasSelection();
|
|
CloseContextMenu();
|
|
ResolveProjectRuntime()->ClearSelection();
|
|
SyncAssetSelectionFromRuntime();
|
|
m_hoveredAssetItemId.clear();
|
|
m_lastPrimaryClickedAssetId.clear();
|
|
if (hadAssetSelection && !ResolveProjectRuntime()->HasSelection()) {
|
|
EmitSelectionClearedEvent(EventSource::Tree);
|
|
}
|
|
SyncCurrentFolderSelection();
|
|
m_layout = BuildLayout(panelState->bounds);
|
|
m_treeFrame.layout = Widgets::BuildUIEditorTreeViewLayout(
|
|
m_layout.treeRect,
|
|
GetBrowserModel().GetTreeItems(),
|
|
m_folderExpansion,
|
|
treeMetrics);
|
|
}
|
|
|
|
struct ProjectAssetDragCallbacks {
|
|
::XCEngine::UI::Widgets::UISelectionModel& assetSelection;
|
|
::XCEngine::UI::Widgets::UIExpansionModel& folderExpansion;
|
|
EditorProjectRuntime& projectRuntime;
|
|
const Layout& layout;
|
|
const std::vector<AssetEntry>& assetEntries;
|
|
std::function<std::string(const UIPoint&, DropTargetSurface*)> resolveDropTarget = {};
|
|
DropTargetSurface dropTargetSurface = DropTargetSurface::None;
|
|
std::string movedItemId = {};
|
|
|
|
bool IsItemSelected(std::string_view itemId) const {
|
|
return assetSelection.IsSelected(itemId);
|
|
}
|
|
|
|
bool SelectDraggedItem(std::string_view itemId) {
|
|
return assetSelection.SetSelection(std::string(itemId));
|
|
}
|
|
|
|
std::string ResolveDraggableItem(const UIPoint& point) const {
|
|
for (const AssetTileLayout& tile : layout.assetTiles) {
|
|
if (tile.itemIndex >= assetEntries.size()) {
|
|
continue;
|
|
}
|
|
if (ContainsPoint(tile.tileRect, point)) {
|
|
return assetEntries[tile.itemIndex].itemId;
|
|
}
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
std::string ResolveDropTargetItem(
|
|
std::string_view draggedItemId,
|
|
const UIPoint& point) {
|
|
dropTargetSurface = DropTargetSurface::None;
|
|
std::string targetItemId = resolveDropTarget(point, &dropTargetSurface);
|
|
if (targetItemId.empty() || targetItemId == draggedItemId) {
|
|
dropTargetSurface = DropTargetSurface::None;
|
|
return {};
|
|
}
|
|
|
|
return targetItemId;
|
|
}
|
|
|
|
bool CanDropOnItem(
|
|
std::string_view draggedItemId,
|
|
std::string_view targetItemId) const {
|
|
return projectRuntime.CanMoveItemToFolder(draggedItemId, targetItemId);
|
|
}
|
|
|
|
bool CommitDropOnItem(
|
|
std::string_view draggedItemId,
|
|
std::string_view targetItemId) {
|
|
movedItemId.clear();
|
|
if (!projectRuntime.MoveItemToFolder(draggedItemId, targetItemId, &movedItemId)) {
|
|
return false;
|
|
}
|
|
|
|
folderExpansion.Expand(std::string(targetItemId));
|
|
return true;
|
|
}
|
|
} assetDragCallbacks{
|
|
m_assetSelection,
|
|
m_folderExpansion,
|
|
*ResolveProjectRuntime(),
|
|
m_layout,
|
|
GetBrowserModel().GetAssetEntries(),
|
|
[this](const UIPoint& point, DropTargetSurface* surface) {
|
|
return ResolveAssetDropTargetItemId(point, surface);
|
|
}
|
|
};
|
|
const GridDrag::ProcessResult assetDragResult =
|
|
GridDrag::ProcessInputEvents(
|
|
m_assetDragState,
|
|
filteredEvents,
|
|
assetDragCallbacks);
|
|
m_assetDropTargetSurface =
|
|
m_assetDragState.dragging && m_assetDragState.validDropTarget
|
|
? assetDragCallbacks.dropTargetSurface
|
|
: DropTargetSurface::None;
|
|
if (assetDragResult.selectionForced) {
|
|
const std::string& draggedItemId = m_assetDragState.draggedItemId.empty()
|
|
? m_assetDragState.armedItemId
|
|
: m_assetDragState.draggedItemId;
|
|
if (const AssetEntry* draggedAsset = FindAssetEntry(draggedItemId);
|
|
draggedAsset != nullptr) {
|
|
EmitEvent(EventKind::AssetSelected, EventSource::GridDrag, draggedAsset);
|
|
}
|
|
}
|
|
if (assetDragResult.dropCommitted) {
|
|
const bool hadAssetSelection = ResolveProjectRuntime()->HasSelection();
|
|
CloseContextMenu();
|
|
ClearRenameState();
|
|
m_hoveredAssetItemId.clear();
|
|
m_lastPrimaryClickedAssetId.clear();
|
|
m_lastPrimaryClickTimeMs = 0u;
|
|
SyncCurrentFolderSelection();
|
|
|
|
const std::string movedItemId = assetDragCallbacks.movedItemId.empty()
|
|
? assetDragResult.draggedItemId
|
|
: assetDragCallbacks.movedItemId;
|
|
if (const AssetEntry* movedAsset = FindAssetEntry(movedItemId);
|
|
movedAsset != nullptr) {
|
|
ResolveProjectRuntime()->SetSelection(movedItemId);
|
|
SyncAssetSelectionFromRuntime();
|
|
EmitEvent(EventKind::AssetSelected, EventSource::GridDrag, movedAsset);
|
|
} else {
|
|
ResolveProjectRuntime()->ClearSelection();
|
|
SyncAssetSelectionFromRuntime();
|
|
if (hadAssetSelection && !ResolveProjectRuntime()->HasSelection()) {
|
|
EmitSelectionClearedEvent(EventSource::GridDrag);
|
|
}
|
|
}
|
|
|
|
m_layout = BuildLayout(panelState->bounds);
|
|
m_treeFrame.layout = Widgets::BuildUIEditorTreeViewLayout(
|
|
m_layout.treeRect,
|
|
GetBrowserModel().GetTreeItems(),
|
|
m_folderExpansion,
|
|
treeMetrics);
|
|
}
|
|
|
|
const bool suppressPanelPointerEvents =
|
|
m_assetDragState.dragging ||
|
|
m_assetDragState.requestPointerCapture ||
|
|
m_assetDragState.requestPointerRelease ||
|
|
m_treeDragState.armed ||
|
|
m_treeDragState.dragging ||
|
|
m_treeDragState.requestPointerCapture ||
|
|
m_treeDragState.requestPointerRelease;
|
|
|
|
for (const UIInputEvent& event : filteredEvents) {
|
|
if (suppressPanelPointerEvents) {
|
|
switch (event.type) {
|
|
case UIInputEventType::PointerMove:
|
|
case UIInputEventType::PointerButtonDown:
|
|
case UIInputEventType::PointerButtonUp:
|
|
case UIInputEventType::PointerWheel:
|
|
case UIInputEventType::PointerEnter:
|
|
continue;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (HandleContextMenuEvent(event)) {
|
|
continue;
|
|
}
|
|
|
|
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 = GetBrowserModel().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 = GetBrowserModel().GetAssetEntries();
|
|
const std::size_t hitIndex = HitTestAssetTile(event.position);
|
|
if (hitIndex >= assetEntries.size()) {
|
|
if (ResolveProjectRuntime()->HasSelection()) {
|
|
ResolveProjectRuntime()->ClearSelection();
|
|
SyncAssetSelectionFromRuntime();
|
|
EmitSelectionClearedEvent(EventSource::Background);
|
|
}
|
|
break;
|
|
}
|
|
|
|
const AssetEntry& assetEntry = assetEntries[hitIndex];
|
|
const bool alreadySelected = m_assetSelection.IsSelected(assetEntry.itemId);
|
|
const bool selectionChanged = ResolveProjectRuntime()->SetSelection(assetEntry.itemId);
|
|
SyncAssetSelectionFromRuntime();
|
|
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;
|
|
}
|
|
|
|
OpenProjectItem(assetEntry.itemId, EventSource::GridDoubleClick);
|
|
break;
|
|
}
|
|
|
|
if (event.pointerButton == ::XCEngine::UI::UIPointerButton::Right &&
|
|
ContainsPoint(m_layout.gridRect, event.position)) {
|
|
const auto& assetEntries = GetBrowserModel().GetAssetEntries();
|
|
const std::size_t hitIndex = HitTestAssetTile(event.position);
|
|
if (hitIndex >= assetEntries.size()) {
|
|
EmitEvent(
|
|
EventKind::ContextMenuRequested,
|
|
EventSource::Background,
|
|
static_cast<const AssetEntry*>(nullptr));
|
|
OpenContextMenu(event.position, {}, true);
|
|
break;
|
|
}
|
|
|
|
const AssetEntry& assetEntry = assetEntries[hitIndex];
|
|
if (!m_assetSelection.IsSelected(assetEntry.itemId)) {
|
|
ResolveProjectRuntime()->SetSelection(assetEntry.itemId);
|
|
SyncAssetSelectionFromRuntime();
|
|
EmitEvent(EventKind::AssetSelected, EventSource::GridSecondary, &assetEntry);
|
|
}
|
|
EmitEvent(EventKind::ContextMenuRequested, EventSource::GridSecondary, &assetEntry);
|
|
OpenContextMenu(event.position, assetEntry.itemId, false);
|
|
}
|
|
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 = GetBrowserModel().GetAssetEntries();
|
|
const std::vector<ProjectBrowserModel::BreadcrumbSegment> breadcrumbSegments =
|
|
GetBrowserModel().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;
|
|
}
|
|
|
|
std::string ProjectPanel::ResolveAssetDropTargetItemId(
|
|
const UIPoint& point,
|
|
DropTargetSurface* surface) const {
|
|
if (surface != nullptr) {
|
|
*surface = DropTargetSurface::None;
|
|
}
|
|
|
|
if (ContainsPoint(m_treeFrame.layout.bounds, point)) {
|
|
Widgets::UIEditorTreeViewHitTarget hitTarget =
|
|
Widgets::HitTestUIEditorTreeView(m_treeFrame.layout, point);
|
|
if (hitTarget.itemIndex < GetBrowserModel().GetTreeItems().size() &&
|
|
(hitTarget.kind == Widgets::UIEditorTreeViewHitTargetKind::Row ||
|
|
hitTarget.kind == Widgets::UIEditorTreeViewHitTargetKind::Disclosure)) {
|
|
if (surface != nullptr) {
|
|
*surface = DropTargetSurface::Tree;
|
|
}
|
|
return GetBrowserModel().GetTreeItems()[hitTarget.itemIndex].itemId;
|
|
}
|
|
}
|
|
|
|
const auto& assetEntries = GetBrowserModel().GetAssetEntries();
|
|
const std::size_t assetIndex = HitTestAssetTile(point);
|
|
if (assetIndex < assetEntries.size() &&
|
|
assetEntries[assetIndex].directory) {
|
|
if (surface != nullptr) {
|
|
*surface = DropTargetSurface::Grid;
|
|
}
|
|
return assetEntries[assetIndex].itemId;
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
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 = GetBrowserModel().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,
|
|
GetBrowserModel().GetTreeItems(),
|
|
m_folderSelection,
|
|
m_treeInteractionState.treeViewState,
|
|
treePalette,
|
|
treeMetrics);
|
|
AppendUIEditorTreeViewForeground(
|
|
drawList,
|
|
m_treeFrame.layout,
|
|
GetBrowserModel().GetTreeItems(),
|
|
treePalette,
|
|
treeMetrics);
|
|
|
|
if (m_treeDragState.dragging && m_treeDragState.validDropTarget) {
|
|
if (m_treeDragState.dropToRoot) {
|
|
drawList.AddRectOutline(
|
|
m_treeFrame.layout.bounds,
|
|
kDropPreviewColor,
|
|
1.0f,
|
|
0.0f);
|
|
} else {
|
|
const std::size_t visibleIndex = FindUIEditorTreePanelVisibleItemIndex(
|
|
m_treeFrame.layout,
|
|
GetBrowserModel().GetTreeItems(),
|
|
m_treeDragState.dropTargetItemId);
|
|
if (visibleIndex != Widgets::UIEditorTreeViewInvalidIndex &&
|
|
visibleIndex < m_treeFrame.layout.rowRects.size()) {
|
|
drawList.AddRectOutline(
|
|
m_treeFrame.layout.rowRects[visibleIndex],
|
|
kDropPreviewColor,
|
|
1.0f,
|
|
0.0f);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (m_assetDragState.dragging &&
|
|
m_assetDragState.validDropTarget &&
|
|
m_assetDropTargetSurface == DropTargetSurface::Tree) {
|
|
const std::size_t visibleIndex = FindUIEditorTreePanelVisibleItemIndex(
|
|
m_treeFrame.layout,
|
|
GetBrowserModel().GetTreeItems(),
|
|
m_assetDragState.dropTargetItemId);
|
|
if (visibleIndex != Widgets::UIEditorTreeViewInvalidIndex &&
|
|
visibleIndex < m_treeFrame.layout.rowRects.size()) {
|
|
drawList.AddRectOutline(
|
|
m_treeFrame.layout.rowRects[visibleIndex],
|
|
kDropPreviewColor,
|
|
1.0f,
|
|
0.0f);
|
|
}
|
|
}
|
|
|
|
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 (m_assetDragState.dragging &&
|
|
m_assetDragState.validDropTarget &&
|
|
m_assetDropTargetSurface == DropTargetSurface::Grid) {
|
|
for (const AssetTileLayout& tile : m_layout.assetTiles) {
|
|
if (tile.itemIndex >= assetEntries.size()) {
|
|
continue;
|
|
}
|
|
|
|
const AssetEntry& assetEntry = assetEntries[tile.itemIndex];
|
|
if (assetEntry.itemId != m_assetDragState.dropTargetItemId) {
|
|
continue;
|
|
}
|
|
|
|
drawList.AddRectOutline(
|
|
tile.tileRect,
|
|
kDropPreviewColor,
|
|
1.0f,
|
|
0.0f);
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (m_renameState.active) {
|
|
const Widgets::UIEditorTextFieldPalette textFieldPalette =
|
|
BuildUIEditorPropertyGridTextFieldPalette(
|
|
ResolveUIEditorPropertyGridPalette(),
|
|
ResolveUIEditorTextFieldPalette());
|
|
const Widgets::UIEditorTextFieldMetrics textFieldMetrics =
|
|
BuildUIEditorInlineRenameTextFieldMetrics(
|
|
BuildRenameBounds(m_renameState.itemId, m_activeRenameSurface),
|
|
BuildUIEditorPropertyGridTextFieldMetrics(
|
|
ResolveUIEditorPropertyGridMetrics(),
|
|
ResolveUIEditorTextFieldMetrics()));
|
|
AppendUIEditorInlineRenameSession(
|
|
drawList,
|
|
m_renameFrame,
|
|
m_renameState,
|
|
textFieldPalette,
|
|
textFieldMetrics);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
AppendContextMenu(drawList);
|
|
}
|
|
|
|
} // namespace XCEngine::UI::Editor::App
|