#include "ProjectPanelInternal.h" #include "Ports/SystemInteractionPort.h" #include "Project/EditorProjectRuntime.h" #include "State/EditorCommandFocusService.h" #include #include #include #include #include #include #include #include #include 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& 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(); 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::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& 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::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 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 editTarget = ResolveEditCommandTarget( m_contextMenu.targetItemId, m_contextMenu.forceCurrentFolder); std::vector 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(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 ProjectPanel::BuildTreeInteractionInputEvents( const std::vector& inputEvents, const UIRect& bounds, const PanelInputContext& inputContext) const { const std::vector 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 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 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& 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& 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 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 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 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& assetEntries; std::function 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(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 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((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(index % static_cast(columnCount)); const int row = static_cast(index / static_cast(columnCount)); const float tileX = layout.gridRect.x + static_cast(column) * (kGridTileWidth + kGridTileGapX); const float tileY = layout.gridRect.y + static_cast(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