#include "ProjectPanelInternal.h" #include #include namespace XCEngine::UI::Editor::App { using namespace ProjectPanelInternal; namespace { UIEditorHostCommandEvaluationResult BuildEvaluationResult( bool executable, std::string message) { UIEditorHostCommandEvaluationResult result = {}; result.executable = executable; result.message = std::move(message); return result; } UIEditorHostCommandDispatchResult BuildDispatchResult( bool commandExecuted, std::string message) { UIEditorHostCommandDispatchResult result = {}; result.commandExecuted = commandExecuted; result.message = std::move(message); return result; } } // namespace void ProjectPanel::Initialize(const std::filesystem::path& repoRoot) { m_browserModel.Initialize(repoRoot); SyncCurrentFolderSelection(); } void ProjectPanel::SetBuiltInIcons(const BuiltInIcons* icons) { m_icons = icons; m_browserModel.SetFolderIcon(ResolveFolderIcon(m_icons)); SyncCurrentFolderSelection(); } void ProjectPanel::SetTextMeasurer(const UIEditorTextMeasurer* textMeasurer) { m_textMeasurer = textMeasurer; } void ProjectPanel::ResetInteractionState() { m_treeInteractionState = {}; m_treeFrame = {}; m_frameEvents.clear(); m_layout = {}; m_hoveredAssetItemId.clear(); m_lastPrimaryClickedAssetId.clear(); m_hoveredBreadcrumbIndex = kInvalidLayoutIndex; m_pressedBreadcrumbIndex = kInvalidLayoutIndex; m_visible = false; m_splitterHovered = false; m_splitterDragging = false; m_requestPointerCapture = false; m_requestPointerRelease = false; } ProjectPanel::CursorKind ProjectPanel::GetCursorKind() const { return (m_splitterHovered || m_splitterDragging) ? CursorKind::ResizeEW : CursorKind::Arrow; } bool ProjectPanel::WantsHostPointerCapture() const { return m_requestPointerCapture; } bool ProjectPanel::WantsHostPointerRelease() const { return m_requestPointerRelease; } bool ProjectPanel::HasActivePointerCapture() const { return m_splitterDragging; } const std::vector& ProjectPanel::GetFrameEvents() const { return m_frameEvents; } const ProjectPanel::FolderEntry* ProjectPanel::FindFolderEntry(std::string_view itemId) const { return m_browserModel.FindFolderEntry(itemId); } const ProjectPanel::AssetEntry* ProjectPanel::FindAssetEntry(std::string_view itemId) const { return m_browserModel.FindAssetEntry(itemId); } std::optional ProjectPanel::ResolveEditCommandTarget() const { if (m_assetSelection.HasSelection()) { const AssetEntry* asset = FindAssetEntry(m_assetSelection.GetSelectedId()); if (asset != nullptr) { EditCommandTarget target = {}; target.itemId = asset->itemId; target.absolutePath = asset->absolutePath; target.displayName = asset->displayName; target.directory = asset->directory; return target; } } if (!m_folderSelection.HasSelection()) { return std::nullopt; } const FolderEntry* folder = FindFolderEntry(m_folderSelection.GetSelectedId()); if (folder == nullptr) { return std::nullopt; } EditCommandTarget target = {}; target.itemId = folder->itemId; target.absolutePath = folder->absolutePath; target.displayName = folder->label; target.directory = true; target.assetsRoot = folder->absolutePath == m_browserModel.GetAssetsRootPath(); return target; } 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() { const std::string& currentFolderId = m_browserModel.GetCurrentFolderId(); if (currentFolderId.empty()) { m_folderSelection.ClearSelection(); return; } const std::vector ancestorFolderIds = m_browserModel.CollectCurrentFolderAncestorIds(); for (const std::string& ancestorFolderId : ancestorFolderIds) { m_folderExpansion.Expand(ancestorFolderId); } m_folderSelection.SetSelection(currentFolderId); } bool ProjectPanel::NavigateToFolder(std::string_view itemId, EventSource source) { if (!m_browserModel.NavigateToFolder(itemId)) { return false; } SyncCurrentFolderSelection(); m_assetSelection.ClearSelection(); m_hoveredAssetItemId.clear(); m_lastPrimaryClickedAssetId.clear(); EmitEvent(EventKind::FolderNavigated, source, FindFolderEntry(m_browserModel.GetCurrentFolderId())); return true; } 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.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.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)); } UIEditorHostCommandEvaluationResult ProjectPanel::EvaluateEditCommand( std::string_view commandId) const { const std::optional target = ResolveEditCommandTarget(); if (!target.has_value()) { return BuildEvaluationResult(false, "Select an asset or folder in Project first."); } if (target->assetsRoot && (commandId == "edit.rename" || commandId == "edit.delete")) { return BuildEvaluationResult(false, "The Assets root cannot be renamed or deleted."); } if (commandId == "edit.rename") { return BuildEvaluationResult( true, "Rename project item '" + target->displayName + "'."); } if (commandId == "edit.delete") { return BuildEvaluationResult( false, "Project delete is blocked until asset metadata ownership is wired."); } if (commandId == "edit.duplicate") { return BuildEvaluationResult( false, "Project duplicate is blocked until asset metadata rewrite is wired."); } if (commandId == "edit.cut" || commandId == "edit.copy" || commandId == "edit.paste") { return BuildEvaluationResult( false, "Project clipboard has no bound asset transfer owner in the current shell."); } return BuildEvaluationResult(false, "Project does not expose this edit command."); } UIEditorHostCommandDispatchResult ProjectPanel::DispatchEditCommand( std::string_view commandId) { const UIEditorHostCommandEvaluationResult evaluation = EvaluateEditCommand(commandId); if (!evaluation.executable) { return BuildDispatchResult(false, evaluation.message); } if (commandId == "edit.rename") { if (m_assetSelection.HasSelection()) { if (const AssetEntry* asset = FindAssetEntry(m_assetSelection.GetSelectedId()); asset != nullptr) { EmitEvent(EventKind::RenameRequested, EventSource::None, asset); return BuildDispatchResult( true, "Project rename requested for '" + asset->displayName + "'."); } } if (m_folderSelection.HasSelection()) { if (const FolderEntry* folder = FindFolderEntry(m_folderSelection.GetSelectedId()); folder != nullptr) { EmitEvent(EventKind::RenameRequested, EventSource::None, folder); return BuildDispatchResult( true, "Project rename requested for '" + folder->label + "'."); } } return BuildDispatchResult(false, "Select an asset or folder in Project first."); } return BuildDispatchResult(false, "Project does not expose this edit command."); } void ProjectPanel::ResetTransientFrames() { m_treeFrame = {}; m_frameEvents.clear(); m_layout = {}; m_hoveredAssetItemId.clear(); m_hoveredBreadcrumbIndex = kInvalidLayoutIndex; m_pressedBreadcrumbIndex = kInvalidLayoutIndex; m_splitterHovered = false; m_splitterDragging = false; } void ProjectPanel::Update( const UIEditorPanelContentHostFrame& contentHostFrame, const std::vector& inputEvents, bool allowInteraction, bool panelActive) { m_requestPointerCapture = false; m_requestPointerRelease = false; m_frameEvents.clear(); const UIEditorPanelContentHostPanelState* panelState = FindMountedProjectPanel(contentHostFrame); if (panelState == nullptr) { if (m_splitterDragging) { m_requestPointerRelease = true; } m_visible = false; ResetTransientFrames(); return; } if (m_browserModel.GetTreeItems().empty()) { m_browserModel.Refresh(); SyncCurrentFolderSelection(); } m_visible = true; const std::vector filteredEvents = FilterProjectPanelInputEvents( panelState->bounds, inputEvents, allowInteraction, panelActive, m_splitterDragging); m_navigationWidth = ClampNavigationWidth(m_navigationWidth, panelState->bounds.width); m_layout = BuildLayout(panelState->bounds); const Widgets::UIEditorTreeViewMetrics treeMetrics = ResolveUIEditorTreeViewMetrics(); const std::vector treeEvents = FilterTreeInputEvents(filteredEvents, m_splitterDragging); m_treeFrame = UpdateUIEditorTreeViewInteraction( m_treeInteractionState, m_folderSelection, m_folderExpansion, m_layout.treeRect, m_browserModel.GetTreeItems(), treeEvents, treeMetrics); if (m_treeFrame.result.selectionChanged && !m_treeFrame.result.selectedItemId.empty() && m_treeFrame.result.selectedItemId != m_browserModel.GetCurrentFolderId()) { NavigateToFolder(m_treeFrame.result.selectedItemId, EventSource::Tree); m_layout = BuildLayout(panelState->bounds); } for (const UIInputEvent& event : filteredEvents) { switch (event.type) { case UIInputEventType::FocusLost: m_hoveredAssetItemId.clear(); m_hoveredBreadcrumbIndex = kInvalidLayoutIndex; m_pressedBreadcrumbIndex = kInvalidLayoutIndex; m_splitterHovered = false; if (m_splitterDragging) { m_splitterDragging = false; m_requestPointerRelease = true; } break; case UIInputEventType::PointerMove: { if (m_splitterDragging) { m_navigationWidth = ClampNavigationWidth(event.position.x - panelState->bounds.x, panelState->bounds.width); m_layout = BuildLayout(panelState->bounds); } m_splitterHovered = m_splitterDragging || ContainsPoint(m_layout.dividerRect, event.position); m_hoveredBreadcrumbIndex = HitTestBreadcrumbItem(event.position); const std::size_t hoveredAssetIndex = HitTestAssetTile(event.position); const auto& assetEntries = m_browserModel.GetAssetEntries(); m_hoveredAssetItemId = hoveredAssetIndex < assetEntries.size() ? assetEntries[hoveredAssetIndex].itemId : std::string(); break; } case UIInputEventType::PointerLeave: if (!m_splitterDragging) { m_splitterHovered = false; } m_hoveredBreadcrumbIndex = kInvalidLayoutIndex; m_hoveredAssetItemId.clear(); break; case UIInputEventType::PointerButtonDown: if (event.pointerButton == ::XCEngine::UI::UIPointerButton::Left) { if (ContainsPoint(m_layout.dividerRect, event.position)) { m_splitterDragging = true; m_splitterHovered = true; m_pressedBreadcrumbIndex = kInvalidLayoutIndex; m_requestPointerCapture = true; break; } m_pressedBreadcrumbIndex = HitTestBreadcrumbItem(event.position); if (!ContainsPoint(m_layout.gridRect, event.position)) { break; } const auto& assetEntries = m_browserModel.GetAssetEntries(); const std::size_t hitIndex = HitTestAssetTile(event.position); if (hitIndex >= assetEntries.size()) { if (m_assetSelection.HasSelection()) { m_assetSelection.ClearSelection(); EmitSelectionClearedEvent(EventSource::Background); } break; } const AssetEntry& assetEntry = assetEntries[hitIndex]; const bool alreadySelected = m_assetSelection.IsSelected(assetEntry.itemId); const bool selectionChanged = m_assetSelection.SetSelection(assetEntry.itemId); if (selectionChanged) { EmitEvent(EventKind::AssetSelected, EventSource::GridPrimary, &assetEntry); } const std::uint64_t nowMs = GetTickCount64(); const std::uint64_t doubleClickThresholdMs = static_cast(GetDoubleClickTime()); const bool doubleClicked = alreadySelected && m_lastPrimaryClickedAssetId == assetEntry.itemId && nowMs >= m_lastPrimaryClickTimeMs && nowMs - m_lastPrimaryClickTimeMs <= doubleClickThresholdMs; m_lastPrimaryClickedAssetId = assetEntry.itemId; m_lastPrimaryClickTimeMs = nowMs; if (!doubleClicked) { break; } if (assetEntry.directory) { NavigateToFolder(assetEntry.itemId, EventSource::GridDoubleClick); m_layout = BuildLayout(panelState->bounds); m_hoveredAssetItemId.clear(); } else { EmitEvent(EventKind::AssetOpened, EventSource::GridDoubleClick, &assetEntry); } break; } if (event.pointerButton == ::XCEngine::UI::UIPointerButton::Right && ContainsPoint(m_layout.gridRect, event.position)) { const auto& assetEntries = m_browserModel.GetAssetEntries(); const std::size_t hitIndex = HitTestAssetTile(event.position); if (hitIndex >= assetEntries.size()) { EmitEvent( EventKind::ContextMenuRequested, EventSource::Background, static_cast(nullptr)); break; } const AssetEntry& assetEntry = assetEntries[hitIndex]; if (!m_assetSelection.IsSelected(assetEntry.itemId)) { m_assetSelection.SetSelection(assetEntry.itemId); EmitEvent(EventKind::AssetSelected, EventSource::GridSecondary, &assetEntry); } EmitEvent(EventKind::ContextMenuRequested, EventSource::GridSecondary, &assetEntry); } break; case UIInputEventType::PointerButtonUp: if (event.pointerButton != ::XCEngine::UI::UIPointerButton::Left) { break; } if (m_splitterDragging) { m_splitterDragging = false; m_splitterHovered = ContainsPoint(m_layout.dividerRect, event.position); m_requestPointerRelease = true; break; } { const std::size_t releasedBreadcrumbIndex = HitTestBreadcrumbItem(event.position); if (m_pressedBreadcrumbIndex != kInvalidLayoutIndex && m_pressedBreadcrumbIndex == releasedBreadcrumbIndex && releasedBreadcrumbIndex < m_layout.breadcrumbItems.size()) { const BreadcrumbItemLayout& item = m_layout.breadcrumbItems[releasedBreadcrumbIndex]; if (item.clickable) { NavigateToFolder(item.targetFolderId, EventSource::Breadcrumb); m_layout = BuildLayout(panelState->bounds); } } m_pressedBreadcrumbIndex = kInvalidLayoutIndex; } break; default: break; } } } ProjectPanel::Layout ProjectPanel::BuildLayout(const UIRect& bounds) const { Layout layout = {}; const auto& assetEntries = m_browserModel.GetAssetEntries(); const std::vector breadcrumbSegments = m_browserModel.BuildBreadcrumbSegments(); const float dividerThickness = ResolveUIEditorDockHostMetrics().splitterMetrics.thickness; layout.bounds = UIRect( bounds.x, bounds.y, ClampNonNegative(bounds.width), ClampNonNegative(bounds.height)); const float leftWidth = ClampNavigationWidth(m_navigationWidth, layout.bounds.width); layout.leftPaneRect = UIRect( layout.bounds.x, layout.bounds.y, leftWidth, layout.bounds.height); layout.dividerRect = UIRect( layout.leftPaneRect.x + layout.leftPaneRect.width, layout.bounds.y, dividerThickness, layout.bounds.height); layout.rightPaneRect = UIRect( layout.dividerRect.x + layout.dividerRect.width, layout.bounds.y, ClampNonNegative(layout.bounds.width - layout.leftPaneRect.width - layout.dividerRect.width), layout.bounds.height); layout.treeRect = UIRect( layout.leftPaneRect.x, layout.leftPaneRect.y + kTreeTopPadding, layout.leftPaneRect.width, ClampNonNegative(layout.leftPaneRect.height - kTreeTopPadding)); layout.browserHeaderRect = UIRect( layout.rightPaneRect.x, layout.rightPaneRect.y, layout.rightPaneRect.width, (std::min)(kBrowserHeaderHeight, layout.rightPaneRect.height)); layout.browserBodyRect = UIRect( layout.rightPaneRect.x, layout.browserHeaderRect.y + layout.browserHeaderRect.height, layout.rightPaneRect.width, ClampNonNegative(layout.rightPaneRect.height - layout.browserHeaderRect.height)); layout.gridRect = UIRect( layout.browserBodyRect.x + kGridInsetX, layout.browserBodyRect.y + kGridInsetY, ClampNonNegative(layout.browserBodyRect.width - kGridInsetX * 2.0f), ClampNonNegative(layout.browserBodyRect.height - kGridInsetY * 2.0f)); const float breadcrumbRowHeight = kHeaderFontSize + kBreadcrumbItemPaddingY * 2.0f; const float breadcrumbY = layout.browserHeaderRect.y + std::floor((layout.browserHeaderRect.height - breadcrumbRowHeight) * 0.5f); const float headerRight = layout.browserHeaderRect.x + layout.browserHeaderRect.width - kHeaderHorizontalPadding; float nextItemX = layout.browserHeaderRect.x + kHeaderHorizontalPadding; for (std::size_t index = 0u; index < breadcrumbSegments.size(); ++index) { if (index > 0u) { const float separatorWidth = MeasureTextWidth(m_textMeasurer, ">", kHeaderFontSize); if (nextItemX < headerRight && separatorWidth > 0.0f) { layout.breadcrumbItems.push_back({ ">", {}, UIRect( nextItemX, breadcrumbY, ClampNonNegative((std::min)(separatorWidth, headerRight - nextItemX)), breadcrumbRowHeight), true, false, false }); } nextItemX += separatorWidth + kBreadcrumbSpacing; } const ProjectBrowserModel::BreadcrumbSegment& segment = breadcrumbSegments[index]; const float labelWidth = MeasureTextWidth(m_textMeasurer, segment.label, kHeaderFontSize); const float itemWidth = labelWidth + kBreadcrumbItemPaddingX * 2.0f; const float availableWidth = headerRight - nextItemX; if (availableWidth <= 0.0f) { break; } layout.breadcrumbItems.push_back({ segment.label, segment.targetFolderId, UIRect( nextItemX, breadcrumbY, ClampNonNegative((std::min)(itemWidth, availableWidth)), breadcrumbRowHeight), false, !segment.current, segment.current }); nextItemX += itemWidth + kBreadcrumbSpacing; } const float effectiveTileWidth = kGridTileWidth + kGridTileGapX; int columnCount = effectiveTileWidth > 0.0f ? static_cast((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; } void ProjectPanel::Append(UIDrawList& drawList) const { if (!m_visible || m_layout.bounds.width <= 0.0f || m_layout.bounds.height <= 0.0f) { return; } const auto& assetEntries = m_browserModel.GetAssetEntries(); drawList.AddFilledRect(m_layout.bounds, kSurfaceColor); drawList.AddFilledRect(m_layout.leftPaneRect, kPaneColor); drawList.AddFilledRect(m_layout.rightPaneRect, kPaneColor); drawList.AddFilledRect( m_layout.dividerRect, ResolveUIEditorDockHostPalette().splitterColor); drawList.AddFilledRect(m_layout.browserHeaderRect, kHeaderColor); drawList.AddFilledRect( UIRect( m_layout.browserHeaderRect.x, m_layout.browserHeaderRect.y + m_layout.browserHeaderRect.height - kHeaderBottomBorderThickness, m_layout.browserHeaderRect.width, kHeaderBottomBorderThickness), ResolveUIEditorDockHostPalette().splitterColor); const Widgets::UIEditorTreeViewPalette treePalette = ResolveUIEditorTreeViewPalette(); const Widgets::UIEditorTreeViewMetrics treeMetrics = ResolveUIEditorTreeViewMetrics(); AppendUIEditorTreeViewBackground( drawList, m_treeFrame.layout, m_browserModel.GetTreeItems(), m_folderSelection, m_treeInteractionState.treeViewState, treePalette, treeMetrics); AppendUIEditorTreeViewForeground( drawList, m_treeFrame.layout, m_browserModel.GetTreeItems(), treePalette, treeMetrics); drawList.PushClipRect(m_layout.browserHeaderRect); for (std::size_t index = 0u; index < m_layout.breadcrumbItems.size(); ++index) { const BreadcrumbItemLayout& item = m_layout.breadcrumbItems[index]; const UIColor textColor = item.separator ? kTextMuted : (index == m_hoveredBreadcrumbIndex && item.clickable ? kTextStrong : (item.current ? kTextPrimary : kTextMuted)); const float textWidth = MeasureTextWidth(m_textMeasurer, item.label, kHeaderFontSize); const float textX = item.separator ? item.rect.x : item.rect.x + (item.rect.width - textWidth) * 0.5f; drawList.AddText( UIPoint(textX, ResolveTextTop(item.rect.y, item.rect.height, kHeaderFontSize)), item.label, textColor, kHeaderFontSize); } drawList.PopClipRect(); for (const AssetTileLayout& tile : m_layout.assetTiles) { if (tile.itemIndex >= assetEntries.size()) { continue; } const AssetEntry& assetEntry = assetEntries[tile.itemIndex]; const bool selected = m_assetSelection.IsSelected(assetEntry.itemId); const bool hovered = m_hoveredAssetItemId == assetEntry.itemId; if (selected || hovered) { drawList.AddFilledRect( tile.tileRect, selected ? kTileSelectedColor : kTileHoverColor); } AppendTilePreview(drawList, tile.previewRect, assetEntry.directory); drawList.PushClipRect(tile.labelRect); const float textWidth = MeasureTextWidth(m_textMeasurer, assetEntry.displayName, kTileLabelFontSize); drawList.AddText( UIPoint( tile.labelRect.x + (tile.labelRect.width - textWidth) * 0.5f, ResolveTextTop(tile.labelRect.y, tile.labelRect.height, kTileLabelFontSize)), assetEntry.displayName, kTextPrimary, kTileLabelFontSize); drawList.PopClipRect(); } if (assetEntries.empty()) { const UIRect messageRect( m_layout.gridRect.x, m_layout.gridRect.y, m_layout.gridRect.width, 18.0f); drawList.AddText( UIPoint(messageRect.x, ResolveTextTop(messageRect.y, messageRect.height, kHeaderFontSize)), "Current folder is empty.", kTextMuted, kHeaderFontSize); } } } // namespace XCEngine::UI::Editor::App