Files
XCEngine/new_editor/app/Features/Project/ProjectPanel.cpp

2154 lines
76 KiB
C++

#include "ProjectPanelInternal.h"
#include "Ports/SystemInteractionPort.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 <XCEngine/Input/InputTypes.h>
#include <XCEngine/UI/Widgets/UIPopupOverlayModel.h>
#include <functional>
#include <memory>
#include <utility>
namespace XCEngine::UI::Editor::App {
using namespace ProjectPanelInternal;
using ::XCEngine::Input::KeyCode;
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;
}
constexpr auto kGridDoubleClickInterval = std::chrono::milliseconds(400);
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::SetSystemInteractionHost(
Ports::SystemInteractionPort* systemInteractionHost) {
m_systemInteractionHost = systemInteractionHost;
}
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_lastPrimaryClickTime = {};
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 == static_cast<std::int32_t>(KeyCode::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,
const PanelInputContext& inputContext) const {
const std::vector<UIInputEvent> rawEvents =
BuildUIEditorPanelInputEvents(
bounds,
inputEvents,
UIEditorPanelInputFilterOptions{
.allowPointerInBounds = inputContext.allowInteraction,
.allowPointerWhileCaptured = HasActivePointerCapture(),
.allowKeyboardInput = inputContext.hasInputFocus,
.allowFocusEvents =
inputContext.hasInputFocus ||
HasActivePointerCapture() ||
inputContext.focusGained ||
inputContext.focusLost,
.includePointerLeave =
inputContext.allowInteraction || HasActivePointerCapture()
},
inputContext.focusGained,
inputContext.focusLost);
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.");
}
if (m_systemInteractionHost == nullptr) {
return BuildEvaluationResult(false, "Project system host is unavailable.");
}
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.");
}
if (m_systemInteractionHost == nullptr) {
return BuildEvaluationResult(false, "Project system host is unavailable.");
}
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_lastPrimaryClickTime = {};
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 (m_systemInteractionHost == nullptr ||
!m_systemInteractionHost->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 (m_systemInteractionHost == nullptr ||
!m_systemInteractionHost->RevealPathInFileBrowser(
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();
m_lastPrimaryClickTime = {};
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,
const PanelInputContext& inputContext) {
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 =
BuildUIEditorPanelInputEvents(
panelState->bounds,
inputEvents,
UIEditorPanelInputFilterOptions{
.allowPointerInBounds = inputContext.allowInteraction,
.allowPointerWhileCaptured = HasActivePointerCapture(),
.allowKeyboardInput = inputContext.hasInputFocus,
.allowFocusEvents =
inputContext.hasInputFocus ||
HasActivePointerCapture() ||
inputContext.focusGained ||
inputContext.focusLost,
.includePointerLeave =
inputContext.allowInteraction || HasActivePointerCapture()
},
inputContext.focusGained,
inputContext.focusLost);
ClaimCommandFocus(filteredEvents, panelState->bounds, inputContext.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,
inputContext);
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_lastPrimaryClickTime = {};
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 auto now = std::chrono::steady_clock::now();
const bool doubleClicked =
alreadySelected &&
m_lastPrimaryClickedAssetId == assetEntry.itemId &&
m_lastPrimaryClickTime != std::chrono::steady_clock::time_point{} &&
now - m_lastPrimaryClickTime <= kGridDoubleClickInterval;
m_lastPrimaryClickedAssetId = assetEntry.itemId;
m_lastPrimaryClickTime = now;
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