#include "ProductHierarchyPanel.h" #include "Icons/ProductBuiltInIcons.h" #include "Panels/ProductTreeViewStyle.h" #include #include #include #include #include namespace XCEngine::UI::Editor::App { namespace { using ::XCEngine::UI::UIColor; using ::XCEngine::UI::UIDrawList; using ::XCEngine::UI::UIInputEvent; using ::XCEngine::UI::UIInputEventType; using ::XCEngine::UI::UIPoint; using ::XCEngine::UI::UIPointerButton; using ::XCEngine::UI::UIRect; using Widgets::AppendUIEditorTreeViewBackground; using Widgets::AppendUIEditorTreeViewForeground; using Widgets::DoesUIEditorTreeViewItemHaveChildren; using Widgets::HitTestUIEditorTreeView; using Widgets::IsUIEditorTreeViewPointInside; using Widgets::UIEditorTreeViewHitTarget; using Widgets::UIEditorTreeViewHitTargetKind; using Widgets::UIEditorTreeViewInvalidIndex; constexpr std::string_view kHierarchyPanelId = "hierarchy"; constexpr float kDragThreshold = 4.0f; constexpr UIColor kDragPreviewColor(0.82f, 0.82f, 0.82f, 0.55f); bool ContainsPoint(const UIRect& rect, const UIPoint& point) { return point.x >= rect.x && point.x <= rect.x + rect.width && point.y >= rect.y && point.y <= rect.y + rect.height; } float ComputeSquaredDistance(const UIPoint& lhs, const UIPoint& rhs) { const float dx = lhs.x - rhs.x; const float dy = lhs.y - rhs.y; return dx * dx + dy * dy; } ::XCEngine::UI::UITextureHandle ResolveGameObjectIcon( const ProductBuiltInIcons* icons) { return icons != nullptr ? icons->Resolve(ProductBuiltInIconKind::GameObject) : ::XCEngine::UI::UITextureHandle {}; } std::vector FilterHierarchyInputEvents( const UIRect& bounds, const std::vector& inputEvents, bool allowInteraction, bool panelActive, bool captureActive) { if (!allowInteraction && !captureActive) { return {}; } std::vector filteredEvents = {}; filteredEvents.reserve(inputEvents.size()); for (const UIInputEvent& event : inputEvents) { switch (event.type) { case UIInputEventType::PointerMove: case UIInputEventType::PointerButtonDown: case UIInputEventType::PointerButtonUp: case UIInputEventType::PointerWheel: if (captureActive || ContainsPoint(bounds, event.position)) { filteredEvents.push_back(event); } break; case UIInputEventType::PointerLeave: filteredEvents.push_back(event); break; case UIInputEventType::FocusGained: case UIInputEventType::FocusLost: if (panelActive || captureActive) { filteredEvents.push_back(event); } break; case UIInputEventType::KeyDown: case UIInputEventType::KeyUp: case UIInputEventType::Character: if (panelActive) { filteredEvents.push_back(event); } break; default: break; } } return filteredEvents; } const Widgets::UIEditorTreeViewItem* ResolveHitItem( const Widgets::UIEditorTreeViewLayout& layout, const std::vector& items, const UIPoint& point, UIEditorTreeViewHitTarget* hitTargetOutput = nullptr) { const UIEditorTreeViewHitTarget hitTarget = HitTestUIEditorTreeView(layout, point); if (hitTargetOutput != nullptr) { *hitTargetOutput = hitTarget; } if (hitTarget.itemIndex >= items.size()) { return nullptr; } return &items[hitTarget.itemIndex]; } std::size_t FindVisibleIndexForItemId( const Widgets::UIEditorTreeViewLayout& layout, const std::vector& items, std::string_view itemId) { for (std::size_t visibleIndex = 0u; visibleIndex < layout.visibleItemIndices.size(); ++visibleIndex) { const std::size_t itemIndex = layout.visibleItemIndices[visibleIndex]; if (itemIndex < items.size() && items[itemIndex].itemId == itemId) { return visibleIndex; } } return UIEditorTreeViewInvalidIndex; } } // namespace void ProductHierarchyPanel::Initialize() { m_model = ProductHierarchyModel::BuildDefault(); RebuildItems(); } void ProductHierarchyPanel::SetBuiltInIcons(const ProductBuiltInIcons* icons) { m_icons = icons; RebuildItems(); } const UIEditorPanelContentHostPanelState* ProductHierarchyPanel::FindMountedHierarchyPanel( const UIEditorPanelContentHostFrame& contentHostFrame) const { for (const UIEditorPanelContentHostPanelState& panelState : contentHostFrame.panelStates) { if (panelState.panelId == kHierarchyPanelId && panelState.mounted) { return &panelState; } } return nullptr; } void ProductHierarchyPanel::ResetTransientState() { m_frameEvents.clear(); m_dragState.requestPointerCapture = false; m_dragState.requestPointerRelease = false; } void ProductHierarchyPanel::RebuildItems() { const auto icon = ResolveGameObjectIcon(m_icons); const std::string previousSelection = m_selection.HasSelection() ? m_selection.GetSelectedId() : std::string(); m_treeItems = m_model.BuildTreeItems(icon); m_expansion.Expand("player"); m_expansion.Expand("environment"); m_expansion.Expand("props"); if (!previousSelection.empty() && m_model.ContainsNode(previousSelection)) { m_selection.SetSelection(previousSelection); return; } if (!m_treeItems.empty()) { m_selection.SetSelection(m_treeItems.front().itemId); } } void ProductHierarchyPanel::EmitSelectionEvent() { if (!m_selection.HasSelection()) { return; } const ProductHierarchyNode* node = m_model.FindNode(m_selection.GetSelectedId()); if (node == nullptr) { return; } Event event = {}; event.kind = EventKind::SelectionChanged; event.itemId = node->nodeId; event.label = node->label; m_frameEvents.push_back(std::move(event)); } void ProductHierarchyPanel::EmitReparentEvent( EventKind kind, std::string itemId, std::string targetItemId) { Event event = {}; event.kind = kind; event.itemId = std::move(itemId); event.targetItemId = std::move(targetItemId); if (const ProductHierarchyNode* node = m_model.FindNode(event.itemId); node != nullptr) { event.label = node->label; } m_frameEvents.push_back(std::move(event)); } std::vector ProductHierarchyPanel::BuildInteractionInputEvents( const std::vector& inputEvents, const UIRect& bounds, bool allowInteraction, bool panelActive) const { const std::vector rawEvents = FilterHierarchyInputEvents( bounds, inputEvents, allowInteraction, panelActive, HasActivePointerCapture()); struct DragPreviewState { std::string armedItemId = {}; UIPoint pressPosition = {}; bool armed = false; bool dragging = false; }; const Widgets::UIEditorTreeViewLayout layout = m_treeFrame.layout.bounds.width > 0.0f ? m_treeFrame.layout : Widgets::BuildUIEditorTreeViewLayout( bounds, m_treeItems, m_expansion, BuildProductTreeViewMetrics()); DragPreviewState preview = {}; preview.armed = m_dragState.armed; preview.armedItemId = m_dragState.armedItemId; preview.pressPosition = m_dragState.pressPosition; preview.dragging = m_dragState.dragging; std::vector filteredEvents = {}; filteredEvents.reserve(rawEvents.size()); for (const UIInputEvent& event : rawEvents) { bool suppress = false; switch (event.type) { case UIInputEventType::PointerButtonDown: if (event.pointerButton == UIPointerButton::Left) { UIEditorTreeViewHitTarget hitTarget = {}; const Widgets::UIEditorTreeViewItem* hitItem = ResolveHitItem(layout, m_treeItems, event.position, &hitTarget); if (hitItem != nullptr && hitTarget.kind == UIEditorTreeViewHitTargetKind::Row) { preview.armed = true; preview.armedItemId = hitItem->itemId; preview.pressPosition = event.position; } else { preview.armed = false; preview.armedItemId.clear(); } } if (preview.dragging) { suppress = true; } break; case UIInputEventType::PointerMove: if (preview.dragging) { suppress = true; break; } if (preview.armed && ComputeSquaredDistance(event.position, preview.pressPosition) >= kDragThreshold * kDragThreshold) { preview.dragging = true; suppress = true; } break; case UIInputEventType::PointerButtonUp: if (event.pointerButton == UIPointerButton::Left) { if (preview.dragging) { suppress = true; preview.dragging = false; } preview.armed = false; preview.armedItemId.clear(); } else if (preview.dragging) { suppress = true; } break; case UIInputEventType::PointerLeave: if (preview.dragging) { suppress = true; } break; case UIInputEventType::FocusLost: preview.armed = false; preview.dragging = false; preview.armedItemId.clear(); break; default: break; } if (!suppress) { filteredEvents.push_back(event); } } return filteredEvents; } void ProductHierarchyPanel::ProcessDragAndFrameEvents( const std::vector& inputEvents, const UIRect& bounds, bool allowInteraction, bool panelActive) { const std::vector filteredEvents = FilterHierarchyInputEvents( bounds, inputEvents, allowInteraction, panelActive, HasActivePointerCapture()); if (m_treeFrame.result.selectionChanged) { EmitSelectionEvent(); } if (m_treeFrame.result.renameRequested && !m_treeFrame.result.renameItemId.empty()) { Event event = {}; event.kind = EventKind::RenameRequested; event.itemId = m_treeFrame.result.renameItemId; if (const ProductHierarchyNode* node = m_model.FindNode(event.itemId); node != nullptr) { event.label = node->label; } m_frameEvents.push_back(std::move(event)); } for (const UIInputEvent& event : filteredEvents) { switch (event.type) { case UIInputEventType::PointerButtonDown: if (event.pointerButton == UIPointerButton::Left) { UIEditorTreeViewHitTarget hitTarget = {}; const Widgets::UIEditorTreeViewItem* hitItem = ResolveHitItem(m_treeFrame.layout, m_treeItems, event.position, &hitTarget); if (hitItem != nullptr && hitTarget.kind == UIEditorTreeViewHitTargetKind::Row) { m_dragState.armed = true; m_dragState.armedItemId = hitItem->itemId; m_dragState.pressPosition = event.position; } else { m_dragState.armed = false; m_dragState.armedItemId.clear(); } } break; case UIInputEventType::PointerMove: if (m_dragState.armed && !m_dragState.dragging && ComputeSquaredDistance(event.position, m_dragState.pressPosition) >= kDragThreshold * kDragThreshold) { m_dragState.dragging = !m_dragState.armedItemId.empty(); m_dragState.draggedItemId = m_dragState.armedItemId; m_dragState.dropTargetItemId.clear(); m_dragState.dropToRoot = false; m_dragState.validDropTarget = false; if (m_dragState.dragging) { m_dragState.requestPointerCapture = true; if (!m_selection.IsSelected(m_dragState.draggedItemId)) { m_selection.SetSelection(m_dragState.draggedItemId); EmitSelectionEvent(); } } } if (m_dragState.dragging) { UIEditorTreeViewHitTarget hitTarget = {}; const Widgets::UIEditorTreeViewItem* hitItem = ResolveHitItem(m_treeFrame.layout, m_treeItems, event.position, &hitTarget); m_dragState.dropTargetItemId.clear(); m_dragState.dropToRoot = false; m_dragState.validDropTarget = false; if (hitItem != nullptr && (hitTarget.kind == UIEditorTreeViewHitTargetKind::Row || hitTarget.kind == UIEditorTreeViewHitTargetKind::Disclosure)) { m_dragState.dropTargetItemId = hitItem->itemId; m_dragState.validDropTarget = m_model.CanReparent( m_dragState.draggedItemId, m_dragState.dropTargetItemId); } else if (ContainsPoint(bounds, event.position)) { m_dragState.dropToRoot = true; m_dragState.validDropTarget = m_model.GetParentId(m_dragState.draggedItemId).has_value(); } } break; case UIInputEventType::PointerButtonUp: if (event.pointerButton != UIPointerButton::Left) { break; } if (m_dragState.dragging) { if (m_dragState.validDropTarget) { const std::string draggedItemId = m_dragState.draggedItemId; const std::string dropTargetItemId = m_dragState.dropTargetItemId; const bool changed = m_dragState.dropToRoot ? m_model.MoveToRoot(draggedItemId) : m_model.Reparent(draggedItemId, dropTargetItemId); if (changed) { RebuildItems(); EmitReparentEvent( m_dragState.dropToRoot ? EventKind::MovedToRoot : EventKind::Reparented, draggedItemId, dropTargetItemId); } } m_dragState.armed = false; m_dragState.dragging = false; m_dragState.armedItemId.clear(); m_dragState.draggedItemId.clear(); m_dragState.dropTargetItemId.clear(); m_dragState.dropToRoot = false; m_dragState.validDropTarget = false; m_dragState.requestPointerRelease = true; } else { m_dragState.armed = false; m_dragState.armedItemId.clear(); } break; case UIInputEventType::FocusLost: if (m_dragState.dragging) { m_dragState.requestPointerRelease = true; } m_dragState = {}; break; default: break; } } } void ProductHierarchyPanel::Update( const UIEditorPanelContentHostFrame& contentHostFrame, const std::vector& inputEvents, bool allowInteraction, bool panelActive) { ResetTransientState(); const UIEditorPanelContentHostPanelState* panelState = FindMountedHierarchyPanel(contentHostFrame); if (panelState == nullptr) { m_visible = false; m_treeFrame = {}; m_dragState = {}; return; } if (m_treeItems.empty()) { RebuildItems(); } m_visible = true; const std::vector interactionEvents = BuildInteractionInputEvents( inputEvents, panelState->bounds, allowInteraction, panelActive); m_treeFrame = UpdateUIEditorTreeViewInteraction( m_treeInteractionState, m_selection, m_expansion, panelState->bounds, m_treeItems, interactionEvents, BuildProductTreeViewMetrics()); ProcessDragAndFrameEvents( inputEvents, panelState->bounds, allowInteraction, panelActive); } void ProductHierarchyPanel::Append(UIDrawList& drawList) const { if (!m_visible || m_treeFrame.layout.bounds.width <= 0.0f || m_treeFrame.layout.bounds.height <= 0.0f) { return; } const Widgets::UIEditorTreeViewPalette palette = BuildProductTreeViewPalette(); const Widgets::UIEditorTreeViewMetrics metrics = BuildProductTreeViewMetrics(); AppendUIEditorTreeViewBackground( drawList, m_treeFrame.layout, m_treeItems, m_selection, m_treeInteractionState.treeViewState, palette, metrics); AppendUIEditorTreeViewForeground( drawList, m_treeFrame.layout, m_treeItems, palette, metrics); if (!m_dragState.dragging || !m_dragState.validDropTarget) { return; } if (m_dragState.dropToRoot) { drawList.AddRectOutline( m_treeFrame.layout.bounds, kDragPreviewColor, 1.0f, 0.0f); return; } const std::size_t visibleIndex = FindVisibleIndexForItemId( m_treeFrame.layout, m_treeItems, m_dragState.dropTargetItemId); if (visibleIndex == UIEditorTreeViewInvalidIndex || visibleIndex >= m_treeFrame.layout.rowRects.size()) { return; } drawList.AddRectOutline( m_treeFrame.layout.rowRects[visibleIndex], kDragPreviewColor, 1.0f, 0.0f); } bool ProductHierarchyPanel::WantsHostPointerCapture() const { return m_dragState.requestPointerCapture; } bool ProductHierarchyPanel::WantsHostPointerRelease() const { return m_dragState.requestPointerRelease; } bool ProductHierarchyPanel::HasActivePointerCapture() const { return m_dragState.dragging; } const std::vector& ProductHierarchyPanel::GetFrameEvents() const { return m_frameEvents; } } // namespace XCEngine::UI::Editor::App