diff --git a/new_editor/CMakeLists.txt b/new_editor/CMakeLists.txt index 7567c671..6cc9fa3d 100644 --- a/new_editor/CMakeLists.txt +++ b/new_editor/CMakeLists.txt @@ -24,6 +24,7 @@ add_library(XCUIEditorLib STATIC src/Core/UIEditorShellCompose.cpp src/Core/UIEditorShellInteraction.cpp src/Core/UIEditorShortcutManager.cpp + src/Core/UIEditorTreeViewInteraction.cpp src/Core/UIEditorViewportInputBridge.cpp src/Core/UIEditorViewportShell.cpp src/Core/UIEditorWorkspaceCompose.cpp @@ -39,6 +40,7 @@ add_library(XCUIEditorLib STATIC src/Widgets/UIEditorPanelFrame.cpp src/Widgets/UIEditorStatusBar.cpp src/Widgets/UIEditorTabStrip.cpp + src/Widgets/UIEditorTreeView.cpp src/Widgets/UIEditorViewportSlot.cpp ) diff --git a/new_editor/include/XCEditor/Core/UIEditorTreeViewInteraction.h b/new_editor/include/XCEditor/Core/UIEditorTreeViewInteraction.h new file mode 100644 index 00000000..07d5007f --- /dev/null +++ b/new_editor/include/XCEditor/Core/UIEditorTreeViewInteraction.h @@ -0,0 +1,44 @@ +#pragma once + +#include + +#include +#include +#include + +#include +#include + +namespace XCEngine::UI::Editor { + +struct UIEditorTreeViewInteractionState { + Widgets::UIEditorTreeViewState treeViewState = {}; + ::XCEngine::UI::UIPoint pointerPosition = {}; + bool hasPointerPosition = false; +}; + +struct UIEditorTreeViewInteractionResult { + bool consumed = false; + bool selectionChanged = false; + bool expansionChanged = false; + bool secondaryClicked = false; + Widgets::UIEditorTreeViewHitTarget hitTarget = {}; + std::string selectedItemId = {}; + std::string toggledItemId = {}; +}; + +struct UIEditorTreeViewInteractionFrame { + Widgets::UIEditorTreeViewLayout layout = {}; + UIEditorTreeViewInteractionResult result = {}; +}; + +UIEditorTreeViewInteractionFrame UpdateUIEditorTreeViewInteraction( + UIEditorTreeViewInteractionState& state, + ::XCEngine::UI::Widgets::UISelectionModel& selectionModel, + ::XCEngine::UI::Widgets::UIExpansionModel& expansionModel, + const ::XCEngine::UI::UIRect& bounds, + const std::vector& items, + const std::vector<::XCEngine::UI::UIInputEvent>& inputEvents, + const Widgets::UIEditorTreeViewMetrics& metrics = {}); + +} // namespace XCEngine::UI::Editor diff --git a/new_editor/include/XCEditor/Widgets/UIEditorTreeView.h b/new_editor/include/XCEditor/Widgets/UIEditorTreeView.h new file mode 100644 index 00000000..20af2762 --- /dev/null +++ b/new_editor/include/XCEditor/Widgets/UIEditorTreeView.h @@ -0,0 +1,145 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace XCEngine::UI::Editor::Widgets { + +inline constexpr std::size_t UIEditorTreeViewInvalidIndex = static_cast(-1); + +enum class UIEditorTreeViewHitTargetKind : std::uint8_t { + None = 0, + Row, + Disclosure +}; + +struct UIEditorTreeViewItem { + std::string itemId = {}; + std::string label = {}; + std::uint32_t depth = 0u; + bool forceLeaf = false; + float desiredHeight = 0.0f; +}; + +struct UIEditorTreeViewState { + std::string hoveredItemId = {}; + bool focused = false; +}; + +struct UIEditorTreeViewMetrics { + float rowHeight = 28.0f; + float rowGap = 2.0f; + float horizontalPadding = 8.0f; + float indentWidth = 18.0f; + float disclosureExtent = 12.0f; + float disclosureLabelGap = 6.0f; + float labelInsetY = 6.0f; + float cornerRounding = 6.0f; + float borderThickness = 1.0f; + float focusedBorderThickness = 2.0f; +}; + +struct UIEditorTreeViewPalette { + ::XCEngine::UI::UIColor surfaceColor = + ::XCEngine::UI::UIColor(0.14f, 0.14f, 0.14f, 1.0f); + ::XCEngine::UI::UIColor borderColor = + ::XCEngine::UI::UIColor(0.29f, 0.29f, 0.29f, 1.0f); + ::XCEngine::UI::UIColor focusedBorderColor = + ::XCEngine::UI::UIColor(0.84f, 0.84f, 0.84f, 1.0f); + ::XCEngine::UI::UIColor rowHoverColor = + ::XCEngine::UI::UIColor(0.24f, 0.24f, 0.24f, 1.0f); + ::XCEngine::UI::UIColor rowSelectedColor = + ::XCEngine::UI::UIColor(0.32f, 0.32f, 0.32f, 1.0f); + ::XCEngine::UI::UIColor rowSelectedFocusedColor = + ::XCEngine::UI::UIColor(0.40f, 0.40f, 0.40f, 1.0f); + ::XCEngine::UI::UIColor disclosureColor = + ::XCEngine::UI::UIColor(0.74f, 0.74f, 0.74f, 1.0f); + ::XCEngine::UI::UIColor textColor = + ::XCEngine::UI::UIColor(0.94f, 0.94f, 0.94f, 1.0f); +}; + +struct UIEditorTreeViewLayout { + ::XCEngine::UI::UIRect bounds = {}; + std::vector visibleItemIndices = {}; + std::vector<::XCEngine::UI::UIRect> rowRects = {}; + std::vector<::XCEngine::UI::UIRect> disclosureRects = {}; + std::vector<::XCEngine::UI::UIRect> labelRects = {}; + std::vector itemHasChildren = {}; + std::vector itemExpanded = {}; +}; + +struct UIEditorTreeViewHitTarget { + UIEditorTreeViewHitTargetKind kind = UIEditorTreeViewHitTargetKind::None; + std::size_t visibleIndex = UIEditorTreeViewInvalidIndex; + std::size_t itemIndex = UIEditorTreeViewInvalidIndex; +}; + +bool IsUIEditorTreeViewPointInside( + const ::XCEngine::UI::UIRect& rect, + const ::XCEngine::UI::UIPoint& point); + +bool DoesUIEditorTreeViewItemHaveChildren( + const std::vector& items, + std::size_t itemIndex); + +std::size_t FindUIEditorTreeViewItemIndex( + const std::vector& items, + std::string_view itemId); + +std::size_t FindUIEditorTreeViewParentItemIndex( + const std::vector& items, + std::size_t itemIndex); + +std::vector CollectUIEditorTreeViewVisibleItemIndices( + const std::vector& items, + const ::XCEngine::UI::Widgets::UIExpansionModel& expansionModel); + +std::size_t FindUIEditorTreeViewFirstVisibleChildItemIndex( + const std::vector& items, + const ::XCEngine::UI::Widgets::UIExpansionModel& expansionModel, + std::size_t itemIndex); + +UIEditorTreeViewLayout BuildUIEditorTreeViewLayout( + const ::XCEngine::UI::UIRect& bounds, + const std::vector& items, + const ::XCEngine::UI::Widgets::UIExpansionModel& expansionModel, + const UIEditorTreeViewMetrics& metrics = {}); + +UIEditorTreeViewHitTarget HitTestUIEditorTreeView( + const UIEditorTreeViewLayout& layout, + const ::XCEngine::UI::UIPoint& point); + +void AppendUIEditorTreeViewBackground( + ::XCEngine::UI::UIDrawList& drawList, + const UIEditorTreeViewLayout& layout, + const std::vector& items, + const ::XCEngine::UI::Widgets::UISelectionModel& selectionModel, + const UIEditorTreeViewState& state, + const UIEditorTreeViewPalette& palette = {}, + const UIEditorTreeViewMetrics& metrics = {}); + +void AppendUIEditorTreeViewForeground( + ::XCEngine::UI::UIDrawList& drawList, + const UIEditorTreeViewLayout& layout, + const std::vector& items, + const UIEditorTreeViewPalette& palette = {}, + const UIEditorTreeViewMetrics& metrics = {}); + +void AppendUIEditorTreeView( + ::XCEngine::UI::UIDrawList& drawList, + const ::XCEngine::UI::UIRect& bounds, + const std::vector& items, + const ::XCEngine::UI::Widgets::UISelectionModel& selectionModel, + const ::XCEngine::UI::Widgets::UIExpansionModel& expansionModel, + const UIEditorTreeViewState& state, + const UIEditorTreeViewPalette& palette = {}, + const UIEditorTreeViewMetrics& metrics = {}); + +} // namespace XCEngine::UI::Editor::Widgets diff --git a/new_editor/src/Core/UIEditorTreeViewInteraction.cpp b/new_editor/src/Core/UIEditorTreeViewInteraction.cpp new file mode 100644 index 00000000..931de985 --- /dev/null +++ b/new_editor/src/Core/UIEditorTreeViewInteraction.cpp @@ -0,0 +1,192 @@ +#include + +namespace XCEngine::UI::Editor { + +namespace { + +using ::XCEngine::UI::UIInputEvent; +using ::XCEngine::UI::UIInputEventType; +using ::XCEngine::UI::UIPointerButton; +using Widgets::BuildUIEditorTreeViewLayout; +using Widgets::DoesUIEditorTreeViewItemHaveChildren; +using Widgets::HitTestUIEditorTreeView; +using Widgets::IsUIEditorTreeViewPointInside; +using Widgets::UIEditorTreeViewHitTarget; +using Widgets::UIEditorTreeViewHitTargetKind; + +bool ShouldUsePointerPosition(const UIInputEvent& event) { + switch (event.type) { + case UIInputEventType::PointerMove: + case UIInputEventType::PointerEnter: + case UIInputEventType::PointerButtonDown: + case UIInputEventType::PointerButtonUp: + return true; + default: + return false; + } +} + +void SyncHoverTarget( + UIEditorTreeViewInteractionState& state, + const Widgets::UIEditorTreeViewLayout& layout, + const std::vector& items) { + state.treeViewState.hoveredItemId.clear(); + if (!state.hasPointerPosition) { + return; + } + + const UIEditorTreeViewHitTarget hitTarget = + HitTestUIEditorTreeView(layout, state.pointerPosition); + if (hitTarget.itemIndex < items.size()) { + state.treeViewState.hoveredItemId = items[hitTarget.itemIndex].itemId; + } +} + +} // namespace + +UIEditorTreeViewInteractionFrame UpdateUIEditorTreeViewInteraction( + UIEditorTreeViewInteractionState& state, + ::XCEngine::UI::Widgets::UISelectionModel& selectionModel, + ::XCEngine::UI::Widgets::UIExpansionModel& expansionModel, + const ::XCEngine::UI::UIRect& bounds, + const std::vector& items, + const std::vector& inputEvents, + const Widgets::UIEditorTreeViewMetrics& metrics) { + Widgets::UIEditorTreeViewLayout layout = + BuildUIEditorTreeViewLayout(bounds, items, expansionModel, metrics); + SyncHoverTarget(state, layout, items); + + UIEditorTreeViewInteractionResult interactionResult = {}; + for (const UIInputEvent& event : inputEvents) { + if (ShouldUsePointerPosition(event)) { + state.pointerPosition = event.position; + state.hasPointerPosition = true; + } else if (event.type == UIInputEventType::PointerLeave) { + state.hasPointerPosition = false; + } + + UIEditorTreeViewInteractionResult eventResult = {}; + switch (event.type) { + case UIInputEventType::FocusGained: + state.treeViewState.focused = true; + break; + + case UIInputEventType::FocusLost: + state.treeViewState.focused = false; + state.hasPointerPosition = false; + state.treeViewState.hoveredItemId.clear(); + break; + + case UIInputEventType::PointerMove: + case UIInputEventType::PointerEnter: + break; + + case UIInputEventType::PointerLeave: + state.treeViewState.hoveredItemId.clear(); + break; + + case UIInputEventType::PointerButtonDown: { + const UIEditorTreeViewHitTarget hitTarget = + state.hasPointerPosition + ? HitTestUIEditorTreeView(layout, state.pointerPosition) + : UIEditorTreeViewHitTarget {}; + eventResult.hitTarget = hitTarget; + if ((event.pointerButton == UIPointerButton::Left || + event.pointerButton == UIPointerButton::Right) && + hitTarget.kind != UIEditorTreeViewHitTargetKind::None) { + state.treeViewState.focused = true; + eventResult.consumed = true; + } else if (event.pointerButton == UIPointerButton::Left && + (!state.hasPointerPosition || + !IsUIEditorTreeViewPointInside(layout.bounds, state.pointerPosition))) { + state.treeViewState.focused = false; + } + break; + } + + case UIInputEventType::PointerButtonUp: { + const UIEditorTreeViewHitTarget hitTarget = + state.hasPointerPosition + ? HitTestUIEditorTreeView(layout, state.pointerPosition) + : UIEditorTreeViewHitTarget {}; + eventResult.hitTarget = hitTarget; + + const bool insideTree = + state.hasPointerPosition && + IsUIEditorTreeViewPointInside(layout.bounds, state.pointerPosition); + + if (hitTarget.itemIndex >= items.size()) { + if (event.pointerButton == UIPointerButton::Left && insideTree) { + eventResult.consumed = true; + state.treeViewState.focused = true; + } else if (event.pointerButton == UIPointerButton::Left) { + state.treeViewState.focused = false; + } + break; + } + + const Widgets::UIEditorTreeViewItem& item = items[hitTarget.itemIndex]; + if (event.pointerButton == UIPointerButton::Left) { + if (hitTarget.kind == UIEditorTreeViewHitTargetKind::Disclosure && + DoesUIEditorTreeViewItemHaveChildren(items, hitTarget.itemIndex)) { + eventResult.expansionChanged = + expansionModel.ToggleExpanded(item.itemId); + eventResult.toggledItemId = item.itemId; + eventResult.consumed = true; + state.treeViewState.focused = true; + } else if (hitTarget.kind == UIEditorTreeViewHitTargetKind::Row) { + eventResult.selectionChanged = + selectionModel.SetSelection(item.itemId); + eventResult.selectedItemId = item.itemId; + eventResult.consumed = true; + state.treeViewState.focused = true; + } + } else if (event.pointerButton == UIPointerButton::Right && + (hitTarget.kind == UIEditorTreeViewHitTargetKind::Row || + hitTarget.kind == UIEditorTreeViewHitTargetKind::Disclosure)) { + eventResult.selectionChanged = + selectionModel.SetSelection(item.itemId); + eventResult.selectedItemId = item.itemId; + eventResult.secondaryClicked = true; + eventResult.consumed = true; + state.treeViewState.focused = true; + } + break; + } + + default: + break; + } + + layout = BuildUIEditorTreeViewLayout(bounds, items, expansionModel, metrics); + SyncHoverTarget(state, layout, items); + if (eventResult.hitTarget.kind == UIEditorTreeViewHitTargetKind::None && + state.hasPointerPosition) { + eventResult.hitTarget = HitTestUIEditorTreeView(layout, state.pointerPosition); + } + + if (eventResult.consumed || + eventResult.selectionChanged || + eventResult.expansionChanged || + eventResult.secondaryClicked || + eventResult.hitTarget.kind != UIEditorTreeViewHitTargetKind::None || + !eventResult.selectedItemId.empty() || + !eventResult.toggledItemId.empty()) { + interactionResult = std::move(eventResult); + } + } + + layout = BuildUIEditorTreeViewLayout(bounds, items, expansionModel, metrics); + SyncHoverTarget(state, layout, items); + if (interactionResult.hitTarget.kind == UIEditorTreeViewHitTargetKind::None && + state.hasPointerPosition) { + interactionResult.hitTarget = HitTestUIEditorTreeView(layout, state.pointerPosition); + } + + return { + std::move(layout), + std::move(interactionResult) + }; +} + +} // namespace XCEngine::UI::Editor diff --git a/new_editor/src/Widgets/UIEditorTreeView.cpp b/new_editor/src/Widgets/UIEditorTreeView.cpp new file mode 100644 index 00000000..7f52525d --- /dev/null +++ b/new_editor/src/Widgets/UIEditorTreeView.cpp @@ -0,0 +1,314 @@ +#include + +#include + +#include +#include + +namespace XCEngine::UI::Editor::Widgets { + +namespace { + +float ClampNonNegative(float value) { + return (std::max)(value, 0.0f); +} + +std::vector BuildItemOffsets(std::size_t count) { + std::vector offsets(count); + std::iota(offsets.begin(), offsets.end(), 0u); + return offsets; +} + +float ResolveTreeViewRowHeight( + const UIEditorTreeViewItem& item, + const UIEditorTreeViewMetrics& metrics) { + return item.desiredHeight > 0.0f ? item.desiredHeight : metrics.rowHeight; +} + +::XCEngine::UI::UIPoint ResolveDisclosureGlyphPosition( + const ::XCEngine::UI::UIRect& rect, + float insetY) { + return ::XCEngine::UI::UIPoint(rect.x + 2.0f, rect.y + insetY - 1.0f); +} + +} // namespace + +bool IsUIEditorTreeViewPointInside( + const ::XCEngine::UI::UIRect& rect, + const ::XCEngine::UI::UIPoint& point) { + return point.x >= rect.x && + point.x <= rect.x + rect.width && + point.y >= rect.y && + point.y <= rect.y + rect.height; +} + +bool DoesUIEditorTreeViewItemHaveChildren( + const std::vector& items, + std::size_t itemIndex) { + if (itemIndex >= items.size() || items[itemIndex].forceLeaf) { + return false; + } + + const std::vector itemOffsets = BuildItemOffsets(items.size()); + return ::XCEngine::UI::Widgets::UIFlatHierarchyHasChildren( + itemOffsets, + itemIndex, + [&](std::size_t offset) { + return items[offset].depth; + }); +} + +std::size_t FindUIEditorTreeViewItemIndex( + const std::vector& items, + std::string_view itemId) { + for (std::size_t itemIndex = 0u; itemIndex < items.size(); ++itemIndex) { + if (items[itemIndex].itemId == itemId) { + return itemIndex; + } + } + + return UIEditorTreeViewInvalidIndex; +} + +std::size_t FindUIEditorTreeViewParentItemIndex( + const std::vector& items, + std::size_t itemIndex) { + const std::vector itemOffsets = BuildItemOffsets(items.size()); + const std::size_t parentOffset = ::XCEngine::UI::Widgets::UIFlatHierarchyFindParentOffset( + itemOffsets, + itemIndex, + [&](std::size_t offset) { + return items[offset].depth; + }); + return parentOffset != ::XCEngine::UI::Widgets::kInvalidUIFlatHierarchyItemOffset + ? parentOffset + : UIEditorTreeViewInvalidIndex; +} + +std::vector CollectUIEditorTreeViewVisibleItemIndices( + const std::vector& items, + const ::XCEngine::UI::Widgets::UIExpansionModel& expansionModel) { + std::vector visibleItemIndices = {}; + if (items.empty()) { + return visibleItemIndices; + } + + const std::vector itemOffsets = BuildItemOffsets(items.size()); + for (std::size_t itemOffset = 0u; itemOffset < items.size(); ++itemOffset) { + const bool visible = ::XCEngine::UI::Widgets::UIFlatHierarchyIsVisible( + itemOffsets, + itemOffset, + [&](std::size_t offset) { + return items[offset].depth; + }, + [&](std::size_t offset) { + return expansionModel.IsExpanded(items[offset].itemId); + }); + if (visible) { + visibleItemIndices.push_back(itemOffset); + } + } + + return visibleItemIndices; +} + +std::size_t FindUIEditorTreeViewFirstVisibleChildItemIndex( + const std::vector& items, + const ::XCEngine::UI::Widgets::UIExpansionModel& expansionModel, + std::size_t itemIndex) { + if (itemIndex >= items.size()) { + return UIEditorTreeViewInvalidIndex; + } + + const std::vector itemOffsets = BuildItemOffsets(items.size()); + const std::size_t childOffset = + ::XCEngine::UI::Widgets::UIFlatHierarchyFindFirstVisibleChildOffset( + itemOffsets, + itemIndex, + [&](std::size_t offset) { + return items[offset].depth; + }, + [&](std::size_t offset) { + return ::XCEngine::UI::Widgets::UIFlatHierarchyIsVisible( + itemOffsets, + offset, + [&](std::size_t visibleOffset) { + return items[visibleOffset].depth; + }, + [&](std::size_t visibleOffset) { + return expansionModel.IsExpanded(items[visibleOffset].itemId); + }); + }); + return childOffset != ::XCEngine::UI::Widgets::kInvalidUIFlatHierarchyItemOffset + ? childOffset + : UIEditorTreeViewInvalidIndex; +} + +UIEditorTreeViewLayout BuildUIEditorTreeViewLayout( + const ::XCEngine::UI::UIRect& bounds, + const std::vector& items, + const ::XCEngine::UI::Widgets::UIExpansionModel& expansionModel, + const UIEditorTreeViewMetrics& metrics) { + UIEditorTreeViewLayout layout = {}; + layout.bounds = ::XCEngine::UI::UIRect( + bounds.x, + bounds.y, + ClampNonNegative(bounds.width), + ClampNonNegative(bounds.height)); + layout.visibleItemIndices = CollectUIEditorTreeViewVisibleItemIndices(items, expansionModel); + layout.rowRects.reserve(layout.visibleItemIndices.size()); + layout.disclosureRects.reserve(layout.visibleItemIndices.size()); + layout.labelRects.reserve(layout.visibleItemIndices.size()); + layout.itemHasChildren.reserve(layout.visibleItemIndices.size()); + layout.itemExpanded.reserve(layout.visibleItemIndices.size()); + + float rowY = layout.bounds.y; + for (std::size_t visibleOffset = 0u; + visibleOffset < layout.visibleItemIndices.size(); + ++visibleOffset) { + const std::size_t itemIndex = layout.visibleItemIndices[visibleOffset]; + const UIEditorTreeViewItem& item = items[itemIndex]; + const float rowHeight = ResolveTreeViewRowHeight(item, metrics); + const bool hasChildren = DoesUIEditorTreeViewItemHaveChildren(items, itemIndex); + const bool expanded = hasChildren && expansionModel.IsExpanded(item.itemId); + + const ::XCEngine::UI::UIRect rowRect( + layout.bounds.x, + rowY, + layout.bounds.width, + rowHeight); + const float contentX = + rowRect.x + metrics.horizontalPadding + static_cast(item.depth) * metrics.indentWidth; + const ::XCEngine::UI::UIRect disclosureRect( + contentX, + rowRect.y + (rowRect.height - metrics.disclosureExtent) * 0.5f, + metrics.disclosureExtent, + metrics.disclosureExtent); + const ::XCEngine::UI::UIRect labelRect( + disclosureRect.x + metrics.disclosureExtent + metrics.disclosureLabelGap, + rowRect.y, + (rowRect.x + rowRect.width) - + (disclosureRect.x + metrics.disclosureExtent + metrics.disclosureLabelGap) - + metrics.horizontalPadding, + rowRect.height); + + layout.rowRects.push_back(rowRect); + layout.disclosureRects.push_back(disclosureRect); + layout.labelRects.push_back(labelRect); + layout.itemHasChildren.push_back(hasChildren); + layout.itemExpanded.push_back(expanded); + + rowY += rowHeight + metrics.rowGap; + } + + return layout; +} + +UIEditorTreeViewHitTarget HitTestUIEditorTreeView( + const UIEditorTreeViewLayout& layout, + const ::XCEngine::UI::UIPoint& point) { + for (std::size_t visibleOffset = 0u; visibleOffset < layout.rowRects.size(); ++visibleOffset) { + if (!IsUIEditorTreeViewPointInside(layout.rowRects[visibleOffset], point)) { + continue; + } + + UIEditorTreeViewHitTarget target = {}; + target.visibleIndex = visibleOffset; + target.itemIndex = layout.visibleItemIndices[visibleOffset]; + target.kind = + layout.itemHasChildren[visibleOffset] && + IsUIEditorTreeViewPointInside(layout.disclosureRects[visibleOffset], point) + ? UIEditorTreeViewHitTargetKind::Disclosure + : UIEditorTreeViewHitTargetKind::Row; + return target; + } + + return {}; +} + +void AppendUIEditorTreeViewBackground( + ::XCEngine::UI::UIDrawList& drawList, + const UIEditorTreeViewLayout& layout, + const std::vector& items, + const ::XCEngine::UI::Widgets::UISelectionModel& selectionModel, + const UIEditorTreeViewState& state, + const UIEditorTreeViewPalette& palette, + const UIEditorTreeViewMetrics& metrics) { + drawList.AddFilledRect(layout.bounds, palette.surfaceColor, metrics.cornerRounding); + drawList.AddRectOutline( + layout.bounds, + state.focused ? palette.focusedBorderColor : palette.borderColor, + state.focused ? metrics.focusedBorderThickness : metrics.borderThickness, + metrics.cornerRounding); + + for (std::size_t visibleOffset = 0u; visibleOffset < layout.rowRects.size(); ++visibleOffset) { + const UIEditorTreeViewItem& item = items[layout.visibleItemIndices[visibleOffset]]; + const bool selected = selectionModel.IsSelected(item.itemId); + const bool hovered = state.hoveredItemId == item.itemId; + if (!selected && !hovered) { + continue; + } + + const ::XCEngine::UI::UIColor rowColor = + selected + ? (state.focused ? palette.rowSelectedFocusedColor : palette.rowSelectedColor) + : palette.rowHoverColor; + drawList.AddFilledRect(layout.rowRects[visibleOffset], rowColor, metrics.cornerRounding); + } +} + +void AppendUIEditorTreeViewForeground( + ::XCEngine::UI::UIDrawList& drawList, + const UIEditorTreeViewLayout& layout, + const std::vector& items, + const UIEditorTreeViewPalette& palette, + const UIEditorTreeViewMetrics& metrics) { + drawList.PushClipRect(layout.bounds); + for (std::size_t visibleOffset = 0u; visibleOffset < layout.rowRects.size(); ++visibleOffset) { + const UIEditorTreeViewItem& item = items[layout.visibleItemIndices[visibleOffset]]; + if (layout.itemHasChildren[visibleOffset]) { + drawList.AddText( + ResolveDisclosureGlyphPosition( + layout.disclosureRects[visibleOffset], + metrics.labelInsetY), + layout.itemExpanded[visibleOffset] ? "v" : ">", + palette.disclosureColor, + 12.0f); + } + + drawList.PushClipRect(layout.labelRects[visibleOffset]); + drawList.AddText( + ::XCEngine::UI::UIPoint( + layout.labelRects[visibleOffset].x, + layout.labelRects[visibleOffset].y + metrics.labelInsetY), + item.label, + palette.textColor, + 12.0f); + drawList.PopClipRect(); + } + drawList.PopClipRect(); +} + +void AppendUIEditorTreeView( + ::XCEngine::UI::UIDrawList& drawList, + const ::XCEngine::UI::UIRect& bounds, + const std::vector& items, + const ::XCEngine::UI::Widgets::UISelectionModel& selectionModel, + const ::XCEngine::UI::Widgets::UIExpansionModel& expansionModel, + const UIEditorTreeViewState& state, + const UIEditorTreeViewPalette& palette, + const UIEditorTreeViewMetrics& metrics) { + const UIEditorTreeViewLayout layout = + BuildUIEditorTreeViewLayout(bounds, items, expansionModel, metrics); + AppendUIEditorTreeViewBackground( + drawList, + layout, + items, + selectionModel, + state, + palette, + metrics); + AppendUIEditorTreeViewForeground(drawList, layout, items, palette, metrics); +} + +} // namespace XCEngine::UI::Editor::Widgets diff --git a/tests/UI/Editor/integration/CMakeLists.txt b/tests/UI/Editor/integration/CMakeLists.txt index 179b50c1..5df9f3fd 100644 --- a/tests/UI/Editor/integration/CMakeLists.txt +++ b/tests/UI/Editor/integration/CMakeLists.txt @@ -48,6 +48,11 @@ if(TARGET editor_ui_viewport_input_bridge_basic_validation) editor_ui_viewport_input_bridge_basic_validation) endif() +if(TARGET editor_ui_tree_view_basic_validation) + list(APPEND EDITOR_UI_INTEGRATION_TARGETS + editor_ui_tree_view_basic_validation) +endif() + add_custom_target(editor_ui_integration_tests DEPENDS ${EDITOR_UI_INTEGRATION_TARGETS} diff --git a/tests/UI/Editor/integration/README.md b/tests/UI/Editor/integration/README.md index 77757c4e..817114d6 100644 --- a/tests/UI/Editor/integration/README.md +++ b/tests/UI/Editor/integration/README.md @@ -23,6 +23,7 @@ Layout: - `shell/context_menu_basic/`: context menu root/submenu/dismiss/dispatch only - `shell/panel_frame_basic/`: panel frame layout/state/hit-test only - `shell/status_bar_basic/`: status bar slot/segment/hit-test only +- `shell/tree_view_basic/`: TreeView row layout, indent, disclosure, selection, focus, hit-test only - `shell/tab_strip_basic/`: tab strip layout/state/hit-test/close/navigation only - `shell/viewport_slot_basic/`: viewport slot chrome/surface/status only - `shell/viewport_shell_basic/`: viewport shell request/state compose only @@ -81,6 +82,11 @@ Scenarios: Executable: `XCUIEditorStatusBarBasicValidation.exe` Scope: status bar slot layout, hover/active segment hit target, separator layout only +- `editor.shell.tree_view_basic` + Build target: `editor_ui_tree_view_basic_validation` + Executable: `XCUIEditorTreeViewBasicValidation.exe` + Scope: TreeView 基础控件验证;只检查行缩进、disclosure 展开/折叠、selection、hover/focus 和 hit-test,不涉及业务面板 + - `editor.shell.tab_strip_basic` Build target: `editor_ui_tab_strip_basic_validation` Executable: `XCUIEditorTabStripBasicValidation.exe` @@ -171,6 +177,9 @@ Selected controls: - `shell/status_bar_basic/` Move the mouse across leading/trailing segments, click interactive segments, toggle focus/active, press `F12`. +- `shell/tree_view_basic/` + 先看顶部中文说明“这个测试在验证什么功能”,再点击 disclosure 和树节点行,检查 `Hover / Focused / Selected / Expanded / Visible / Result`,按 `重置`、`截图(F12)` 或直接按 `F12`。 + - `shell/tab_strip_basic/` Click `Document A / B / C`, click `X` on closable tabs, click content to focus, press `Left / Right / Home / End`, press `Reset`, press `F12`. diff --git a/tests/UI/Editor/integration/shell/CMakeLists.txt b/tests/UI/Editor/integration/shell/CMakeLists.txt index e92d49a7..12478d31 100644 --- a/tests/UI/Editor/integration/shell/CMakeLists.txt +++ b/tests/UI/Editor/integration/shell/CMakeLists.txt @@ -16,6 +16,9 @@ endif() if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/panel_content_host_basic/CMakeLists.txt") add_subdirectory(panel_content_host_basic) endif() +if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/tree_view_basic/CMakeLists.txt") + add_subdirectory(tree_view_basic) +endif() if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/status_bar_basic/CMakeLists.txt") add_subdirectory(status_bar_basic) endif() diff --git a/tests/UI/Editor/integration/shell/tree_view_basic/CMakeLists.txt b/tests/UI/Editor/integration/shell/tree_view_basic/CMakeLists.txt new file mode 100644 index 00000000..357f689b --- /dev/null +++ b/tests/UI/Editor/integration/shell/tree_view_basic/CMakeLists.txt @@ -0,0 +1,30 @@ +add_executable(editor_ui_tree_view_basic_validation WIN32 + main.cpp +) + +target_include_directories(editor_ui_tree_view_basic_validation PRIVATE + ${CMAKE_SOURCE_DIR}/new_editor/include + ${CMAKE_SOURCE_DIR}/new_editor/app + ${CMAKE_SOURCE_DIR}/engine/include +) + +target_compile_definitions(editor_ui_tree_view_basic_validation PRIVATE + UNICODE + _UNICODE + XCENGINE_EDITOR_UI_TESTS_REPO_ROOT="${XCENGINE_EDITOR_UI_TESTS_REPO_ROOT_PATH}" +) + +if(MSVC) + target_compile_options(editor_ui_tree_view_basic_validation PRIVATE /utf-8 /FS) + set_property(TARGET editor_ui_tree_view_basic_validation PROPERTY + MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>DLL") +endif() + +target_link_libraries(editor_ui_tree_view_basic_validation PRIVATE + XCUIEditorLib + XCUIEditorHost +) + +set_target_properties(editor_ui_tree_view_basic_validation PROPERTIES + OUTPUT_NAME "XCUIEditorTreeViewBasicValidation" +) diff --git a/tests/UI/Editor/integration/shell/tree_view_basic/captures/.gitkeep b/tests/UI/Editor/integration/shell/tree_view_basic/captures/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/tests/UI/Editor/integration/shell/tree_view_basic/captures/.gitkeep @@ -0,0 +1 @@ + diff --git a/tests/UI/Editor/integration/shell/tree_view_basic/main.cpp b/tests/UI/Editor/integration/shell/tree_view_basic/main.cpp new file mode 100644 index 00000000..767156de --- /dev/null +++ b/tests/UI/Editor/integration/shell/tree_view_basic/main.cpp @@ -0,0 +1,747 @@ +#ifndef NOMINMAX +#define NOMINMAX +#endif + +#include +#include +#include "Host/AutoScreenshot.h" +#include "Host/NativeRenderer.h" + +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#ifndef XCENGINE_EDITOR_UI_TESTS_REPO_ROOT +#define XCENGINE_EDITOR_UI_TESTS_REPO_ROOT "." +#endif + +namespace { + +using XCEngine::UI::UIColor; +using XCEngine::UI::UIDrawData; +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 XCEngine::UI::Widgets::UIExpansionModel; +using XCEngine::UI::Widgets::UISelectionModel; +using XCEngine::UI::Editor::Host::AutoScreenshotController; +using XCEngine::UI::Editor::Host::NativeRenderer; +using XCEngine::UI::Editor::UIEditorTreeViewInteractionFrame; +using XCEngine::UI::Editor::UIEditorTreeViewInteractionResult; +using XCEngine::UI::Editor::UIEditorTreeViewInteractionState; +using XCEngine::UI::Editor::UpdateUIEditorTreeViewInteraction; +using XCEngine::UI::Editor::Widgets::AppendUIEditorTreeViewBackground; +using XCEngine::UI::Editor::Widgets::AppendUIEditorTreeViewForeground; +using XCEngine::UI::Editor::Widgets::HitTestUIEditorTreeView; +using XCEngine::UI::Editor::Widgets::UIEditorTreeViewHitTarget; +using XCEngine::UI::Editor::Widgets::UIEditorTreeViewHitTargetKind; +using XCEngine::UI::Editor::Widgets::UIEditorTreeViewItem; +using XCEngine::UI::Editor::Widgets::UIEditorTreeViewLayout; + +constexpr const wchar_t* kWindowClassName = L"XCUIEditorTreeViewBasicValidation"; +constexpr const wchar_t* kWindowTitle = L"XCUI Editor | TreeView Basic"; + +constexpr UIColor kWindowBg(0.13f, 0.13f, 0.13f, 1.0f); +constexpr UIColor kCardBg(0.18f, 0.18f, 0.18f, 1.0f); +constexpr UIColor kCardBorder(0.29f, 0.29f, 0.29f, 1.0f); +constexpr UIColor kTextPrimary(0.94f, 0.94f, 0.94f, 1.0f); +constexpr UIColor kTextMuted(0.72f, 0.72f, 0.72f, 1.0f); +constexpr UIColor kTextWeak(0.56f, 0.56f, 0.56f, 1.0f); +constexpr UIColor kTextSuccess(0.63f, 0.76f, 0.63f, 1.0f); +constexpr UIColor kButtonBg(0.25f, 0.25f, 0.25f, 1.0f); +constexpr UIColor kButtonHoverBg(0.32f, 0.32f, 0.32f, 1.0f); + +enum class ActionId : unsigned char { + Reset = 0, + Capture +}; + +struct ButtonLayout { + ActionId action = ActionId::Reset; + const char* label = ""; + UIRect rect = {}; +}; + +struct ScenarioLayout { + UIRect introRect = {}; + UIRect controlRect = {}; + UIRect stateRect = {}; + UIRect previewRect = {}; + UIRect treeRect = {}; + std::vector buttons = {}; +}; + +std::filesystem::path ResolveRepoRootPath() { + std::string root = XCENGINE_EDITOR_UI_TESTS_REPO_ROOT; + if (root.size() >= 2u && root.front() == '"' && root.back() == '"') { + root = root.substr(1u, root.size() - 2u); + } + + return std::filesystem::path(root).lexically_normal(); +} + +bool ContainsPoint(const UIRect& rect, float x, float y) { + return x >= rect.x && + x <= rect.x + rect.width && + y >= rect.y && + y <= rect.y + rect.height; +} + +ScenarioLayout BuildScenarioLayout(float width, float height) { + constexpr float margin = 20.0f; + constexpr float leftWidth = 430.0f; + constexpr float gap = 16.0f; + + ScenarioLayout layout = {}; + layout.introRect = UIRect(margin, margin, leftWidth, 214.0f); + layout.controlRect = UIRect(margin, layout.introRect.y + layout.introRect.height + gap, leftWidth, 92.0f); + layout.stateRect = UIRect( + margin, + layout.controlRect.y + layout.controlRect.height + gap, + leftWidth, + (std::max)(200.0f, height - (layout.controlRect.y + layout.controlRect.height + gap) - margin)); + layout.previewRect = UIRect( + leftWidth + margin * 2.0f, + margin, + (std::max)(420.0f, width - leftWidth - margin * 3.0f), + height - margin * 2.0f); + layout.treeRect = UIRect( + layout.previewRect.x + 18.0f, + layout.previewRect.y + 64.0f, + layout.previewRect.width - 36.0f, + layout.previewRect.height - 84.0f); + + const float buttonWidth = (layout.controlRect.width - 44.0f) * 0.5f; + const float buttonY = layout.controlRect.y + 40.0f; + layout.buttons = { + { ActionId::Reset, "重置", UIRect(layout.controlRect.x + 14.0f, buttonY, buttonWidth, 36.0f) }, + { ActionId::Capture, "截图(F12)", UIRect(layout.controlRect.x + 26.0f + buttonWidth, buttonY, buttonWidth, 36.0f) } + }; + + return layout; +} + +void DrawCard( + UIDrawList& drawList, + const UIRect& rect, + std::string_view title, + std::string_view subtitle = {}) { + drawList.AddFilledRect(rect, kCardBg, 10.0f); + drawList.AddRectOutline(rect, kCardBorder, 1.0f, 10.0f); + drawList.AddText(UIPoint(rect.x + 16.0f, rect.y + 14.0f), std::string(title), kTextPrimary, 17.0f); + if (!subtitle.empty()) { + drawList.AddText(UIPoint(rect.x + 16.0f, rect.y + 40.0f), std::string(subtitle), kTextMuted, 12.0f); + } +} + +void DrawButton( + UIDrawList& drawList, + const ButtonLayout& button, + bool hovered) { + drawList.AddFilledRect(button.rect, hovered ? kButtonHoverBg : kButtonBg, 8.0f); + drawList.AddRectOutline(button.rect, kCardBorder, 1.0f, 8.0f); + drawList.AddText( + UIPoint(button.rect.x + 16.0f, button.rect.y + 10.0f), + button.label, + kTextPrimary, + 12.0f); +} + +std::vector BuildTreeItems() { + return { + { "scene", "Scene", 0u, false, 0.0f }, + { "camera", "Camera", 1u, true, 0.0f }, + { "lights", "Lights", 1u, false, 0.0f }, + { "directional-light", "Directional Light", 2u, true, 0.0f }, + { "fill-light", "Fill Light", 2u, true, 0.0f }, + { "ui-root", "UI Root", 0u, false, 0.0f }, + { "canvas", "Canvas", 1u, true, 0.0f }, + { "event-system", "EventSystem", 1u, true, 0.0f } + }; +} + +std::string JoinExpandedItems( + const std::vector& items, + const UIExpansionModel& expansionModel) { + std::ostringstream stream = {}; + bool first = true; + for (const UIEditorTreeViewItem& item : items) { + if (!expansionModel.IsExpanded(item.itemId)) { + continue; + } + + if (!first) { + stream << " | "; + } + first = false; + stream << item.label; + } + + return first ? "(none)" : stream.str(); +} + +std::string JoinVisibleItems( + const std::vector& items, + const UIEditorTreeViewLayout& layout) { + if (layout.visibleItemIndices.empty()) { + return "(none)"; + } + + constexpr std::size_t kMaxVisibleLabels = 5u; + std::ostringstream stream = {}; + const std::size_t labelCount = (std::min)(layout.visibleItemIndices.size(), kMaxVisibleLabels); + for (std::size_t index = 0; index < labelCount; ++index) { + if (index > 0u) { + stream << " | "; + } + const std::size_t itemIndex = layout.visibleItemIndices[index]; + if (itemIndex < items.size()) { + stream << items[itemIndex].label; + } + } + + if (layout.visibleItemIndices.size() > labelCount) { + stream << " | +" << (layout.visibleItemIndices.size() - labelCount) << " more"; + } + + return stream.str(); +} + +std::string DescribeHitTarget( + const UIEditorTreeViewHitTarget& hitTarget, + const std::vector& items) { + if (hitTarget.itemIndex >= items.size()) { + return "无"; + } + + const std::string& label = items[hitTarget.itemIndex].label; + switch (hitTarget.kind) { + case UIEditorTreeViewHitTargetKind::Disclosure: + return "disclosure: " + label; + case UIEditorTreeViewHitTargetKind::Row: + return "row: " + label; + case UIEditorTreeViewHitTargetKind::None: + default: + return "无"; + } +} + +UIInputEvent MakePointerEvent( + UIInputEventType type, + const UIPoint& position, + UIPointerButton button = UIPointerButton::None) { + UIInputEvent event = {}; + event.type = type; + event.position = position; + event.pointerButton = button; + return event; +} + +class ScenarioApp { +public: + int Run(HINSTANCE hInstance, int nCmdShow) { + if (!Initialize(hInstance, nCmdShow)) { + Shutdown(); + return 1; + } + + MSG message = {}; + while (message.message != WM_QUIT) { + if (PeekMessageW(&message, nullptr, 0U, 0U, PM_REMOVE)) { + TranslateMessage(&message); + DispatchMessageW(&message); + continue; + } + + RenderFrame(); + Sleep(8); + } + + Shutdown(); + return static_cast(message.wParam); + } + +private: + static LRESULT CALLBACK WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) { + if (message == WM_NCCREATE) { + const auto* createStruct = reinterpret_cast(lParam); + auto* app = reinterpret_cast(createStruct->lpCreateParams); + SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast(app)); + return TRUE; + } + + auto* app = reinterpret_cast(GetWindowLongPtrW(hwnd, GWLP_USERDATA)); + switch (message) { + case WM_SIZE: + if (app != nullptr && wParam != SIZE_MINIMIZED) { + app->OnResize(static_cast(LOWORD(lParam)), static_cast(HIWORD(lParam))); + } + return 0; + + case WM_MOUSEMOVE: + if (app != nullptr) { + app->HandleMouseMove( + static_cast(GET_X_LPARAM(lParam)), + static_cast(GET_Y_LPARAM(lParam))); + return 0; + } + break; + + case WM_MOUSELEAVE: + if (app != nullptr) { + app->HandleMouseLeave(); + return 0; + } + break; + + case WM_LBUTTONDOWN: + if (app != nullptr) { + app->HandleLeftButtonDown( + static_cast(GET_X_LPARAM(lParam)), + static_cast(GET_Y_LPARAM(lParam))); + return 0; + } + break; + + case WM_LBUTTONUP: + if (app != nullptr) { + app->HandleLeftButtonUp( + static_cast(GET_X_LPARAM(lParam)), + static_cast(GET_Y_LPARAM(lParam))); + return 0; + } + break; + + case WM_KEYDOWN: + case WM_SYSKEYDOWN: + if (app != nullptr && wParam == VK_F12) { + app->m_autoScreenshot.RequestCapture("manual_f12"); + app->m_lastResult = "已请求截图,输出到 captures/latest.png"; + InvalidateRect(hwnd, nullptr, FALSE); + return 0; + } + break; + + case WM_PAINT: + if (app != nullptr) { + PAINTSTRUCT paintStruct = {}; + BeginPaint(hwnd, &paintStruct); + app->RenderFrame(); + EndPaint(hwnd, &paintStruct); + return 0; + } + break; + + case WM_ERASEBKGND: + return 1; + + case WM_DESTROY: + if (app != nullptr) { + app->m_hwnd = nullptr; + } + PostQuitMessage(0); + return 0; + + default: + break; + } + + return DefWindowProcW(hwnd, message, wParam, lParam); + } + + bool Initialize(HINSTANCE hInstance, int nCmdShow) { + WNDCLASSEXW windowClass = {}; + windowClass.cbSize = sizeof(windowClass); + windowClass.style = CS_HREDRAW | CS_VREDRAW; + windowClass.lpfnWndProc = &ScenarioApp::WndProc; + windowClass.hInstance = hInstance; + windowClass.hCursor = LoadCursorW(nullptr, IDC_ARROW); + windowClass.lpszClassName = kWindowClassName; + + m_windowClassAtom = RegisterClassExW(&windowClass); + if (m_windowClassAtom == 0) { + return false; + } + + m_hwnd = CreateWindowExW( + 0, + kWindowClassName, + kWindowTitle, + WS_OVERLAPPEDWINDOW | WS_VISIBLE, + CW_USEDEFAULT, + CW_USEDEFAULT, + 1480, + 920, + nullptr, + nullptr, + hInstance, + this); + if (m_hwnd == nullptr) { + return false; + } + + ShowWindow(m_hwnd, nCmdShow); + UpdateWindow(m_hwnd); + + if (!m_renderer.Initialize(m_hwnd)) { + return false; + } + + m_captureRoot = + ResolveRepoRootPath() / "tests/UI/Editor/integration/shell/tree_view_basic/captures"; + m_autoScreenshot.Initialize(m_captureRoot); + + ResetScenario(); + return true; + } + + void Shutdown() { + m_autoScreenshot.Shutdown(); + m_renderer.Shutdown(); + + if (m_hwnd != nullptr && IsWindow(m_hwnd)) { + DestroyWindow(m_hwnd); + } + m_hwnd = nullptr; + + if (m_windowClassAtom != 0) { + UnregisterClassW(kWindowClassName, GetModuleHandleW(nullptr)); + m_windowClassAtom = 0; + } + } + + ScenarioLayout GetLayout() const { + RECT clientRect = {}; + GetClientRect(m_hwnd, &clientRect); + const float width = static_cast((std::max)(1L, clientRect.right - clientRect.left)); + const float height = static_cast((std::max)(1L, clientRect.bottom - clientRect.top)); + return BuildScenarioLayout(width, height); + } + + void ResetScenario() { + m_items = BuildTreeItems(); + m_selectionModel = {}; + m_selectionModel.SetSelection("camera"); + m_expansionModel = {}; + m_expansionModel.Expand("scene"); + m_expansionModel.Expand("lights"); + m_expansionModel.Expand("ui-root"); + m_interactionState = {}; + m_interactionState.treeViewState.focused = true; + m_mousePosition = UIPoint(-1000.0f, -1000.0f); + m_hoveredAction = ActionId::Reset; + m_hasHoveredAction = false; + m_lastResult = "已重置到默认树结构"; + RefreshTreeFrame(); + } + + void RefreshTreeFrame() { + if (m_hwnd == nullptr) { + return; + } + + const ScenarioLayout layout = GetLayout(); + m_treeFrame = + UpdateUIEditorTreeViewInteraction( + m_interactionState, + m_selectionModel, + m_expansionModel, + layout.treeRect, + m_items, + {}); + } + + void OnResize(UINT width, UINT height) { + if (width == 0u || height == 0u) { + return; + } + + m_renderer.Resize(width, height); + RefreshTreeFrame(); + } + + void HandleMouseMove(float x, float y) { + m_mousePosition = UIPoint(x, y); + const ScenarioLayout layout = GetLayout(); + UpdateHoveredAction(layout, x, y); + + TRACKMOUSEEVENT trackEvent = {}; + trackEvent.cbSize = sizeof(trackEvent); + trackEvent.dwFlags = TME_LEAVE; + trackEvent.hwndTrack = m_hwnd; + TrackMouseEvent(&trackEvent); + + PumpTreeEvents({ MakePointerEvent(UIInputEventType::PointerMove, m_mousePosition) }); + InvalidateRect(m_hwnd, nullptr, FALSE); + } + + void HandleMouseLeave() { + m_mousePosition = UIPoint(-1000.0f, -1000.0f); + m_hasHoveredAction = false; + PumpTreeEvents({ MakePointerEvent(UIInputEventType::PointerLeave, m_mousePosition) }); + InvalidateRect(m_hwnd, nullptr, FALSE); + } + + void HandleLeftButtonDown(float x, float y) { + m_mousePosition = UIPoint(x, y); + const ScenarioLayout layout = GetLayout(); + if (HitTestAction(layout, x, y) != nullptr) { + UpdateHoveredAction(layout, x, y); + InvalidateRect(m_hwnd, nullptr, FALSE); + return; + } + + PumpTreeEvents({ MakePointerEvent(UIInputEventType::PointerButtonDown, m_mousePosition, UIPointerButton::Left) }); + InvalidateRect(m_hwnd, nullptr, FALSE); + } + + void HandleLeftButtonUp(float x, float y) { + m_mousePosition = UIPoint(x, y); + const ScenarioLayout layout = GetLayout(); + const ButtonLayout* button = HitTestAction(layout, x, y); + if (button != nullptr) { + ExecuteAction(button->action); + UpdateHoveredAction(layout, x, y); + InvalidateRect(m_hwnd, nullptr, FALSE); + return; + } + + const bool wasFocused = m_interactionState.treeViewState.focused; + const bool insideTree = ContainsPoint(layout.treeRect, x, y); + const UIEditorTreeViewInteractionResult result = + PumpTreeEvents({ MakePointerEvent(UIInputEventType::PointerButtonUp, m_mousePosition, UIPointerButton::Left) }); + UpdateResultText(result, wasFocused, insideTree); + InvalidateRect(m_hwnd, nullptr, FALSE); + } + + void UpdateHoveredAction(const ScenarioLayout& layout, float x, float y) { + const ButtonLayout* button = HitTestAction(layout, x, y); + if (button == nullptr) { + m_hasHoveredAction = false; + return; + } + + m_hoveredAction = button->action; + m_hasHoveredAction = true; + } + + const ButtonLayout* HitTestAction(const ScenarioLayout& layout, float x, float y) const { + for (const ButtonLayout& button : layout.buttons) { + if (ContainsPoint(button.rect, x, y)) { + return &button; + } + } + + return nullptr; + } + + UIEditorTreeViewInteractionResult PumpTreeEvents(std::vector events) { + const ScenarioLayout layout = GetLayout(); + m_treeFrame = + UpdateUIEditorTreeViewInteraction( + m_interactionState, + m_selectionModel, + m_expansionModel, + layout.treeRect, + m_items, + events); + return m_treeFrame.result; + } + + void UpdateResultText( + const UIEditorTreeViewInteractionResult& result, + bool wasFocused, + bool insideTree) { + if (result.expansionChanged && !result.toggledItemId.empty()) { + m_lastResult = "切换展开: " + result.toggledItemId; + return; + } + + if (result.selectionChanged && !result.selectedItemId.empty()) { + m_lastResult = "选中行: " + result.selectedItemId; + return; + } + + if (!insideTree && wasFocused && !m_interactionState.treeViewState.focused) { + m_lastResult = "点击树外空白: focus 已清除,selection 保留"; + return; + } + + if (insideTree) { + m_lastResult = "点击树内空白: 只更新 focus / hover"; + return; + } + + m_lastResult = "等待交互"; + } + + void ExecuteAction(ActionId action) { + switch (action) { + case ActionId::Reset: + ResetScenario(); + break; + + case ActionId::Capture: + m_autoScreenshot.RequestCapture("manual_button"); + m_lastResult = "已请求截图,输出到 captures/latest.png"; + break; + } + } + + void RenderFrame() { + if (m_hwnd == nullptr) { + return; + } + + RECT clientRect = {}; + GetClientRect(m_hwnd, &clientRect); + const float width = static_cast((std::max)(1L, clientRect.right - clientRect.left)); + const float height = static_cast((std::max)(1L, clientRect.bottom - clientRect.top)); + const ScenarioLayout layout = BuildScenarioLayout(width, height); + RefreshTreeFrame(); + + const UIEditorTreeViewHitTarget currentHit = + HitTestUIEditorTreeView(m_treeFrame.layout, m_mousePosition); + + UIDrawData drawData = {}; + UIDrawList& drawList = drawData.EmplaceDrawList("EditorTreeViewBasic"); + drawList.AddFilledRect(UIRect(0.0f, 0.0f, width, height), kWindowBg); + + DrawCard( + drawList, + layout.introRect, + "这个测试在验证什么功能", + "只验证 Editor TreeView 基础控件,不涉及任何业务面板。"); + drawList.AddText( + UIPoint(layout.introRect.x + 16.0f, layout.introRect.y + 72.0f), + "1. 验证行缩进是否正确:Scene 的子项右移一层,Directional Light 再右移一层。", + kTextPrimary, + 12.0f); + drawList.AddText( + UIPoint(layout.introRect.x + 16.0f, layout.introRect.y + 94.0f), + "2. 点击 disclosure 只切换展开/折叠,不应误改 selection。", + kTextPrimary, + 12.0f); + drawList.AddText( + UIPoint(layout.introRect.x + 16.0f, layout.introRect.y + 116.0f), + "3. 点击行只切换 selection;hover、selected、focused 三种视觉状态要能区分。", + kTextPrimary, + 12.0f); + drawList.AddText( + UIPoint(layout.introRect.x + 16.0f, layout.introRect.y + 138.0f), + "4. 点击树外空白后 focus 应清除,但 selection 不应丢失。", + kTextPrimary, + 12.0f); + drawList.AddText( + UIPoint(layout.introRect.x + 16.0f, layout.introRect.y + 160.0f), + "5. 按 F12 手动截图;设置 XCUI_AUTO_CAPTURE_ON_STARTUP=1 可自动截图。", + kTextPrimary, + 12.0f); + + DrawCard(drawList, layout.controlRect, "操作"); + for (const ButtonLayout& button : layout.buttons) { + DrawButton( + drawList, + button, + m_hasHoveredAction && m_hoveredAction == button.action); + } + + DrawCard(drawList, layout.stateRect, "状态摘要", "重点检查 hit / focus / selection / expanded / visible。"); + drawList.AddText( + UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 70.0f), + "Hover: " + DescribeHitTarget(currentHit, m_items), + kTextPrimary, + 12.0f); + drawList.AddText( + UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 94.0f), + std::string("Focused: ") + (m_interactionState.treeViewState.focused ? "开" : "关"), + kTextPrimary, + 12.0f); + drawList.AddText( + UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 118.0f), + "Selected: " + + (m_selectionModel.HasSelection() ? m_selectionModel.GetSelectedId() : std::string("(none)")), + kTextSuccess, + 12.0f); + drawList.AddText( + UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 142.0f), + "Expanded: " + JoinExpandedItems(m_items, m_expansionModel), + kTextMuted, + 12.0f); + drawList.AddText( + UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 166.0f), + "Visible(" + std::to_string(m_treeFrame.layout.visibleItemIndices.size()) + "): " + + JoinVisibleItems(m_items, m_treeFrame.layout), + kTextMuted, + 12.0f); + drawList.AddText( + UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 190.0f), + "Result: " + m_lastResult, + kTextPrimary, + 12.0f); + + const std::string captureSummary = + m_autoScreenshot.HasPendingCapture() + ? "截图排队中..." + : (m_autoScreenshot.GetLastCaptureSummary().empty() + ? std::string("F12 -> tests/UI/Editor/integration/shell/tree_view_basic/captures/") + : m_autoScreenshot.GetLastCaptureSummary()); + drawList.AddText( + UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 216.0f), + captureSummary, + kTextWeak, + 12.0f); + + DrawCard(drawList, layout.previewRect, "TreeView 预览", "这里只放一个 TreeView,不混入 Hierarchy/Inspector 等业务内容。"); + AppendUIEditorTreeViewBackground( + drawList, + m_treeFrame.layout, + m_items, + m_selectionModel, + m_interactionState.treeViewState); + AppendUIEditorTreeViewForeground(drawList, m_treeFrame.layout, m_items); + + const bool framePresented = m_renderer.Render(drawData); + m_autoScreenshot.CaptureIfRequested( + m_renderer, + drawData, + static_cast(width), + static_cast(height), + framePresented); + } + + HWND m_hwnd = nullptr; + ATOM m_windowClassAtom = 0; + NativeRenderer m_renderer = {}; + AutoScreenshotController m_autoScreenshot = {}; + std::filesystem::path m_captureRoot = {}; + std::vector m_items = {}; + UISelectionModel m_selectionModel = {}; + UIExpansionModel m_expansionModel = {}; + UIEditorTreeViewInteractionState m_interactionState = {}; + UIEditorTreeViewInteractionFrame m_treeFrame = {}; + UIPoint m_mousePosition = UIPoint(-1000.0f, -1000.0f); + ActionId m_hoveredAction = ActionId::Reset; + bool m_hasHoveredAction = false; + std::string m_lastResult = {}; +}; + +} // namespace + +int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) { + return ScenarioApp().Run(hInstance, nCmdShow); +} diff --git a/tests/UI/Editor/unit/CMakeLists.txt b/tests/UI/Editor/unit/CMakeLists.txt index 9c6ac080..a893365b 100644 --- a/tests/UI/Editor/unit/CMakeLists.txt +++ b/tests/UI/Editor/unit/CMakeLists.txt @@ -20,6 +20,8 @@ set(EDITOR_UI_UNIT_TEST_SOURCES test_ui_editor_panel_frame.cpp test_ui_editor_status_bar.cpp test_ui_editor_tab_strip.cpp + test_ui_editor_tree_view.cpp + test_ui_editor_tree_view_interaction.cpp test_ui_editor_viewport_input_bridge.cpp test_ui_editor_viewport_shell.cpp test_ui_editor_viewport_slot.cpp diff --git a/tests/UI/Editor/unit/test_ui_editor_tree_view.cpp b/tests/UI/Editor/unit/test_ui_editor_tree_view.cpp new file mode 100644 index 00000000..82f5a7d7 --- /dev/null +++ b/tests/UI/Editor/unit/test_ui_editor_tree_view.cpp @@ -0,0 +1,187 @@ +#include + +#include +#include +#include +#include + +namespace { + +using XCEngine::UI::UIDrawCommandType; +using XCEngine::UI::UIDrawList; +using XCEngine::UI::UIPoint; +using XCEngine::UI::UIRect; +using XCEngine::UI::Widgets::UIExpansionModel; +using XCEngine::UI::Widgets::UISelectionModel; +using XCEngine::UI::Editor::Widgets::AppendUIEditorTreeViewBackground; +using XCEngine::UI::Editor::Widgets::AppendUIEditorTreeViewForeground; +using XCEngine::UI::Editor::Widgets::BuildUIEditorTreeViewLayout; +using XCEngine::UI::Editor::Widgets::CollectUIEditorTreeViewVisibleItemIndices; +using XCEngine::UI::Editor::Widgets::DoesUIEditorTreeViewItemHaveChildren; +using XCEngine::UI::Editor::Widgets::FindUIEditorTreeViewFirstVisibleChildItemIndex; +using XCEngine::UI::Editor::Widgets::FindUIEditorTreeViewItemIndex; +using XCEngine::UI::Editor::Widgets::FindUIEditorTreeViewParentItemIndex; +using XCEngine::UI::Editor::Widgets::HitTestUIEditorTreeView; +using XCEngine::UI::Editor::Widgets::UIEditorTreeViewHitTargetKind; +using XCEngine::UI::Editor::Widgets::UIEditorTreeViewItem; +using XCEngine::UI::Editor::Widgets::UIEditorTreeViewInvalidIndex; +using XCEngine::UI::Editor::Widgets::UIEditorTreeViewLayout; +using XCEngine::UI::Editor::Widgets::UIEditorTreeViewMetrics; +using XCEngine::UI::Editor::Widgets::UIEditorTreeViewState; + +bool ContainsTextCommand(const UIDrawList& drawList, std::string_view text) { + for (const auto& command : drawList.GetCommands()) { + if (command.type == UIDrawCommandType::Text && command.text == text) { + return true; + } + } + + return false; +} + +std::vector BuildTreeItems() { + return { + { "scene", "Scene", 0u, false, 0.0f }, + { "camera", "Camera", 1u, true, 0.0f }, + { "lights", "Lights", 1u, false, 0.0f }, + { "sun", "Directional Light", 2u, true, 0.0f }, + { "ui", "UI Root", 0u, false, 0.0f }, + { "canvas", "Canvas", 1u, true, 0.0f } + }; +} + +TEST(UIEditorTreeViewTest, ChildDetectionUsesFlatHierarchyDepthRules) { + const std::vector items = BuildTreeItems(); + + EXPECT_TRUE(DoesUIEditorTreeViewItemHaveChildren(items, 0u)); + EXPECT_FALSE(DoesUIEditorTreeViewItemHaveChildren(items, 1u)); + EXPECT_TRUE(DoesUIEditorTreeViewItemHaveChildren(items, 2u)); + EXPECT_TRUE(DoesUIEditorTreeViewItemHaveChildren(items, 4u)); + + EXPECT_EQ(FindUIEditorTreeViewItemIndex(items, "lights"), 2u); + EXPECT_EQ(FindUIEditorTreeViewParentItemIndex(items, 0u), UIEditorTreeViewInvalidIndex); + EXPECT_EQ(FindUIEditorTreeViewParentItemIndex(items, 1u), 0u); + EXPECT_EQ(FindUIEditorTreeViewParentItemIndex(items, 3u), 2u); +} + +TEST(UIEditorTreeViewTest, VisibleItemsFollowExpansionModel) { + const std::vector items = BuildTreeItems(); + + UIExpansionModel expansionModel = {}; + EXPECT_EQ( + FindUIEditorTreeViewFirstVisibleChildItemIndex(items, expansionModel, 0u), + UIEditorTreeViewInvalidIndex); + EXPECT_EQ( + CollectUIEditorTreeViewVisibleItemIndices(items, expansionModel), + std::vector({ 0u, 4u })); + + expansionModel.Expand("scene"); + EXPECT_EQ( + FindUIEditorTreeViewFirstVisibleChildItemIndex(items, expansionModel, 0u), + 1u); + EXPECT_EQ( + CollectUIEditorTreeViewVisibleItemIndices(items, expansionModel), + std::vector({ 0u, 1u, 2u, 4u })); + + expansionModel.Expand("lights"); + expansionModel.Expand("ui"); + EXPECT_EQ( + FindUIEditorTreeViewFirstVisibleChildItemIndex(items, expansionModel, 2u), + 3u); + EXPECT_EQ( + CollectUIEditorTreeViewVisibleItemIndices(items, expansionModel), + std::vector({ 0u, 1u, 2u, 3u, 4u, 5u })); +} + +TEST(UIEditorTreeViewTest, LayoutBuildsIndentedDisclosureAndLabelRects) { + const std::vector items = BuildTreeItems(); + UIExpansionModel expansionModel = {}; + expansionModel.Expand("scene"); + + UIEditorTreeViewMetrics metrics = {}; + metrics.rowHeight = 24.0f; + metrics.rowGap = 4.0f; + metrics.horizontalPadding = 10.0f; + metrics.indentWidth = 20.0f; + metrics.disclosureExtent = 10.0f; + metrics.disclosureLabelGap = 6.0f; + + const UIEditorTreeViewLayout layout = + BuildUIEditorTreeViewLayout(UIRect(20.0f, 30.0f, 280.0f, 240.0f), items, expansionModel, metrics); + + ASSERT_EQ(layout.visibleItemIndices.size(), 4u); + EXPECT_EQ(layout.visibleItemIndices[0], 0u); + EXPECT_EQ(layout.visibleItemIndices[1], 1u); + EXPECT_EQ(layout.visibleItemIndices[2], 2u); + EXPECT_EQ(layout.visibleItemIndices[3], 4u); + + EXPECT_FLOAT_EQ(layout.rowRects[0].x, 20.0f); + EXPECT_FLOAT_EQ(layout.rowRects[0].y, 30.0f); + EXPECT_FLOAT_EQ(layout.rowRects[0].height, 24.0f); + + EXPECT_FLOAT_EQ(layout.disclosureRects[0].x, 30.0f); + EXPECT_FLOAT_EQ(layout.disclosureRects[1].x, 50.0f); + EXPECT_FLOAT_EQ(layout.disclosureRects[2].x, 50.0f); + EXPECT_FLOAT_EQ(layout.labelRects[0].x, 46.0f); + EXPECT_FLOAT_EQ(layout.labelRects[1].x, 66.0f); + EXPECT_TRUE(layout.itemHasChildren[0]); + EXPECT_FALSE(layout.itemHasChildren[1]); + EXPECT_TRUE(layout.itemExpanded[0]); + EXPECT_FALSE(layout.itemExpanded[2]); +} + +TEST(UIEditorTreeViewTest, HitTestPrioritizesDisclosureBeforeRow) { + const std::vector items = BuildTreeItems(); + UIExpansionModel expansionModel = {}; + expansionModel.Expand("scene"); + + const UIEditorTreeViewLayout layout = + BuildUIEditorTreeViewLayout(UIRect(20.0f, 30.0f, 280.0f, 240.0f), items, expansionModel); + + const auto disclosureHit = HitTestUIEditorTreeView(layout, UIPoint(34.0f, 44.0f)); + EXPECT_EQ(disclosureHit.kind, UIEditorTreeViewHitTargetKind::Disclosure); + EXPECT_EQ(disclosureHit.itemIndex, 0u); + + const auto rowHit = HitTestUIEditorTreeView(layout, UIPoint(88.0f, 44.0f)); + EXPECT_EQ(rowHit.kind, UIEditorTreeViewHitTargetKind::Row); + EXPECT_EQ(rowHit.itemIndex, 0u); + + const auto emptyHit = HitTestUIEditorTreeView(layout, UIPoint(12.0f, 18.0f)); + EXPECT_EQ(emptyHit.kind, UIEditorTreeViewHitTargetKind::None); +} + +TEST(UIEditorTreeViewTest, BackgroundAndForegroundEmitStableCommands) { + const std::vector items = BuildTreeItems(); + UIExpansionModel expansionModel = {}; + expansionModel.Expand("scene"); + expansionModel.Expand("ui"); + + UISelectionModel selectionModel = {}; + selectionModel.SetSelection("camera"); + + UIEditorTreeViewState state = {}; + state.hoveredItemId = "lights"; + state.focused = true; + + const UIEditorTreeViewLayout layout = + BuildUIEditorTreeViewLayout(UIRect(20.0f, 30.0f, 280.0f, 240.0f), items, expansionModel); + + UIDrawList background("TreeViewBackground"); + AppendUIEditorTreeViewBackground(background, layout, items, selectionModel, state); + ASSERT_EQ(background.GetCommandCount(), 4u); + EXPECT_EQ(background.GetCommands()[0].type, UIDrawCommandType::FilledRect); + EXPECT_EQ(background.GetCommands()[1].type, UIDrawCommandType::RectOutline); + EXPECT_EQ(background.GetCommands()[2].type, UIDrawCommandType::FilledRect); + EXPECT_EQ(background.GetCommands()[3].type, UIDrawCommandType::FilledRect); + + UIDrawList foreground("TreeViewForeground"); + AppendUIEditorTreeViewForeground(foreground, layout, items); + ASSERT_EQ(foreground.GetCommandCount(), 20u); + EXPECT_EQ(foreground.GetCommands()[0].type, UIDrawCommandType::PushClipRect); + EXPECT_TRUE(ContainsTextCommand(foreground, "v")); + EXPECT_TRUE(ContainsTextCommand(foreground, "Scene")); + EXPECT_TRUE(ContainsTextCommand(foreground, "Camera")); + EXPECT_EQ(foreground.GetCommands()[19].type, UIDrawCommandType::PopClipRect); +} + +} // namespace diff --git a/tests/UI/Editor/unit/test_ui_editor_tree_view_interaction.cpp b/tests/UI/Editor/unit/test_ui_editor_tree_view_interaction.cpp new file mode 100644 index 00000000..a61aad61 --- /dev/null +++ b/tests/UI/Editor/unit/test_ui_editor_tree_view_interaction.cpp @@ -0,0 +1,235 @@ +#include + +#include + +namespace { + +using XCEngine::UI::UIInputEvent; +using XCEngine::UI::UIInputEventType; +using XCEngine::UI::UIPoint; +using XCEngine::UI::UIPointerButton; +using XCEngine::UI::UIRect; +using XCEngine::UI::Widgets::UIExpansionModel; +using XCEngine::UI::Widgets::UISelectionModel; +using XCEngine::UI::Editor::UIEditorTreeViewInteractionState; +using XCEngine::UI::Editor::UpdateUIEditorTreeViewInteraction; +using XCEngine::UI::Editor::Widgets::BuildUIEditorTreeViewLayout; +using XCEngine::UI::Editor::Widgets::UIEditorTreeViewHitTargetKind; +using XCEngine::UI::Editor::Widgets::UIEditorTreeViewItem; + +std::vector BuildTreeItems() { + return { + { "scene", "Scene", 0u, false, 0.0f }, + { "camera", "Camera", 1u, true, 0.0f }, + { "lights", "Lights", 1u, false, 0.0f }, + { "sun", "Directional Light", 2u, true, 0.0f }, + { "ui", "UI Root", 0u, false, 0.0f }, + { "canvas", "Canvas", 1u, true, 0.0f } + }; +} + +UIInputEvent MakePointerMove(float x, float y) { + UIInputEvent event = {}; + event.type = UIInputEventType::PointerMove; + event.position = UIPoint(x, y); + return event; +} + +UIInputEvent MakePointerDown(float x, float y, UIPointerButton button = UIPointerButton::Left) { + UIInputEvent event = {}; + event.type = UIInputEventType::PointerButtonDown; + event.position = UIPoint(x, y); + event.pointerButton = button; + return event; +} + +UIInputEvent MakePointerUp(float x, float y, UIPointerButton button = UIPointerButton::Left) { + UIInputEvent event = {}; + event.type = UIInputEventType::PointerButtonUp; + event.position = UIPoint(x, y); + event.pointerButton = button; + return event; +} + +UIInputEvent MakePointerLeave() { + UIInputEvent event = {}; + event.type = UIInputEventType::PointerLeave; + return event; +} + +UIInputEvent MakeFocusLost() { + UIInputEvent event = {}; + event.type = UIInputEventType::FocusLost; + return event; +} + +UIPoint RectCenter(const XCEngine::UI::UIRect& rect) { + return UIPoint(rect.x + rect.width * 0.5f, rect.y + rect.height * 0.5f); +} + +} // namespace + +TEST(UIEditorTreeViewInteractionTest, PointerMoveUpdatesHoveredItemAndHitTarget) { + const auto items = BuildTreeItems(); + UISelectionModel selectionModel = {}; + UIExpansionModel expansionModel = {}; + expansionModel.Expand("scene"); + UIEditorTreeViewInteractionState state = {}; + + const auto initialFrame = UpdateUIEditorTreeViewInteraction( + state, + selectionModel, + expansionModel, + UIRect(0.0f, 0.0f, 320.0f, 240.0f), + items, + {}); + const UIPoint lightsCenter = RectCenter(initialFrame.layout.rowRects[2]); + + const auto frame = UpdateUIEditorTreeViewInteraction( + state, + selectionModel, + expansionModel, + UIRect(0.0f, 0.0f, 320.0f, 240.0f), + items, + { MakePointerMove(lightsCenter.x, lightsCenter.y) }); + + EXPECT_EQ(frame.result.hitTarget.kind, UIEditorTreeViewHitTargetKind::Row); + EXPECT_EQ(frame.result.hitTarget.itemIndex, 2u); + EXPECT_EQ(state.treeViewState.hoveredItemId, "lights"); +} + +TEST(UIEditorTreeViewInteractionTest, LeftClickRowSelectsItemAndFocusesTree) { + const auto items = BuildTreeItems(); + UISelectionModel selectionModel = {}; + UIExpansionModel expansionModel = {}; + expansionModel.Expand("scene"); + UIEditorTreeViewInteractionState state = {}; + + const auto initialFrame = UpdateUIEditorTreeViewInteraction( + state, + selectionModel, + expansionModel, + UIRect(0.0f, 0.0f, 320.0f, 240.0f), + items, + {}); + const UIPoint cameraCenter = RectCenter(initialFrame.layout.rowRects[1]); + + const auto frame = UpdateUIEditorTreeViewInteraction( + state, + selectionModel, + expansionModel, + UIRect(0.0f, 0.0f, 320.0f, 240.0f), + items, + { + MakePointerDown(cameraCenter.x, cameraCenter.y), + MakePointerUp(cameraCenter.x, cameraCenter.y) + }); + + EXPECT_TRUE(frame.result.consumed); + EXPECT_TRUE(frame.result.selectionChanged); + EXPECT_EQ(frame.result.selectedItemId, "camera"); + EXPECT_TRUE(selectionModel.IsSelected("camera")); + EXPECT_TRUE(state.treeViewState.focused); +} + +TEST(UIEditorTreeViewInteractionTest, LeftClickDisclosureTogglesExpansionAndRebuildsLayout) { + const auto items = BuildTreeItems(); + UISelectionModel selectionModel = {}; + UIExpansionModel expansionModel = {}; + expansionModel.Expand("scene"); + UIEditorTreeViewInteractionState state = {}; + + const auto initialFrame = UpdateUIEditorTreeViewInteraction( + state, + selectionModel, + expansionModel, + UIRect(0.0f, 0.0f, 320.0f, 240.0f), + items, + {}); + const UIPoint lightsDisclosureCenter = RectCenter(initialFrame.layout.disclosureRects[2]); + + const auto frame = UpdateUIEditorTreeViewInteraction( + state, + selectionModel, + expansionModel, + UIRect(0.0f, 0.0f, 320.0f, 240.0f), + items, + { + MakePointerDown(lightsDisclosureCenter.x, lightsDisclosureCenter.y), + MakePointerUp(lightsDisclosureCenter.x, lightsDisclosureCenter.y) + }); + + EXPECT_TRUE(frame.result.consumed); + EXPECT_TRUE(frame.result.expansionChanged); + EXPECT_EQ(frame.result.toggledItemId, "lights"); + EXPECT_TRUE(expansionModel.IsExpanded("lights")); + ASSERT_EQ(frame.layout.visibleItemIndices.size(), 5u); + EXPECT_EQ(frame.layout.visibleItemIndices[3], 3u); + EXPECT_EQ(frame.layout.visibleItemIndices[4], 4u); +} + +TEST(UIEditorTreeViewInteractionTest, RightClickRowSelectsItemAndMarksSecondaryClick) { + const auto items = BuildTreeItems(); + UISelectionModel selectionModel = {}; + UIExpansionModel expansionModel = {}; + expansionModel.Expand("scene"); + UIEditorTreeViewInteractionState state = {}; + + const auto initialFrame = UpdateUIEditorTreeViewInteraction( + state, + selectionModel, + expansionModel, + UIRect(0.0f, 0.0f, 320.0f, 240.0f), + items, + {}); + const UIPoint lightsCenter = RectCenter(initialFrame.layout.rowRects[2]); + + const auto frame = UpdateUIEditorTreeViewInteraction( + state, + selectionModel, + expansionModel, + UIRect(0.0f, 0.0f, 320.0f, 240.0f), + items, + { + MakePointerDown(lightsCenter.x, lightsCenter.y, UIPointerButton::Right), + MakePointerUp(lightsCenter.x, lightsCenter.y, UIPointerButton::Right) + }); + + EXPECT_TRUE(frame.result.consumed); + EXPECT_TRUE(frame.result.secondaryClicked); + EXPECT_TRUE(frame.result.selectionChanged); + EXPECT_EQ(frame.result.selectedItemId, "lights"); + EXPECT_TRUE(selectionModel.IsSelected("lights")); + EXPECT_TRUE(state.treeViewState.focused); +} + +TEST(UIEditorTreeViewInteractionTest, OutsideClickAndFocusLostClearFocusAndHover) { + const auto items = BuildTreeItems(); + UISelectionModel selectionModel = {}; + UIExpansionModel expansionModel = {}; + expansionModel.Expand("scene"); + UIEditorTreeViewInteractionState state = {}; + state.treeViewState.focused = true; + state.treeViewState.hoveredItemId = "camera"; + + auto frame = UpdateUIEditorTreeViewInteraction( + state, + selectionModel, + expansionModel, + UIRect(0.0f, 0.0f, 320.0f, 240.0f), + items, + { MakePointerDown(400.0f, 260.0f), MakePointerUp(400.0f, 260.0f) }); + EXPECT_FALSE(state.treeViewState.focused); + EXPECT_EQ(frame.result.hitTarget.kind, UIEditorTreeViewHitTargetKind::None); + + frame = UpdateUIEditorTreeViewInteraction( + state, + selectionModel, + expansionModel, + UIRect(0.0f, 0.0f, 320.0f, 240.0f), + items, + { MakePointerLeave(), MakeFocusLost() }); + EXPECT_FALSE(state.treeViewState.focused); + EXPECT_TRUE(state.treeViewState.hoveredItemId.empty()); + EXPECT_FALSE(state.hasPointerPosition); +}