diff --git a/new_editor/CMakeLists.txt b/new_editor/CMakeLists.txt index 6cc9fa3d..b422200a 100644 --- a/new_editor/CMakeLists.txt +++ b/new_editor/CMakeLists.txt @@ -16,11 +16,14 @@ add_library(XCUIEditorLib STATIC src/Core/UIEditorCommandDispatcher.cpp src/Core/UIEditorCommandRegistry.cpp src/Core/UIEditorDockHostInteraction.cpp + src/Core/UIEditorListViewInteraction.cpp src/Core/UIEditorMenuModel.cpp src/Core/UIEditorMenuSession.cpp src/Core/UIEditorPanelContentHost.cpp src/Core/UIEditorPanelHostLifecycle.cpp src/Core/UIEditorPanelRegistry.cpp + src/Core/UIEditorPropertyGridInteraction.cpp + src/Core/UIEditorScrollViewInteraction.cpp src/Core/UIEditorShellCompose.cpp src/Core/UIEditorShellInteraction.cpp src/Core/UIEditorShortcutManager.cpp @@ -35,9 +38,12 @@ add_library(XCUIEditorLib STATIC src/Core/UIEditorWorkspaceSession.cpp src/Widgets/UIEditorCollectionPrimitives.cpp src/Widgets/UIEditorDockHost.cpp + src/Widgets/UIEditorListView.cpp src/Widgets/UIEditorMenuBar.cpp src/Widgets/UIEditorMenuPopup.cpp src/Widgets/UIEditorPanelFrame.cpp + src/Widgets/UIEditorPropertyGrid.cpp + src/Widgets/UIEditorScrollView.cpp src/Widgets/UIEditorStatusBar.cpp src/Widgets/UIEditorTabStrip.cpp src/Widgets/UIEditorTreeView.cpp diff --git a/new_editor/include/XCEditor/Core/UIEditorListViewInteraction.h b/new_editor/include/XCEditor/Core/UIEditorListViewInteraction.h new file mode 100644 index 00000000..7b749b67 --- /dev/null +++ b/new_editor/include/XCEditor/Core/UIEditorListViewInteraction.h @@ -0,0 +1,44 @@ +#pragma once + +#include + +#include +#include +#include + +#include +#include + +namespace XCEngine::UI::Editor { + +struct UIEditorListViewInteractionState { + Widgets::UIEditorListViewState listViewState = {}; + ::XCEngine::UI::Widgets::UIKeyboardNavigationModel keyboardNavigation = {}; + ::XCEngine::UI::UIPoint pointerPosition = {}; + bool hasPointerPosition = false; +}; + +struct UIEditorListViewInteractionResult { + bool consumed = false; + bool selectionChanged = false; + bool keyboardNavigated = false; + bool secondaryClicked = false; + Widgets::UIEditorListViewHitTarget hitTarget = {}; + std::string selectedItemId = {}; + std::size_t selectedIndex = Widgets::UIEditorListViewInvalidIndex; +}; + +struct UIEditorListViewInteractionFrame { + Widgets::UIEditorListViewLayout layout = {}; + UIEditorListViewInteractionResult result = {}; +}; + +UIEditorListViewInteractionFrame UpdateUIEditorListViewInteraction( + UIEditorListViewInteractionState& state, + ::XCEngine::UI::Widgets::UISelectionModel& selectionModel, + const ::XCEngine::UI::UIRect& bounds, + const std::vector& items, + const std::vector<::XCEngine::UI::UIInputEvent>& inputEvents, + const Widgets::UIEditorListViewMetrics& metrics = {}); + +} // namespace XCEngine::UI::Editor diff --git a/new_editor/include/XCEditor/Core/UIEditorPropertyGridInteraction.h b/new_editor/include/XCEditor/Core/UIEditorPropertyGridInteraction.h new file mode 100644 index 00000000..63db5876 --- /dev/null +++ b/new_editor/include/XCEditor/Core/UIEditorPropertyGridInteraction.h @@ -0,0 +1,58 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace XCEngine::UI::Editor { + +struct UIEditorPropertyGridInteractionState { + Widgets::UIEditorPropertyGridState propertyGridState = {}; + ::XCEngine::UI::Widgets::UIKeyboardNavigationModel keyboardNavigation = {}; + ::XCEngine::UI::Text::UITextInputState textInputState = {}; + ::XCEngine::UI::UIPoint pointerPosition = {}; + bool hasPointerPosition = false; +}; + +struct UIEditorPropertyGridInteractionResult { + bool consumed = false; + bool sectionToggled = false; + bool selectionChanged = false; + bool keyboardNavigated = false; + bool editStarted = false; + bool editValueChanged = false; + bool editCommitted = false; + bool editCanceled = false; + bool secondaryClicked = false; + Widgets::UIEditorPropertyGridHitTarget hitTarget = {}; + std::string toggledSectionId = {}; + std::string selectedFieldId = {}; + std::string activeFieldId = {}; + std::string committedFieldId = {}; + std::string committedValue = {}; +}; + +struct UIEditorPropertyGridInteractionFrame { + Widgets::UIEditorPropertyGridLayout layout = {}; + UIEditorPropertyGridInteractionResult result = {}; +}; + +UIEditorPropertyGridInteractionFrame UpdateUIEditorPropertyGridInteraction( + UIEditorPropertyGridInteractionState& state, + ::XCEngine::UI::Widgets::UISelectionModel& selectionModel, + ::XCEngine::UI::Widgets::UIExpansionModel& expansionModel, + ::XCEngine::UI::Widgets::UIPropertyEditModel& propertyEditModel, + const ::XCEngine::UI::UIRect& bounds, + const std::vector& sections, + const std::vector<::XCEngine::UI::UIInputEvent>& inputEvents, + const Widgets::UIEditorPropertyGridMetrics& metrics = {}); + +} // namespace XCEngine::UI::Editor diff --git a/new_editor/include/XCEditor/Core/UIEditorScrollViewInteraction.h b/new_editor/include/XCEditor/Core/UIEditorScrollViewInteraction.h new file mode 100644 index 00000000..70623329 --- /dev/null +++ b/new_editor/include/XCEditor/Core/UIEditorScrollViewInteraction.h @@ -0,0 +1,42 @@ +#pragma once + +#include + +#include + +#include + +namespace XCEngine::UI::Editor { + +struct UIEditorScrollViewInteractionState { + Widgets::UIEditorScrollViewState scrollViewState = {}; + ::XCEngine::UI::UIPoint pointerPosition = {}; + float thumbDragStartPointerY = 0.0f; + float thumbDragStartOffset = 0.0f; + bool hasPointerPosition = false; +}; + +struct UIEditorScrollViewInteractionResult { + bool consumed = false; + bool offsetChanged = false; + bool focusChanged = false; + bool startedThumbDrag = false; + bool endedThumbDrag = false; + Widgets::UIEditorScrollViewHitTarget hitTarget = {}; + float verticalOffset = 0.0f; +}; + +struct UIEditorScrollViewInteractionFrame { + Widgets::UIEditorScrollViewLayout layout = {}; + UIEditorScrollViewInteractionResult result = {}; +}; + +UIEditorScrollViewInteractionFrame UpdateUIEditorScrollViewInteraction( + UIEditorScrollViewInteractionState& state, + float& verticalOffset, + const ::XCEngine::UI::UIRect& bounds, + float contentHeight, + const std::vector<::XCEngine::UI::UIInputEvent>& inputEvents, + const Widgets::UIEditorScrollViewMetrics& metrics = {}); + +} // namespace XCEngine::UI::Editor diff --git a/new_editor/include/XCEditor/Widgets/UIEditorListView.h b/new_editor/include/XCEditor/Widgets/UIEditorListView.h new file mode 100644 index 00000000..522ebafc --- /dev/null +++ b/new_editor/include/XCEditor/Widgets/UIEditorListView.h @@ -0,0 +1,121 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include + +namespace XCEngine::UI::Editor::Widgets { + +inline constexpr std::size_t UIEditorListViewInvalidIndex = static_cast(-1); + +enum class UIEditorListViewHitTargetKind : std::uint8_t { + None = 0, + Row +}; + +struct UIEditorListViewItem { + std::string itemId = {}; + std::string primaryText = {}; + std::string secondaryText = {}; + float desiredHeight = 0.0f; +}; + +struct UIEditorListViewState { + std::string hoveredItemId = {}; + bool focused = false; +}; + +struct UIEditorListViewMetrics { + float rowHeight = 44.0f; + float rowGap = 2.0f; + float horizontalPadding = 10.0f; + float primaryTextInsetY = 7.0f; + float secondaryTextInsetY = 23.0f; + float singleLineTextInsetY = 13.0f; + float cornerRounding = 6.0f; + float borderThickness = 1.0f; + float focusedBorderThickness = 2.0f; +}; + +struct UIEditorListViewPalette { + ::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.31f, 0.31f, 0.31f, 1.0f); + ::XCEngine::UI::UIColor rowSelectedFocusedColor = + ::XCEngine::UI::UIColor(0.40f, 0.40f, 0.40f, 1.0f); + ::XCEngine::UI::UIColor primaryTextColor = + ::XCEngine::UI::UIColor(0.94f, 0.94f, 0.94f, 1.0f); + ::XCEngine::UI::UIColor secondaryTextColor = + ::XCEngine::UI::UIColor(0.70f, 0.70f, 0.70f, 1.0f); +}; + +struct UIEditorListViewLayout { + ::XCEngine::UI::UIRect bounds = {}; + std::vector itemIndices = {}; + std::vector<::XCEngine::UI::UIRect> rowRects = {}; + std::vector<::XCEngine::UI::UIRect> primaryTextRects = {}; + std::vector<::XCEngine::UI::UIRect> secondaryTextRects = {}; + std::vector hasSecondaryText = {}; +}; + +struct UIEditorListViewHitTarget { + UIEditorListViewHitTargetKind kind = UIEditorListViewHitTargetKind::None; + std::size_t visibleIndex = UIEditorListViewInvalidIndex; + std::size_t itemIndex = UIEditorListViewInvalidIndex; +}; + +bool IsUIEditorListViewPointInside( + const ::XCEngine::UI::UIRect& rect, + const ::XCEngine::UI::UIPoint& point); + +std::size_t FindUIEditorListViewItemIndex( + const std::vector& items, + std::string_view itemId); + +UIEditorListViewLayout BuildUIEditorListViewLayout( + const ::XCEngine::UI::UIRect& bounds, + const std::vector& items, + const UIEditorListViewMetrics& metrics = {}); + +UIEditorListViewHitTarget HitTestUIEditorListView( + const UIEditorListViewLayout& layout, + const ::XCEngine::UI::UIPoint& point); + +void AppendUIEditorListViewBackground( + ::XCEngine::UI::UIDrawList& drawList, + const UIEditorListViewLayout& layout, + const std::vector& items, + const ::XCEngine::UI::Widgets::UISelectionModel& selectionModel, + const UIEditorListViewState& state, + const UIEditorListViewPalette& palette = {}, + const UIEditorListViewMetrics& metrics = {}); + +void AppendUIEditorListViewForeground( + ::XCEngine::UI::UIDrawList& drawList, + const UIEditorListViewLayout& layout, + const std::vector& items, + const UIEditorListViewPalette& palette = {}, + const UIEditorListViewMetrics& metrics = {}); + +void AppendUIEditorListView( + ::XCEngine::UI::UIDrawList& drawList, + const ::XCEngine::UI::UIRect& bounds, + const std::vector& items, + const ::XCEngine::UI::Widgets::UISelectionModel& selectionModel, + const UIEditorListViewState& state, + const UIEditorListViewPalette& palette = {}, + const UIEditorListViewMetrics& metrics = {}); + +} // namespace XCEngine::UI::Editor::Widgets diff --git a/new_editor/include/XCEditor/Widgets/UIEditorPropertyGrid.h b/new_editor/include/XCEditor/Widgets/UIEditorPropertyGrid.h new file mode 100644 index 00000000..65a414ab --- /dev/null +++ b/new_editor/include/XCEditor/Widgets/UIEditorPropertyGrid.h @@ -0,0 +1,200 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace XCEngine::UI::Editor::Widgets { + +inline constexpr std::size_t UIEditorPropertyGridInvalidIndex = static_cast(-1); + +enum class UIEditorPropertyGridHitTargetKind : std::uint8_t { + None = 0, + SectionHeader, + FieldRow, + ValueBox +}; + +struct UIEditorPropertyGridFieldLocation { + std::size_t sectionIndex = UIEditorPropertyGridInvalidIndex; + std::size_t fieldIndex = UIEditorPropertyGridInvalidIndex; + + constexpr bool IsValid() const { + return sectionIndex != UIEditorPropertyGridInvalidIndex && + fieldIndex != UIEditorPropertyGridInvalidIndex; + } +}; + +struct UIEditorPropertyGridField { + std::string fieldId = {}; + std::string label = {}; + std::string valueText = {}; + bool readOnly = false; + float desiredHeight = 0.0f; +}; + +struct UIEditorPropertyGridSection { + std::string sectionId = {}; + std::string title = {}; + std::vector fields = {}; + float desiredHeaderHeight = 0.0f; +}; + +struct UIEditorPropertyGridState { + std::string hoveredSectionId = {}; + std::string hoveredFieldId = {}; + bool focused = false; +}; + +struct UIEditorPropertyGridMetrics { + float contentInset = 8.0f; + float sectionGap = 8.0f; + float sectionHeaderHeight = 32.0f; + float fieldRowHeight = 32.0f; + float rowGap = 2.0f; + float horizontalPadding = 12.0f; + float controlColumnStart = 236.0f; + float labelControlGap = 20.0f; + float disclosureExtent = 12.0f; + float disclosureLabelGap = 8.0f; + float sectionTextInsetY = 8.0f; + float labelTextInsetY = 8.0f; + float valueTextInsetY = 8.0f; + float valueBoxInsetY = 4.0f; + float valueBoxInsetX = 8.0f; + float cornerRounding = 6.0f; + float valueBoxRounding = 5.0f; + float borderThickness = 1.0f; + float focusedBorderThickness = 2.0f; + float editOutlineThickness = 1.0f; +}; + +struct UIEditorPropertyGridPalette { + ::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 sectionHeaderColor = + ::XCEngine::UI::UIColor(0.19f, 0.19f, 0.19f, 1.0f); + ::XCEngine::UI::UIColor sectionHeaderHoverColor = + ::XCEngine::UI::UIColor(0.24f, 0.24f, 0.24f, 1.0f); + ::XCEngine::UI::UIColor fieldHoverColor = + ::XCEngine::UI::UIColor(0.24f, 0.24f, 0.24f, 1.0f); + ::XCEngine::UI::UIColor fieldSelectedColor = + ::XCEngine::UI::UIColor(0.31f, 0.31f, 0.31f, 1.0f); + ::XCEngine::UI::UIColor fieldSelectedFocusedColor = + ::XCEngine::UI::UIColor(0.40f, 0.40f, 0.40f, 1.0f); + ::XCEngine::UI::UIColor valueBoxColor = + ::XCEngine::UI::UIColor(0.17f, 0.17f, 0.17f, 1.0f); + ::XCEngine::UI::UIColor valueBoxHoverColor = + ::XCEngine::UI::UIColor(0.22f, 0.22f, 0.22f, 1.0f); + ::XCEngine::UI::UIColor valueBoxEditingColor = + ::XCEngine::UI::UIColor(0.24f, 0.24f, 0.24f, 1.0f); + ::XCEngine::UI::UIColor valueBoxReadOnlyColor = + ::XCEngine::UI::UIColor(0.15f, 0.15f, 0.15f, 1.0f); + ::XCEngine::UI::UIColor valueBoxBorderColor = + ::XCEngine::UI::UIColor(0.32f, 0.32f, 0.32f, 1.0f); + ::XCEngine::UI::UIColor valueBoxEditingBorderColor = + ::XCEngine::UI::UIColor(0.75f, 0.75f, 0.75f, 1.0f); + ::XCEngine::UI::UIColor disclosureColor = + ::XCEngine::UI::UIColor(0.74f, 0.74f, 0.74f, 1.0f); + ::XCEngine::UI::UIColor sectionTextColor = + ::XCEngine::UI::UIColor(0.94f, 0.94f, 0.94f, 1.0f); + ::XCEngine::UI::UIColor labelTextColor = + ::XCEngine::UI::UIColor(0.84f, 0.84f, 0.84f, 1.0f); + ::XCEngine::UI::UIColor valueTextColor = + ::XCEngine::UI::UIColor(0.94f, 0.94f, 0.94f, 1.0f); + ::XCEngine::UI::UIColor readOnlyValueTextColor = + ::XCEngine::UI::UIColor(0.60f, 0.60f, 0.60f, 1.0f); + ::XCEngine::UI::UIColor editTagColor = + ::XCEngine::UI::UIColor(0.62f, 0.78f, 0.96f, 1.0f); +}; + +struct UIEditorPropertyGridLayout { + ::XCEngine::UI::UIRect bounds = {}; + std::vector sectionIndices = {}; + std::vector<::XCEngine::UI::UIRect> sectionHeaderRects = {}; + std::vector<::XCEngine::UI::UIRect> sectionDisclosureRects = {}; + std::vector<::XCEngine::UI::UIRect> sectionTitleRects = {}; + std::vector sectionExpanded = {}; + std::vector visibleFieldSectionIndices = {}; + std::vector visibleFieldIndices = {}; + std::vector<::XCEngine::UI::UIRect> fieldRowRects = {}; + std::vector<::XCEngine::UI::UIRect> fieldLabelRects = {}; + std::vector<::XCEngine::UI::UIRect> fieldValueRects = {}; + std::vector fieldReadOnly = {}; +}; + +struct UIEditorPropertyGridHitTarget { + UIEditorPropertyGridHitTargetKind kind = UIEditorPropertyGridHitTargetKind::None; + std::size_t sectionIndex = UIEditorPropertyGridInvalidIndex; + std::size_t fieldIndex = UIEditorPropertyGridInvalidIndex; + std::size_t visibleFieldIndex = UIEditorPropertyGridInvalidIndex; +}; + +bool IsUIEditorPropertyGridPointInside( + const ::XCEngine::UI::UIRect& rect, + const ::XCEngine::UI::UIPoint& point); + +std::size_t FindUIEditorPropertyGridSectionIndex( + const std::vector& sections, + std::string_view sectionId); + +UIEditorPropertyGridFieldLocation FindUIEditorPropertyGridFieldLocation( + const std::vector& sections, + std::string_view fieldId); + +std::size_t FindUIEditorPropertyGridVisibleFieldIndex( + const UIEditorPropertyGridLayout& layout, + std::string_view fieldId, + const std::vector& sections); + +UIEditorPropertyGridLayout BuildUIEditorPropertyGridLayout( + const ::XCEngine::UI::UIRect& bounds, + const std::vector& sections, + const ::XCEngine::UI::Widgets::UIExpansionModel& expansionModel, + const UIEditorPropertyGridMetrics& metrics = {}); + +UIEditorPropertyGridHitTarget HitTestUIEditorPropertyGrid( + const UIEditorPropertyGridLayout& layout, + const ::XCEngine::UI::UIPoint& point); + +void AppendUIEditorPropertyGridBackground( + ::XCEngine::UI::UIDrawList& drawList, + const UIEditorPropertyGridLayout& layout, + const std::vector& sections, + const ::XCEngine::UI::Widgets::UISelectionModel& selectionModel, + const ::XCEngine::UI::Widgets::UIPropertyEditModel& propertyEditModel, + const UIEditorPropertyGridState& state, + const UIEditorPropertyGridPalette& palette = {}, + const UIEditorPropertyGridMetrics& metrics = {}); + +void AppendUIEditorPropertyGridForeground( + ::XCEngine::UI::UIDrawList& drawList, + const UIEditorPropertyGridLayout& layout, + const std::vector& sections, + const ::XCEngine::UI::Widgets::UIPropertyEditModel& propertyEditModel, + const UIEditorPropertyGridPalette& palette = {}, + const UIEditorPropertyGridMetrics& metrics = {}); + +void AppendUIEditorPropertyGrid( + ::XCEngine::UI::UIDrawList& drawList, + const ::XCEngine::UI::UIRect& bounds, + const std::vector& sections, + const ::XCEngine::UI::Widgets::UISelectionModel& selectionModel, + const ::XCEngine::UI::Widgets::UIExpansionModel& expansionModel, + const ::XCEngine::UI::Widgets::UIPropertyEditModel& propertyEditModel, + const UIEditorPropertyGridState& state, + const UIEditorPropertyGridPalette& palette = {}, + const UIEditorPropertyGridMetrics& metrics = {}); + +} // namespace XCEngine::UI::Editor::Widgets diff --git a/new_editor/include/XCEditor/Widgets/UIEditorScrollView.h b/new_editor/include/XCEditor/Widgets/UIEditorScrollView.h new file mode 100644 index 00000000..8d80a696 --- /dev/null +++ b/new_editor/include/XCEditor/Widgets/UIEditorScrollView.h @@ -0,0 +1,96 @@ +#pragma once + +#include +#include + +#include + +namespace XCEngine::UI::Editor::Widgets { + +enum class UIEditorScrollViewHitTargetKind : std::uint8_t { + None = 0, + Content, + ScrollbarTrack, + ScrollbarThumb +}; + +struct UIEditorScrollViewState { + bool focused = false; + bool hovered = false; + bool scrollbarHovered = false; + bool draggingScrollbarThumb = false; +}; + +struct UIEditorScrollViewMetrics { + float scrollbarWidth = 10.0f; + float scrollbarInset = 4.0f; + float minThumbHeight = 28.0f; + float cornerRounding = 6.0f; + float borderThickness = 1.0f; + float focusedBorderThickness = 2.0f; + float wheelStep = 48.0f; +}; + +struct UIEditorScrollViewPalette { + ::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 scrollbarTrackColor = + ::XCEngine::UI::UIColor(0.18f, 0.18f, 0.18f, 1.0f); + ::XCEngine::UI::UIColor scrollbarThumbColor = + ::XCEngine::UI::UIColor(0.31f, 0.31f, 0.31f, 1.0f); + ::XCEngine::UI::UIColor scrollbarThumbHoverColor = + ::XCEngine::UI::UIColor(0.38f, 0.38f, 0.38f, 1.0f); + ::XCEngine::UI::UIColor scrollbarThumbActiveColor = + ::XCEngine::UI::UIColor(0.46f, 0.46f, 0.46f, 1.0f); +}; + +struct UIEditorScrollViewLayout { + ::XCEngine::UI::UIRect bounds = {}; + ::XCEngine::UI::UIRect contentRect = {}; + ::XCEngine::UI::UIRect scrollbarTrackRect = {}; + ::XCEngine::UI::UIRect scrollbarThumbRect = {}; + float contentHeight = 0.0f; + float verticalOffset = 0.0f; + float maxOffset = 0.0f; + bool hasScrollbar = false; +}; + +struct UIEditorScrollViewHitTarget { + UIEditorScrollViewHitTargetKind kind = UIEditorScrollViewHitTargetKind::None; +}; + +bool IsUIEditorScrollViewPointInside( + const ::XCEngine::UI::UIRect& rect, + const ::XCEngine::UI::UIPoint& point); + +float ClampUIEditorScrollViewOffset( + const ::XCEngine::UI::UIRect& bounds, + float contentHeight, + float verticalOffset, + const UIEditorScrollViewMetrics& metrics = {}); + +UIEditorScrollViewLayout BuildUIEditorScrollViewLayout( + const ::XCEngine::UI::UIRect& bounds, + float contentHeight, + float verticalOffset, + const UIEditorScrollViewMetrics& metrics = {}); + +::XCEngine::UI::UIPoint ResolveUIEditorScrollViewContentOrigin( + const UIEditorScrollViewLayout& layout); + +UIEditorScrollViewHitTarget HitTestUIEditorScrollView( + const UIEditorScrollViewLayout& layout, + const ::XCEngine::UI::UIPoint& point); + +void AppendUIEditorScrollViewBackground( + ::XCEngine::UI::UIDrawList& drawList, + const UIEditorScrollViewLayout& layout, + const UIEditorScrollViewState& state, + const UIEditorScrollViewPalette& palette = {}, + const UIEditorScrollViewMetrics& metrics = {}); + +} // namespace XCEngine::UI::Editor::Widgets diff --git a/new_editor/src/Core/UIEditorListViewInteraction.cpp b/new_editor/src/Core/UIEditorListViewInteraction.cpp new file mode 100644 index 00000000..3d0e669a --- /dev/null +++ b/new_editor/src/Core/UIEditorListViewInteraction.cpp @@ -0,0 +1,255 @@ +#include + +#include + +#include + +namespace XCEngine::UI::Editor { + +namespace { + +using ::XCEngine::Input::KeyCode; +using ::XCEngine::UI::UIInputEvent; +using ::XCEngine::UI::UIInputEventType; +using ::XCEngine::UI::UIPointerButton; +using Widgets::BuildUIEditorListViewLayout; +using Widgets::FindUIEditorListViewItemIndex; +using Widgets::HitTestUIEditorListView; +using Widgets::IsUIEditorListViewPointInside; +using Widgets::UIEditorListViewHitTarget; +using Widgets::UIEditorListViewHitTargetKind; +using Widgets::UIEditorListViewInvalidIndex; + +bool ShouldUsePointerPosition(const UIInputEvent& event) { + switch (event.type) { + case UIInputEventType::PointerMove: + case UIInputEventType::PointerEnter: + case UIInputEventType::PointerButtonDown: + case UIInputEventType::PointerButtonUp: + case UIInputEventType::PointerWheel: + return true; + default: + return false; + } +} + +bool HasNavigationModifiers(const ::XCEngine::UI::UIInputModifiers& modifiers) { + return modifiers.shift || modifiers.control || modifiers.alt || modifiers.super; +} + +void SyncHoverTarget( + UIEditorListViewInteractionState& state, + const Widgets::UIEditorListViewLayout& layout, + const std::vector& items) { + state.listViewState.hoveredItemId.clear(); + if (!state.hasPointerPosition) { + return; + } + + const UIEditorListViewHitTarget hitTarget = + HitTestUIEditorListView(layout, state.pointerPosition); + if (hitTarget.itemIndex < items.size()) { + state.listViewState.hoveredItemId = items[hitTarget.itemIndex].itemId; + } +} + +void SyncKeyboardNavigation( + UIEditorListViewInteractionState& state, + const ::XCEngine::UI::Widgets::UISelectionModel& selectionModel, + const std::vector& items) { + state.keyboardNavigation.SetItemCount(items.size()); + state.keyboardNavigation.ClampToItemCount(); + + if (!selectionModel.HasSelection()) { + return; + } + + const std::size_t selectedIndex = + FindUIEditorListViewItemIndex(items, selectionModel.GetSelectedId()); + if (selectedIndex == UIEditorListViewInvalidIndex) { + return; + } + + if (!state.keyboardNavigation.HasCurrentIndex() || + state.keyboardNavigation.GetCurrentIndex() != selectedIndex) { + state.keyboardNavigation.SetCurrentIndex(selectedIndex); + } +} + +bool ApplyKeyboardNavigation( + UIEditorListViewInteractionState& state, + std::int32_t keyCode) { + switch (static_cast(keyCode)) { + case KeyCode::Up: + return state.keyboardNavigation.MovePrevious(); + case KeyCode::Down: + return state.keyboardNavigation.MoveNext(); + case KeyCode::Home: + return state.keyboardNavigation.MoveHome(); + case KeyCode::End: + return state.keyboardNavigation.MoveEnd(); + default: + return false; + } +} + +} // namespace + +UIEditorListViewInteractionFrame UpdateUIEditorListViewInteraction( + UIEditorListViewInteractionState& state, + ::XCEngine::UI::Widgets::UISelectionModel& selectionModel, + const ::XCEngine::UI::UIRect& bounds, + const std::vector& items, + const std::vector& inputEvents, + const Widgets::UIEditorListViewMetrics& metrics) { + Widgets::UIEditorListViewLayout layout = + BuildUIEditorListViewLayout(bounds, items, metrics); + SyncKeyboardNavigation(state, selectionModel, items); + SyncHoverTarget(state, layout, items); + + UIEditorListViewInteractionResult 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; + } + + UIEditorListViewInteractionResult eventResult = {}; + switch (event.type) { + case UIInputEventType::FocusGained: + state.listViewState.focused = true; + break; + + case UIInputEventType::FocusLost: + state.listViewState.focused = false; + state.hasPointerPosition = false; + state.listViewState.hoveredItemId.clear(); + break; + + case UIInputEventType::PointerMove: + case UIInputEventType::PointerEnter: + case UIInputEventType::PointerLeave: + break; + + case UIInputEventType::PointerButtonDown: { + const UIEditorListViewHitTarget hitTarget = + state.hasPointerPosition + ? HitTestUIEditorListView(layout, state.pointerPosition) + : UIEditorListViewHitTarget {}; + eventResult.hitTarget = hitTarget; + + const bool insideList = + state.hasPointerPosition && + IsUIEditorListViewPointInside(layout.bounds, state.pointerPosition); + if ((event.pointerButton == UIPointerButton::Left || + event.pointerButton == UIPointerButton::Right) && + hitTarget.kind == UIEditorListViewHitTargetKind::Row) { + state.listViewState.focused = true; + eventResult.consumed = true; + } else if (event.pointerButton == UIPointerButton::Left && insideList) { + state.listViewState.focused = true; + eventResult.consumed = true; + } else if (event.pointerButton == UIPointerButton::Left) { + state.listViewState.focused = false; + } + break; + } + + case UIInputEventType::PointerButtonUp: { + const UIEditorListViewHitTarget hitTarget = + state.hasPointerPosition + ? HitTestUIEditorListView(layout, state.pointerPosition) + : UIEditorListViewHitTarget {}; + eventResult.hitTarget = hitTarget; + + const bool insideList = + state.hasPointerPosition && + IsUIEditorListViewPointInside(layout.bounds, state.pointerPosition); + if (hitTarget.itemIndex >= items.size()) { + if (event.pointerButton == UIPointerButton::Left && insideList) { + state.listViewState.focused = true; + eventResult.consumed = true; + } else if (event.pointerButton == UIPointerButton::Left) { + state.listViewState.focused = false; + } + break; + } + + const Widgets::UIEditorListViewItem& item = items[hitTarget.itemIndex]; + if (event.pointerButton == UIPointerButton::Left && + hitTarget.kind == UIEditorListViewHitTargetKind::Row) { + eventResult.selectionChanged = selectionModel.SetSelection(item.itemId); + eventResult.selectedItemId = item.itemId; + eventResult.selectedIndex = hitTarget.itemIndex; + eventResult.consumed = true; + state.listViewState.focused = true; + state.keyboardNavigation.SetCurrentIndex(hitTarget.itemIndex); + } else if (event.pointerButton == UIPointerButton::Right && + hitTarget.kind == UIEditorListViewHitTargetKind::Row) { + eventResult.selectionChanged = selectionModel.SetSelection(item.itemId); + eventResult.selectedItemId = item.itemId; + eventResult.selectedIndex = hitTarget.itemIndex; + eventResult.secondaryClicked = true; + eventResult.consumed = true; + state.listViewState.focused = true; + state.keyboardNavigation.SetCurrentIndex(hitTarget.itemIndex); + } + break; + } + + case UIInputEventType::KeyDown: + if (state.listViewState.focused && !HasNavigationModifiers(event.modifiers)) { + if (ApplyKeyboardNavigation(state, event.keyCode) && + state.keyboardNavigation.HasCurrentIndex()) { + const std::size_t currentIndex = state.keyboardNavigation.GetCurrentIndex(); + if (currentIndex < items.size()) { + eventResult.selectionChanged = + selectionModel.SetSelection(items[currentIndex].itemId); + eventResult.keyboardNavigated = true; + eventResult.selectedItemId = items[currentIndex].itemId; + eventResult.selectedIndex = currentIndex; + eventResult.consumed = true; + } + } + } + break; + + default: + break; + } + + layout = BuildUIEditorListViewLayout(bounds, items, metrics); + SyncKeyboardNavigation(state, selectionModel, items); + SyncHoverTarget(state, layout, items); + if (eventResult.hitTarget.kind == UIEditorListViewHitTargetKind::None && + state.hasPointerPosition) { + eventResult.hitTarget = HitTestUIEditorListView(layout, state.pointerPosition); + } + + if (eventResult.consumed || + eventResult.selectionChanged || + eventResult.keyboardNavigated || + eventResult.secondaryClicked || + eventResult.hitTarget.kind != UIEditorListViewHitTargetKind::None || + !eventResult.selectedItemId.empty()) { + interactionResult = std::move(eventResult); + } + } + + layout = BuildUIEditorListViewLayout(bounds, items, metrics); + SyncKeyboardNavigation(state, selectionModel, items); + SyncHoverTarget(state, layout, items); + if (interactionResult.hitTarget.kind == UIEditorListViewHitTargetKind::None && + state.hasPointerPosition) { + interactionResult.hitTarget = HitTestUIEditorListView(layout, state.pointerPosition); + } + + return { + std::move(layout), + std::move(interactionResult) + }; +} + +} // namespace XCEngine::UI::Editor diff --git a/new_editor/src/Core/UIEditorPropertyGridInteraction.cpp b/new_editor/src/Core/UIEditorPropertyGridInteraction.cpp new file mode 100644 index 00000000..3cba1575 --- /dev/null +++ b/new_editor/src/Core/UIEditorPropertyGridInteraction.cpp @@ -0,0 +1,478 @@ +#include + +#include + +#include + +namespace XCEngine::UI::Editor { + +namespace { + +using ::XCEngine::Input::KeyCode; +using ::XCEngine::UI::Text::HandleKeyDown; +using ::XCEngine::UI::Text::InsertCharacter; +using ::XCEngine::UI::UIInputEvent; +using ::XCEngine::UI::UIInputEventType; +using ::XCEngine::UI::UIPointerButton; +using Widgets::BuildUIEditorPropertyGridLayout; +using Widgets::FindUIEditorPropertyGridVisibleFieldIndex; +using Widgets::HitTestUIEditorPropertyGrid; +using Widgets::IsUIEditorPropertyGridPointInside; +using Widgets::UIEditorPropertyGridHitTarget; +using Widgets::UIEditorPropertyGridHitTargetKind; +using Widgets::UIEditorPropertyGridInvalidIndex; + +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; + } +} + +bool HasSystemModifiers(const ::XCEngine::UI::UIInputModifiers& modifiers) { + return modifiers.control || modifiers.alt || modifiers.super; +} + +bool ApplyKeyboardNavigation( + UIEditorPropertyGridInteractionState& state, + std::int32_t keyCode) { + switch (static_cast(keyCode)) { + case KeyCode::Up: + return state.keyboardNavigation.MovePrevious(); + case KeyCode::Down: + return state.keyboardNavigation.MoveNext(); + case KeyCode::Home: + return state.keyboardNavigation.MoveHome(); + case KeyCode::End: + return state.keyboardNavigation.MoveEnd(); + default: + return false; + } +} + +void ClearHoverState(UIEditorPropertyGridInteractionState& state) { + state.propertyGridState.hoveredSectionId.clear(); + state.propertyGridState.hoveredFieldId.clear(); +} + +void SyncHoverTarget( + UIEditorPropertyGridInteractionState& state, + const Widgets::UIEditorPropertyGridLayout& layout, + const std::vector& sections) { + ClearHoverState(state); + if (!state.hasPointerPosition) { + return; + } + + const UIEditorPropertyGridHitTarget hitTarget = + HitTestUIEditorPropertyGrid(layout, state.pointerPosition); + if (hitTarget.kind == UIEditorPropertyGridHitTargetKind::SectionHeader && + hitTarget.sectionIndex < sections.size()) { + state.propertyGridState.hoveredSectionId = sections[hitTarget.sectionIndex].sectionId; + return; + } + + if ((hitTarget.kind == UIEditorPropertyGridHitTargetKind::FieldRow || + hitTarget.kind == UIEditorPropertyGridHitTargetKind::ValueBox) && + hitTarget.sectionIndex < sections.size() && + hitTarget.fieldIndex < sections[hitTarget.sectionIndex].fields.size()) { + state.propertyGridState.hoveredFieldId = + sections[hitTarget.sectionIndex].fields[hitTarget.fieldIndex].fieldId; + } +} + +void SyncKeyboardNavigation( + UIEditorPropertyGridInteractionState& state, + const ::XCEngine::UI::Widgets::UISelectionModel& selectionModel, + const Widgets::UIEditorPropertyGridLayout& layout, + const std::vector& sections) { + state.keyboardNavigation.SetItemCount(layout.visibleFieldIndices.size()); + state.keyboardNavigation.ClampToItemCount(); + + if (!selectionModel.HasSelection()) { + return; + } + + const std::size_t selectedVisibleIndex = + FindUIEditorPropertyGridVisibleFieldIndex( + layout, + selectionModel.GetSelectedId(), + sections); + if (selectedVisibleIndex == UIEditorPropertyGridInvalidIndex) { + return; + } + + if (!state.keyboardNavigation.HasCurrentIndex() || + state.keyboardNavigation.GetCurrentIndex() != selectedVisibleIndex) { + state.keyboardNavigation.SetCurrentIndex(selectedVisibleIndex); + } +} + +bool BeginFieldEdit( + UIEditorPropertyGridInteractionState& state, + ::XCEngine::UI::Widgets::UIPropertyEditModel& propertyEditModel, + const Widgets::UIEditorPropertyGridField& field, + UIEditorPropertyGridInteractionResult& result) { + if (field.readOnly) { + return false; + } + + const bool changed = propertyEditModel.BeginEdit(field.fieldId, field.valueText); + if (!changed && + (!propertyEditModel.HasActiveEdit() || + propertyEditModel.GetActiveFieldId() != field.fieldId)) { + return false; + } + + state.textInputState.value = propertyEditModel.GetStagedValue(); + state.textInputState.caret = state.textInputState.value.size(); + result.editStarted = changed; + result.activeFieldId = field.fieldId; + result.consumed = true; + return true; +} + +bool CommitActiveEdit( + UIEditorPropertyGridInteractionState& state, + ::XCEngine::UI::Widgets::UIPropertyEditModel& propertyEditModel, + UIEditorPropertyGridInteractionResult& result) { + if (!propertyEditModel.HasActiveEdit()) { + return false; + } + + if (!propertyEditModel.CommitEdit(&result.committedFieldId, &result.committedValue)) { + return false; + } + + state.textInputState = {}; + result.editCommitted = true; + result.consumed = true; + return true; +} + +bool CancelActiveEdit( + UIEditorPropertyGridInteractionState& state, + ::XCEngine::UI::Widgets::UIPropertyEditModel& propertyEditModel, + UIEditorPropertyGridInteractionResult& result) { + if (!propertyEditModel.HasActiveEdit()) { + return false; + } + + result.activeFieldId = propertyEditModel.GetActiveFieldId(); + if (!propertyEditModel.CancelEdit()) { + return false; + } + + state.textInputState = {}; + result.editCanceled = true; + result.consumed = true; + return true; +} + +bool SelectVisibleField( + UIEditorPropertyGridInteractionState& state, + ::XCEngine::UI::Widgets::UISelectionModel& selectionModel, + const Widgets::UIEditorPropertyGridLayout& layout, + const std::vector& sections, + std::size_t visibleFieldIndex, + UIEditorPropertyGridInteractionResult& result) { + if (visibleFieldIndex >= layout.visibleFieldIndices.size() || + visibleFieldIndex >= layout.visibleFieldSectionIndices.size()) { + return false; + } + + const std::size_t sectionIndex = layout.visibleFieldSectionIndices[visibleFieldIndex]; + const std::size_t fieldIndex = layout.visibleFieldIndices[visibleFieldIndex]; + if (sectionIndex >= sections.size() || + fieldIndex >= sections[sectionIndex].fields.size()) { + return false; + } + + const Widgets::UIEditorPropertyGridField& field = sections[sectionIndex].fields[fieldIndex]; + result.selectionChanged = selectionModel.SetSelection(field.fieldId); + result.selectedFieldId = field.fieldId; + result.consumed = true; + state.keyboardNavigation.SetCurrentIndex(visibleFieldIndex); + return true; +} + +} // namespace + +UIEditorPropertyGridInteractionFrame UpdateUIEditorPropertyGridInteraction( + UIEditorPropertyGridInteractionState& state, + ::XCEngine::UI::Widgets::UISelectionModel& selectionModel, + ::XCEngine::UI::Widgets::UIExpansionModel& expansionModel, + ::XCEngine::UI::Widgets::UIPropertyEditModel& propertyEditModel, + const ::XCEngine::UI::UIRect& bounds, + const std::vector& sections, + const std::vector& inputEvents, + const Widgets::UIEditorPropertyGridMetrics& metrics) { + Widgets::UIEditorPropertyGridLayout layout = + BuildUIEditorPropertyGridLayout(bounds, sections, expansionModel, metrics); + SyncKeyboardNavigation(state, selectionModel, layout, sections); + SyncHoverTarget(state, layout, sections); + + UIEditorPropertyGridInteractionResult 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; + } + + UIEditorPropertyGridInteractionResult eventResult = {}; + switch (event.type) { + case UIInputEventType::FocusGained: + state.propertyGridState.focused = true; + break; + + case UIInputEventType::FocusLost: + CommitActiveEdit(state, propertyEditModel, eventResult); + state.propertyGridState.focused = false; + state.hasPointerPosition = false; + ClearHoverState(state); + break; + + case UIInputEventType::PointerMove: + case UIInputEventType::PointerEnter: + case UIInputEventType::PointerLeave: + break; + + case UIInputEventType::PointerButtonDown: { + const UIEditorPropertyGridHitTarget hitTarget = + state.hasPointerPosition + ? HitTestUIEditorPropertyGrid(layout, state.pointerPosition) + : UIEditorPropertyGridHitTarget {}; + eventResult.hitTarget = hitTarget; + const bool insideGrid = + state.hasPointerPosition && + IsUIEditorPropertyGridPointInside(layout.bounds, state.pointerPosition); + if ((event.pointerButton == UIPointerButton::Left || + event.pointerButton == UIPointerButton::Right) && + hitTarget.kind != UIEditorPropertyGridHitTargetKind::None) { + state.propertyGridState.focused = true; + eventResult.consumed = true; + } else if (event.pointerButton == UIPointerButton::Left && insideGrid) { + state.propertyGridState.focused = true; + eventResult.consumed = true; + } else if (event.pointerButton == UIPointerButton::Left) { + state.propertyGridState.focused = false; + } + break; + } + + case UIInputEventType::PointerButtonUp: { + const UIEditorPropertyGridHitTarget hitTarget = + state.hasPointerPosition + ? HitTestUIEditorPropertyGrid(layout, state.pointerPosition) + : UIEditorPropertyGridHitTarget {}; + eventResult.hitTarget = hitTarget; + + const bool insideGrid = + state.hasPointerPosition && + IsUIEditorPropertyGridPointInside(layout.bounds, state.pointerPosition); + if (hitTarget.kind == UIEditorPropertyGridHitTargetKind::None) { + if (event.pointerButton == UIPointerButton::Left) { + CommitActiveEdit(state, propertyEditModel, eventResult); + if (insideGrid) { + state.propertyGridState.focused = true; + eventResult.consumed = true; + } else { + state.propertyGridState.focused = false; + } + } + break; + } + + if (event.pointerButton == UIPointerButton::Left) { + const bool clickingActiveValueBox = + hitTarget.kind == UIEditorPropertyGridHitTargetKind::ValueBox && + hitTarget.sectionIndex < sections.size() && + hitTarget.fieldIndex < sections[hitTarget.sectionIndex].fields.size() && + propertyEditModel.HasActiveEdit() && + propertyEditModel.GetActiveFieldId() == + sections[hitTarget.sectionIndex].fields[hitTarget.fieldIndex].fieldId; + if (!clickingActiveValueBox) { + CommitActiveEdit(state, propertyEditModel, eventResult); + } + + if (hitTarget.kind == UIEditorPropertyGridHitTargetKind::SectionHeader && + hitTarget.sectionIndex < sections.size()) { + const std::string& sectionId = sections[hitTarget.sectionIndex].sectionId; + eventResult.sectionToggled = expansionModel.ToggleExpanded(sectionId); + eventResult.toggledSectionId = sectionId; + eventResult.consumed = true; + state.propertyGridState.focused = true; + } else if ((hitTarget.kind == UIEditorPropertyGridHitTargetKind::FieldRow || + hitTarget.kind == UIEditorPropertyGridHitTargetKind::ValueBox) && + hitTarget.sectionIndex < sections.size() && + hitTarget.fieldIndex < sections[hitTarget.sectionIndex].fields.size()) { + state.propertyGridState.focused = true; + SelectVisibleField( + state, + selectionModel, + layout, + sections, + hitTarget.visibleFieldIndex, + eventResult); + + const Widgets::UIEditorPropertyGridField& field = + sections[hitTarget.sectionIndex].fields[hitTarget.fieldIndex]; + if (hitTarget.kind == UIEditorPropertyGridHitTargetKind::ValueBox) { + BeginFieldEdit(state, propertyEditModel, field, eventResult); + } + } + } else if (event.pointerButton == UIPointerButton::Right && + (hitTarget.kind == UIEditorPropertyGridHitTargetKind::FieldRow || + hitTarget.kind == UIEditorPropertyGridHitTargetKind::ValueBox) && + hitTarget.sectionIndex < sections.size() && + hitTarget.fieldIndex < sections[hitTarget.sectionIndex].fields.size()) { + const Widgets::UIEditorPropertyGridField& field = + sections[hitTarget.sectionIndex].fields[hitTarget.fieldIndex]; + eventResult.selectionChanged = selectionModel.SetSelection(field.fieldId); + eventResult.selectedFieldId = field.fieldId; + eventResult.secondaryClicked = true; + eventResult.consumed = true; + state.propertyGridState.focused = true; + if (hitTarget.visibleFieldIndex != UIEditorPropertyGridInvalidIndex) { + state.keyboardNavigation.SetCurrentIndex(hitTarget.visibleFieldIndex); + } + } + break; + } + + case UIInputEventType::KeyDown: + if (!state.propertyGridState.focused) { + break; + } + + if (propertyEditModel.HasActiveEdit()) { + if (static_cast(event.keyCode) == KeyCode::Escape) { + CancelActiveEdit(state, propertyEditModel, eventResult); + break; + } + + const auto editResult = HandleKeyDown( + state.textInputState, + event.keyCode, + event.modifiers, + {}); + if (editResult.handled) { + eventResult.consumed = true; + eventResult.activeFieldId = propertyEditModel.GetActiveFieldId(); + if (editResult.valueChanged) { + propertyEditModel.UpdateStagedValue(state.textInputState.value); + eventResult.editValueChanged = true; + } + if (editResult.submitRequested) { + CommitActiveEdit(state, propertyEditModel, eventResult); + } + } + break; + } + + if (static_cast(event.keyCode) == KeyCode::Enter && + selectionModel.HasSelection()) { + const std::size_t visibleFieldIndex = + FindUIEditorPropertyGridVisibleFieldIndex( + layout, + selectionModel.GetSelectedId(), + sections); + if (visibleFieldIndex != UIEditorPropertyGridInvalidIndex) { + const std::size_t sectionIndex = layout.visibleFieldSectionIndices[visibleFieldIndex]; + const std::size_t fieldIndex = layout.visibleFieldIndices[visibleFieldIndex]; + if (sectionIndex < sections.size() && + fieldIndex < sections[sectionIndex].fields.size()) { + BeginFieldEdit( + state, + propertyEditModel, + sections[sectionIndex].fields[fieldIndex], + eventResult); + } + } + break; + } + + if (!event.modifiers.shift && + !HasSystemModifiers(event.modifiers) && + ApplyKeyboardNavigation(state, event.keyCode) && + state.keyboardNavigation.HasCurrentIndex()) { + const std::size_t currentIndex = state.keyboardNavigation.GetCurrentIndex(); + if (SelectVisibleField( + state, + selectionModel, + layout, + sections, + currentIndex, + eventResult)) { + eventResult.keyboardNavigated = true; + } + } + break; + + case UIInputEventType::Character: + if (!state.propertyGridState.focused || + !propertyEditModel.HasActiveEdit() || + HasSystemModifiers(event.modifiers)) { + break; + } + + if (InsertCharacter(state.textInputState, event.character)) { + propertyEditModel.UpdateStagedValue(state.textInputState.value); + eventResult.consumed = true; + eventResult.editValueChanged = true; + eventResult.activeFieldId = propertyEditModel.GetActiveFieldId(); + } + break; + + default: + break; + } + + layout = BuildUIEditorPropertyGridLayout(bounds, sections, expansionModel, metrics); + SyncKeyboardNavigation(state, selectionModel, layout, sections); + SyncHoverTarget(state, layout, sections); + if (eventResult.hitTarget.kind == UIEditorPropertyGridHitTargetKind::None && + state.hasPointerPosition) { + eventResult.hitTarget = HitTestUIEditorPropertyGrid(layout, state.pointerPosition); + } + + if (eventResult.consumed || + eventResult.sectionToggled || + eventResult.selectionChanged || + eventResult.keyboardNavigated || + eventResult.editStarted || + eventResult.editValueChanged || + eventResult.editCommitted || + eventResult.editCanceled || + eventResult.secondaryClicked || + eventResult.hitTarget.kind != UIEditorPropertyGridHitTargetKind::None || + !eventResult.toggledSectionId.empty() || + !eventResult.selectedFieldId.empty() || + !eventResult.activeFieldId.empty() || + !eventResult.committedFieldId.empty()) { + interactionResult = std::move(eventResult); + } + } + + layout = BuildUIEditorPropertyGridLayout(bounds, sections, expansionModel, metrics); + SyncKeyboardNavigation(state, selectionModel, layout, sections); + SyncHoverTarget(state, layout, sections); + if (interactionResult.hitTarget.kind == UIEditorPropertyGridHitTargetKind::None && + state.hasPointerPosition) { + interactionResult.hitTarget = HitTestUIEditorPropertyGrid(layout, state.pointerPosition); + } + + return { + std::move(layout), + std::move(interactionResult) + }; +} + +} // namespace XCEngine::UI::Editor diff --git a/new_editor/src/Core/UIEditorScrollViewInteraction.cpp b/new_editor/src/Core/UIEditorScrollViewInteraction.cpp new file mode 100644 index 00000000..cc91805d --- /dev/null +++ b/new_editor/src/Core/UIEditorScrollViewInteraction.cpp @@ -0,0 +1,243 @@ +#include + +#include +#include + +namespace XCEngine::UI::Editor { + +namespace { + +using ::XCEngine::UI::UIInputEvent; +using ::XCEngine::UI::UIInputEventType; +using ::XCEngine::UI::UIPointerButton; +using Widgets::BuildUIEditorScrollViewLayout; +using Widgets::ClampUIEditorScrollViewOffset; +using Widgets::HitTestUIEditorScrollView; +using Widgets::IsUIEditorScrollViewPointInside; +using Widgets::UIEditorScrollViewHitTarget; +using Widgets::UIEditorScrollViewHitTargetKind; + +bool ShouldUsePointerPosition(const UIInputEvent& event) { + switch (event.type) { + case UIInputEventType::PointerMove: + case UIInputEventType::PointerEnter: + case UIInputEventType::PointerButtonDown: + case UIInputEventType::PointerButtonUp: + case UIInputEventType::PointerWheel: + return true; + default: + return false; + } +} + +void SyncHoverState( + UIEditorScrollViewInteractionState& state, + const Widgets::UIEditorScrollViewLayout& layout) { + if (!state.hasPointerPosition) { + state.scrollViewState.hovered = false; + state.scrollViewState.scrollbarHovered = false; + return; + } + + state.scrollViewState.hovered = + IsUIEditorScrollViewPointInside(layout.bounds, state.pointerPosition); + const UIEditorScrollViewHitTarget hitTarget = + HitTestUIEditorScrollView(layout, state.pointerPosition); + state.scrollViewState.scrollbarHovered = + hitTarget.kind == UIEditorScrollViewHitTargetKind::ScrollbarTrack || + hitTarget.kind == UIEditorScrollViewHitTargetKind::ScrollbarThumb; +} + +float ResolveTrackPageStep(const Widgets::UIEditorScrollViewLayout& layout) { + return (std::max)(layout.contentRect.height - 24.0f, 24.0f); +} + +float ResolveThumbDragOffset( + const Widgets::UIEditorScrollViewLayout& layout, + const UIEditorScrollViewInteractionState& state) { + const float thumbTravel = + (std::max)(layout.scrollbarTrackRect.height - layout.scrollbarThumbRect.height, 0.0f); + if (thumbTravel <= 0.0f || layout.maxOffset <= 0.0f) { + return 0.0f; + } + + const float pointerDelta = state.pointerPosition.y - state.thumbDragStartPointerY; + const float offsetDelta = pointerDelta * (layout.maxOffset / thumbTravel); + return state.thumbDragStartOffset + offsetDelta; +} + +} // namespace + +UIEditorScrollViewInteractionFrame UpdateUIEditorScrollViewInteraction( + UIEditorScrollViewInteractionState& state, + float& verticalOffset, + const ::XCEngine::UI::UIRect& bounds, + float contentHeight, + const std::vector& inputEvents, + const Widgets::UIEditorScrollViewMetrics& metrics) { + Widgets::UIEditorScrollViewLayout layout = + BuildUIEditorScrollViewLayout(bounds, contentHeight, verticalOffset, metrics); + verticalOffset = layout.verticalOffset; + SyncHoverState(state, layout); + + UIEditorScrollViewInteractionResult 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; + } + + UIEditorScrollViewInteractionResult eventResult = {}; + switch (event.type) { + case UIInputEventType::FocusGained: { + const bool wasFocused = state.scrollViewState.focused; + state.scrollViewState.focused = true; + eventResult.focusChanged = !wasFocused; + break; + } + + case UIInputEventType::FocusLost: { + const bool wasFocused = state.scrollViewState.focused; + state.scrollViewState.focused = false; + state.scrollViewState.draggingScrollbarThumb = false; + state.scrollViewState.hovered = false; + state.scrollViewState.scrollbarHovered = false; + state.hasPointerPosition = false; + eventResult.focusChanged = wasFocused; + break; + } + + case UIInputEventType::PointerMove: + case UIInputEventType::PointerEnter: + if (state.scrollViewState.draggingScrollbarThumb) { + const float previousOffset = verticalOffset; + verticalOffset = ClampUIEditorScrollViewOffset( + bounds, + contentHeight, + ResolveThumbDragOffset(layout, state), + metrics); + eventResult.offsetChanged = verticalOffset != previousOffset; + eventResult.consumed = eventResult.offsetChanged; + } + break; + + case UIInputEventType::PointerLeave: + if (!state.scrollViewState.draggingScrollbarThumb) { + state.scrollViewState.hovered = false; + state.scrollViewState.scrollbarHovered = false; + } + break; + + case UIInputEventType::PointerWheel: + if (state.hasPointerPosition && + IsUIEditorScrollViewPointInside(layout.bounds, state.pointerPosition) && + layout.maxOffset > 0.0f) { + const float previousOffset = verticalOffset; + verticalOffset = ClampUIEditorScrollViewOffset( + bounds, + contentHeight, + verticalOffset - event.wheelDelta * metrics.wheelStep, + metrics); + eventResult.offsetChanged = verticalOffset != previousOffset; + eventResult.consumed = eventResult.offsetChanged; + } + break; + + case UIInputEventType::PointerButtonDown: { + const UIEditorScrollViewHitTarget hitTarget = + state.hasPointerPosition + ? HitTestUIEditorScrollView(layout, state.pointerPosition) + : UIEditorScrollViewHitTarget {}; + eventResult.hitTarget = hitTarget; + if (event.pointerButton != UIPointerButton::Left) { + break; + } + + if (hitTarget.kind == UIEditorScrollViewHitTargetKind::ScrollbarThumb) { + const bool wasFocused = state.scrollViewState.focused; + state.scrollViewState.focused = true; + state.scrollViewState.draggingScrollbarThumb = true; + state.thumbDragStartPointerY = state.pointerPosition.y; + state.thumbDragStartOffset = verticalOffset; + eventResult.focusChanged = !wasFocused; + eventResult.startedThumbDrag = true; + eventResult.consumed = true; + } else if (hitTarget.kind == UIEditorScrollViewHitTargetKind::ScrollbarTrack) { + const bool wasFocused = state.scrollViewState.focused; + state.scrollViewState.focused = true; + const float previousOffset = verticalOffset; + const float pageStep = ResolveTrackPageStep(layout); + verticalOffset = ClampUIEditorScrollViewOffset( + bounds, + contentHeight, + verticalOffset + + (state.pointerPosition.y >= layout.scrollbarThumbRect.y + layout.scrollbarThumbRect.height + ? pageStep + : -pageStep), + metrics); + eventResult.focusChanged = !wasFocused; + eventResult.offsetChanged = verticalOffset != previousOffset; + eventResult.consumed = true; + } else if (hitTarget.kind == UIEditorScrollViewHitTargetKind::Content) { + const bool wasFocused = state.scrollViewState.focused; + state.scrollViewState.focused = true; + eventResult.focusChanged = !wasFocused; + eventResult.consumed = true; + } else if (state.scrollViewState.focused) { + state.scrollViewState.focused = false; + state.scrollViewState.draggingScrollbarThumb = false; + eventResult.focusChanged = true; + } + break; + } + + case UIInputEventType::PointerButtonUp: + if (event.pointerButton == UIPointerButton::Left && + state.scrollViewState.draggingScrollbarThumb) { + state.scrollViewState.draggingScrollbarThumb = false; + eventResult.endedThumbDrag = true; + eventResult.consumed = true; + } + break; + + default: + break; + } + + layout = BuildUIEditorScrollViewLayout(bounds, contentHeight, verticalOffset, metrics); + verticalOffset = layout.verticalOffset; + SyncHoverState(state, layout); + eventResult.verticalOffset = verticalOffset; + if (eventResult.hitTarget.kind == UIEditorScrollViewHitTargetKind::None && + state.hasPointerPosition) { + eventResult.hitTarget = HitTestUIEditorScrollView(layout, state.pointerPosition); + } + + if (eventResult.consumed || + eventResult.offsetChanged || + eventResult.focusChanged || + eventResult.startedThumbDrag || + eventResult.endedThumbDrag || + eventResult.hitTarget.kind != UIEditorScrollViewHitTargetKind::None) { + interactionResult = std::move(eventResult); + } + } + + layout = BuildUIEditorScrollViewLayout(bounds, contentHeight, verticalOffset, metrics); + verticalOffset = layout.verticalOffset; + SyncHoverState(state, layout); + interactionResult.verticalOffset = verticalOffset; + if (interactionResult.hitTarget.kind == UIEditorScrollViewHitTargetKind::None && + state.hasPointerPosition) { + interactionResult.hitTarget = HitTestUIEditorScrollView(layout, state.pointerPosition); + } + + return { + std::move(layout), + std::move(interactionResult) + }; +} + +} // namespace XCEngine::UI::Editor diff --git a/new_editor/src/Widgets/UIEditorListView.cpp b/new_editor/src/Widgets/UIEditorListView.cpp new file mode 100644 index 00000000..d6978b33 --- /dev/null +++ b/new_editor/src/Widgets/UIEditorListView.cpp @@ -0,0 +1,196 @@ +#include + +#include + +namespace XCEngine::UI::Editor::Widgets { + +namespace { + +float ClampNonNegative(float value) { + return (std::max)(value, 0.0f); +} + +float ResolveListViewRowHeight( + const UIEditorListViewItem& item, + const UIEditorListViewMetrics& metrics) { + return item.desiredHeight > 0.0f ? item.desiredHeight : metrics.rowHeight; +} + +} // namespace + +bool IsUIEditorListViewPointInside( + 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; +} + +std::size_t FindUIEditorListViewItemIndex( + 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 UIEditorListViewInvalidIndex; +} + +UIEditorListViewLayout BuildUIEditorListViewLayout( + const ::XCEngine::UI::UIRect& bounds, + const std::vector& items, + const UIEditorListViewMetrics& metrics) { + UIEditorListViewLayout layout = {}; + layout.bounds = ::XCEngine::UI::UIRect( + bounds.x, + bounds.y, + ClampNonNegative(bounds.width), + ClampNonNegative(bounds.height)); + layout.itemIndices.reserve(items.size()); + layout.rowRects.reserve(items.size()); + layout.primaryTextRects.reserve(items.size()); + layout.secondaryTextRects.reserve(items.size()); + layout.hasSecondaryText.reserve(items.size()); + + float rowY = layout.bounds.y; + for (std::size_t itemIndex = 0u; itemIndex < items.size(); ++itemIndex) { + const UIEditorListViewItem& item = items[itemIndex]; + const bool hasSecondaryText = !item.secondaryText.empty(); + const float rowHeight = ResolveListViewRowHeight(item, metrics); + const ::XCEngine::UI::UIRect rowRect( + layout.bounds.x, + rowY, + layout.bounds.width, + rowHeight); + const float textX = rowRect.x + ClampNonNegative(metrics.horizontalPadding); + const float textWidth = + (std::max)(0.0f, rowRect.width - ClampNonNegative(metrics.horizontalPadding) * 2.0f); + const ::XCEngine::UI::UIRect primaryTextRect( + textX, + rowRect.y + (hasSecondaryText + ? ClampNonNegative(metrics.primaryTextInsetY) + : ClampNonNegative(metrics.singleLineTextInsetY)), + textWidth, + hasSecondaryText ? 14.0f : 18.0f); + const ::XCEngine::UI::UIRect secondaryTextRect( + textX, + rowRect.y + ClampNonNegative(metrics.secondaryTextInsetY), + textWidth, + hasSecondaryText ? 12.0f : 0.0f); + + layout.itemIndices.push_back(itemIndex); + layout.rowRects.push_back(rowRect); + layout.primaryTextRects.push_back(primaryTextRect); + layout.secondaryTextRects.push_back(secondaryTextRect); + layout.hasSecondaryText.push_back(hasSecondaryText); + rowY += rowHeight + ClampNonNegative(metrics.rowGap); + } + + return layout; +} + +UIEditorListViewHitTarget HitTestUIEditorListView( + const UIEditorListViewLayout& layout, + const ::XCEngine::UI::UIPoint& point) { + for (std::size_t visibleIndex = 0u; visibleIndex < layout.rowRects.size(); ++visibleIndex) { + if (!IsUIEditorListViewPointInside(layout.rowRects[visibleIndex], point)) { + continue; + } + + UIEditorListViewHitTarget target = {}; + target.kind = UIEditorListViewHitTargetKind::Row; + target.visibleIndex = visibleIndex; + target.itemIndex = layout.itemIndices[visibleIndex]; + return target; + } + + return {}; +} + +void AppendUIEditorListViewBackground( + ::XCEngine::UI::UIDrawList& drawList, + const UIEditorListViewLayout& layout, + const std::vector& items, + const ::XCEngine::UI::Widgets::UISelectionModel& selectionModel, + const UIEditorListViewState& state, + const UIEditorListViewPalette& palette, + const UIEditorListViewMetrics& 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 visibleIndex = 0u; visibleIndex < layout.rowRects.size(); ++visibleIndex) { + const UIEditorListViewItem& item = items[layout.itemIndices[visibleIndex]]; + 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[visibleIndex], rowColor, metrics.cornerRounding); + } +} + +void AppendUIEditorListViewForeground( + ::XCEngine::UI::UIDrawList& drawList, + const UIEditorListViewLayout& layout, + const std::vector& items, + const UIEditorListViewPalette& palette, + const UIEditorListViewMetrics&) { + drawList.PushClipRect(layout.bounds); + for (std::size_t visibleIndex = 0u; visibleIndex < layout.rowRects.size(); ++visibleIndex) { + const UIEditorListViewItem& item = items[layout.itemIndices[visibleIndex]]; + drawList.PushClipRect(layout.rowRects[visibleIndex]); + drawList.AddText( + ::XCEngine::UI::UIPoint( + layout.primaryTextRects[visibleIndex].x, + layout.primaryTextRects[visibleIndex].y), + item.primaryText, + palette.primaryTextColor, + 12.0f); + if (layout.hasSecondaryText[visibleIndex]) { + drawList.AddText( + ::XCEngine::UI::UIPoint( + layout.secondaryTextRects[visibleIndex].x, + layout.secondaryTextRects[visibleIndex].y), + item.secondaryText, + palette.secondaryTextColor, + 11.0f); + } + drawList.PopClipRect(); + } + drawList.PopClipRect(); +} + +void AppendUIEditorListView( + ::XCEngine::UI::UIDrawList& drawList, + const ::XCEngine::UI::UIRect& bounds, + const std::vector& items, + const ::XCEngine::UI::Widgets::UISelectionModel& selectionModel, + const UIEditorListViewState& state, + const UIEditorListViewPalette& palette, + const UIEditorListViewMetrics& metrics) { + const UIEditorListViewLayout layout = + BuildUIEditorListViewLayout(bounds, items, metrics); + AppendUIEditorListViewBackground( + drawList, + layout, + items, + selectionModel, + state, + palette, + metrics); + AppendUIEditorListViewForeground(drawList, layout, items, palette, metrics); +} + +} // namespace XCEngine::UI::Editor::Widgets diff --git a/new_editor/src/Widgets/UIEditorPropertyGrid.cpp b/new_editor/src/Widgets/UIEditorPropertyGrid.cpp new file mode 100644 index 00000000..10b84d4b --- /dev/null +++ b/new_editor/src/Widgets/UIEditorPropertyGrid.cpp @@ -0,0 +1,416 @@ +#include + +#include + +namespace XCEngine::UI::Editor::Widgets { + +namespace { + +float ClampNonNegative(float value) { + return (std::max)(value, 0.0f); +} + +float ResolveSectionHeaderHeight( + const UIEditorPropertyGridSection& section, + const UIEditorPropertyGridMetrics& metrics) { + return section.desiredHeaderHeight > 0.0f + ? section.desiredHeaderHeight + : metrics.sectionHeaderHeight; +} + +float ResolveFieldRowHeight( + const UIEditorPropertyGridField& field, + const UIEditorPropertyGridMetrics& metrics) { + return field.desiredHeight > 0.0f + ? field.desiredHeight + : metrics.fieldRowHeight; +} + +::XCEngine::UI::UIPoint ResolveDisclosureGlyphPosition( + const ::XCEngine::UI::UIRect& rect, + float textInsetY) { + return ::XCEngine::UI::UIPoint(rect.x + 2.0f, rect.y + textInsetY - 1.0f); +} + +const std::string& ResolveDisplayedFieldValue( + const UIEditorPropertyGridField& field, + const ::XCEngine::UI::Widgets::UIPropertyEditModel& propertyEditModel) { + if (propertyEditModel.HasActiveEdit() && + propertyEditModel.GetActiveFieldId() == field.fieldId) { + return propertyEditModel.GetStagedValue(); + } + + return field.valueText; +} + +} // namespace + +bool IsUIEditorPropertyGridPointInside( + 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; +} + +std::size_t FindUIEditorPropertyGridSectionIndex( + const std::vector& sections, + std::string_view sectionId) { + for (std::size_t sectionIndex = 0u; sectionIndex < sections.size(); ++sectionIndex) { + if (sections[sectionIndex].sectionId == sectionId) { + return sectionIndex; + } + } + + return UIEditorPropertyGridInvalidIndex; +} + +UIEditorPropertyGridFieldLocation FindUIEditorPropertyGridFieldLocation( + const std::vector& sections, + std::string_view fieldId) { + for (std::size_t sectionIndex = 0u; sectionIndex < sections.size(); ++sectionIndex) { + const auto& fields = sections[sectionIndex].fields; + for (std::size_t fieldIndex = 0u; fieldIndex < fields.size(); ++fieldIndex) { + if (fields[fieldIndex].fieldId == fieldId) { + return { sectionIndex, fieldIndex }; + } + } + } + + return {}; +} + +std::size_t FindUIEditorPropertyGridVisibleFieldIndex( + const UIEditorPropertyGridLayout& layout, + std::string_view fieldId, + const std::vector& sections) { + for (std::size_t visibleIndex = 0u; + visibleIndex < layout.visibleFieldIndices.size(); + ++visibleIndex) { + const std::size_t sectionIndex = layout.visibleFieldSectionIndices[visibleIndex]; + const std::size_t fieldIndex = layout.visibleFieldIndices[visibleIndex]; + if (sectionIndex >= sections.size() || + fieldIndex >= sections[sectionIndex].fields.size()) { + continue; + } + + if (sections[sectionIndex].fields[fieldIndex].fieldId == fieldId) { + return visibleIndex; + } + } + + return UIEditorPropertyGridInvalidIndex; +} + +UIEditorPropertyGridLayout BuildUIEditorPropertyGridLayout( + const ::XCEngine::UI::UIRect& bounds, + const std::vector& sections, + const ::XCEngine::UI::Widgets::UIExpansionModel& expansionModel, + const UIEditorPropertyGridMetrics& metrics) { + UIEditorPropertyGridLayout layout = {}; + layout.bounds = ::XCEngine::UI::UIRect( + bounds.x, + bounds.y, + ClampNonNegative(bounds.width), + ClampNonNegative(bounds.height)); + + const float inset = ClampNonNegative(metrics.contentInset); + const float contentX = layout.bounds.x + inset; + const float contentY = layout.bounds.y + inset; + const float contentWidth = (std::max)(0.0f, layout.bounds.width - inset * 2.0f); + + float cursorY = contentY; + for (std::size_t sectionIndex = 0u; sectionIndex < sections.size(); ++sectionIndex) { + const UIEditorPropertyGridSection& section = sections[sectionIndex]; + const float headerHeight = ResolveSectionHeaderHeight(section, metrics); + const bool expanded = expansionModel.IsExpanded(section.sectionId); + + const ::XCEngine::UI::UIRect headerRect( + contentX, + cursorY, + contentWidth, + headerHeight); + const ::XCEngine::UI::UIRect disclosureRect( + headerRect.x + metrics.horizontalPadding, + headerRect.y + (headerRect.height - metrics.disclosureExtent) * 0.5f, + metrics.disclosureExtent, + metrics.disclosureExtent); + const float titleX = + disclosureRect.x + disclosureRect.width + metrics.disclosureLabelGap; + const ::XCEngine::UI::UIRect titleRect( + titleX, + headerRect.y, + (std::max)(0.0f, headerRect.x + headerRect.width - titleX - metrics.horizontalPadding), + headerRect.height); + + layout.sectionIndices.push_back(sectionIndex); + layout.sectionHeaderRects.push_back(headerRect); + layout.sectionDisclosureRects.push_back(disclosureRect); + layout.sectionTitleRects.push_back(titleRect); + layout.sectionExpanded.push_back(expanded); + + cursorY += headerHeight; + if (expanded) { + for (std::size_t fieldIndex = 0u; fieldIndex < section.fields.size(); ++fieldIndex) { + const UIEditorPropertyGridField& field = section.fields[fieldIndex]; + const float rowHeight = ResolveFieldRowHeight(field, metrics); + const ::XCEngine::UI::UIRect rowRect( + contentX, + cursorY, + contentWidth, + rowHeight); + const float controlX = + (std::min)( + rowRect.x + rowRect.width - metrics.horizontalPadding, + rowRect.x + metrics.controlColumnStart); + const float labelWidth = + (std::max)( + 0.0f, + controlX - rowRect.x - metrics.horizontalPadding - metrics.labelControlGap); + const ::XCEngine::UI::UIRect labelRect( + rowRect.x + metrics.horizontalPadding, + rowRect.y, + labelWidth, + rowRect.height); + const ::XCEngine::UI::UIRect valueRect( + controlX, + rowRect.y + metrics.valueBoxInsetY, + (std::max)( + 0.0f, + rowRect.x + rowRect.width - controlX - metrics.horizontalPadding), + (std::max)(0.0f, rowRect.height - metrics.valueBoxInsetY * 2.0f)); + + layout.visibleFieldSectionIndices.push_back(sectionIndex); + layout.visibleFieldIndices.push_back(fieldIndex); + layout.fieldRowRects.push_back(rowRect); + layout.fieldLabelRects.push_back(labelRect); + layout.fieldValueRects.push_back(valueRect); + layout.fieldReadOnly.push_back(field.readOnly); + + cursorY += rowHeight + metrics.rowGap; + } + + if (!section.fields.empty()) { + cursorY -= metrics.rowGap; + } + } + + cursorY += metrics.sectionGap; + } + + return layout; +} + +UIEditorPropertyGridHitTarget HitTestUIEditorPropertyGrid( + const UIEditorPropertyGridLayout& layout, + const ::XCEngine::UI::UIPoint& point) { + for (std::size_t sectionVisibleIndex = 0u; + sectionVisibleIndex < layout.sectionHeaderRects.size(); + ++sectionVisibleIndex) { + if (!IsUIEditorPropertyGridPointInside(layout.sectionHeaderRects[sectionVisibleIndex], point)) { + continue; + } + + UIEditorPropertyGridHitTarget target = {}; + target.kind = UIEditorPropertyGridHitTargetKind::SectionHeader; + target.sectionIndex = layout.sectionIndices[sectionVisibleIndex]; + return target; + } + + for (std::size_t visibleFieldIndex = 0u; + visibleFieldIndex < layout.fieldRowRects.size(); + ++visibleFieldIndex) { + if (!IsUIEditorPropertyGridPointInside(layout.fieldRowRects[visibleFieldIndex], point)) { + continue; + } + + UIEditorPropertyGridHitTarget target = {}; + target.visibleFieldIndex = visibleFieldIndex; + target.sectionIndex = layout.visibleFieldSectionIndices[visibleFieldIndex]; + target.fieldIndex = layout.visibleFieldIndices[visibleFieldIndex]; + target.kind = IsUIEditorPropertyGridPointInside(layout.fieldValueRects[visibleFieldIndex], point) + ? UIEditorPropertyGridHitTargetKind::ValueBox + : UIEditorPropertyGridHitTargetKind::FieldRow; + return target; + } + + return {}; +} + +void AppendUIEditorPropertyGridBackground( + ::XCEngine::UI::UIDrawList& drawList, + const UIEditorPropertyGridLayout& layout, + const std::vector& sections, + const ::XCEngine::UI::Widgets::UISelectionModel& selectionModel, + const ::XCEngine::UI::Widgets::UIPropertyEditModel& propertyEditModel, + const UIEditorPropertyGridState& state, + const UIEditorPropertyGridPalette& palette, + const UIEditorPropertyGridMetrics& 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 sectionVisibleIndex = 0u; + sectionVisibleIndex < layout.sectionHeaderRects.size(); + ++sectionVisibleIndex) { + const UIEditorPropertyGridSection& section = + sections[layout.sectionIndices[sectionVisibleIndex]]; + const bool hovered = + state.hoveredFieldId.empty() && + state.hoveredSectionId == section.sectionId; + drawList.AddFilledRect( + layout.sectionHeaderRects[sectionVisibleIndex], + hovered ? palette.sectionHeaderHoverColor : palette.sectionHeaderColor, + metrics.cornerRounding); + } + + for (std::size_t visibleFieldIndex = 0u; + visibleFieldIndex < layout.fieldRowRects.size(); + ++visibleFieldIndex) { + const UIEditorPropertyGridSection& section = + sections[layout.visibleFieldSectionIndices[visibleFieldIndex]]; + const UIEditorPropertyGridField& field = + section.fields[layout.visibleFieldIndices[visibleFieldIndex]]; + const bool selected = selectionModel.IsSelected(field.fieldId); + const bool hovered = state.hoveredFieldId == field.fieldId; + const bool editing = propertyEditModel.HasActiveEdit() && + propertyEditModel.GetActiveFieldId() == field.fieldId; + + if (selected || hovered) { + drawList.AddFilledRect( + layout.fieldRowRects[visibleFieldIndex], + selected + ? (state.focused ? palette.fieldSelectedFocusedColor : palette.fieldSelectedColor) + : palette.fieldHoverColor, + metrics.cornerRounding); + } + + const ::XCEngine::UI::UIColor valueBoxColor = + field.readOnly + ? palette.valueBoxReadOnlyColor + : (editing + ? palette.valueBoxEditingColor + : (hovered ? palette.valueBoxHoverColor : palette.valueBoxColor)); + drawList.AddFilledRect( + layout.fieldValueRects[visibleFieldIndex], + valueBoxColor, + metrics.valueBoxRounding); + drawList.AddRectOutline( + layout.fieldValueRects[visibleFieldIndex], + editing ? palette.valueBoxEditingBorderColor : palette.valueBoxBorderColor, + editing ? metrics.editOutlineThickness : metrics.borderThickness, + metrics.valueBoxRounding); + } +} + +void AppendUIEditorPropertyGridForeground( + ::XCEngine::UI::UIDrawList& drawList, + const UIEditorPropertyGridLayout& layout, + const std::vector& sections, + const ::XCEngine::UI::Widgets::UIPropertyEditModel& propertyEditModel, + const UIEditorPropertyGridPalette& palette, + const UIEditorPropertyGridMetrics& metrics) { + drawList.PushClipRect(layout.bounds); + + for (std::size_t sectionVisibleIndex = 0u; + sectionVisibleIndex < layout.sectionHeaderRects.size(); + ++sectionVisibleIndex) { + const UIEditorPropertyGridSection& section = + sections[layout.sectionIndices[sectionVisibleIndex]]; + drawList.AddText( + ResolveDisclosureGlyphPosition( + layout.sectionDisclosureRects[sectionVisibleIndex], + metrics.sectionTextInsetY), + layout.sectionExpanded[sectionVisibleIndex] ? "v" : ">", + palette.disclosureColor, + 12.0f); + drawList.AddText( + ::XCEngine::UI::UIPoint( + layout.sectionTitleRects[sectionVisibleIndex].x, + layout.sectionTitleRects[sectionVisibleIndex].y + metrics.sectionTextInsetY), + section.title, + palette.sectionTextColor, + 12.0f); + } + + for (std::size_t visibleFieldIndex = 0u; + visibleFieldIndex < layout.fieldRowRects.size(); + ++visibleFieldIndex) { + const UIEditorPropertyGridSection& section = + sections[layout.visibleFieldSectionIndices[visibleFieldIndex]]; + const UIEditorPropertyGridField& field = + section.fields[layout.visibleFieldIndices[visibleFieldIndex]]; + const bool editing = propertyEditModel.HasActiveEdit() && + propertyEditModel.GetActiveFieldId() == field.fieldId; + const std::string& displayedValue = + ResolveDisplayedFieldValue(field, propertyEditModel); + + drawList.PushClipRect(layout.fieldLabelRects[visibleFieldIndex]); + drawList.AddText( + ::XCEngine::UI::UIPoint( + layout.fieldLabelRects[visibleFieldIndex].x, + layout.fieldLabelRects[visibleFieldIndex].y + metrics.labelTextInsetY), + field.label, + palette.labelTextColor, + 12.0f); + drawList.PopClipRect(); + + drawList.PushClipRect(layout.fieldValueRects[visibleFieldIndex]); + drawList.AddText( + ::XCEngine::UI::UIPoint( + layout.fieldValueRects[visibleFieldIndex].x + metrics.valueBoxInsetX, + layout.fieldValueRects[visibleFieldIndex].y + metrics.valueTextInsetY), + displayedValue, + field.readOnly ? palette.readOnlyValueTextColor : palette.valueTextColor, + 12.0f); + if (editing) { + drawList.AddText( + ::XCEngine::UI::UIPoint( + layout.fieldValueRects[visibleFieldIndex].x + + (std::max)(0.0f, layout.fieldValueRects[visibleFieldIndex].width - 34.0f), + layout.fieldValueRects[visibleFieldIndex].y + metrics.valueTextInsetY), + "EDIT", + palette.editTagColor, + 11.0f); + } + drawList.PopClipRect(); + } + + drawList.PopClipRect(); +} + +void AppendUIEditorPropertyGrid( + ::XCEngine::UI::UIDrawList& drawList, + const ::XCEngine::UI::UIRect& bounds, + const std::vector& sections, + const ::XCEngine::UI::Widgets::UISelectionModel& selectionModel, + const ::XCEngine::UI::Widgets::UIExpansionModel& expansionModel, + const ::XCEngine::UI::Widgets::UIPropertyEditModel& propertyEditModel, + const UIEditorPropertyGridState& state, + const UIEditorPropertyGridPalette& palette, + const UIEditorPropertyGridMetrics& metrics) { + const UIEditorPropertyGridLayout layout = + BuildUIEditorPropertyGridLayout(bounds, sections, expansionModel, metrics); + AppendUIEditorPropertyGridBackground( + drawList, + layout, + sections, + selectionModel, + propertyEditModel, + state, + palette, + metrics); + AppendUIEditorPropertyGridForeground( + drawList, + layout, + sections, + propertyEditModel, + palette, + metrics); +} + +} // namespace XCEngine::UI::Editor::Widgets diff --git a/new_editor/src/Widgets/UIEditorScrollView.cpp b/new_editor/src/Widgets/UIEditorScrollView.cpp new file mode 100644 index 00000000..22740838 --- /dev/null +++ b/new_editor/src/Widgets/UIEditorScrollView.cpp @@ -0,0 +1,149 @@ +#include + +#include + +namespace XCEngine::UI::Editor::Widgets { + +namespace { + +float ClampNonNegative(float value) { + return (std::max)(value, 0.0f); +} + +float ClampRange(float value, float minValue, float maxValue) { + return (std::min)((std::max)(value, minValue), maxValue); +} + +} // namespace + +bool IsUIEditorScrollViewPointInside( + 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; +} + +float ClampUIEditorScrollViewOffset( + const ::XCEngine::UI::UIRect& bounds, + float contentHeight, + float verticalOffset, + const UIEditorScrollViewMetrics&) { + const float maxOffset = (std::max)(ClampNonNegative(contentHeight) - ClampNonNegative(bounds.height), 0.0f); + return ClampRange(verticalOffset, 0.0f, maxOffset); +} + +UIEditorScrollViewLayout BuildUIEditorScrollViewLayout( + const ::XCEngine::UI::UIRect& bounds, + float contentHeight, + float verticalOffset, + const UIEditorScrollViewMetrics& metrics) { + UIEditorScrollViewLayout layout = {}; + layout.bounds = ::XCEngine::UI::UIRect( + bounds.x, + bounds.y, + ClampNonNegative(bounds.width), + ClampNonNegative(bounds.height)); + layout.contentHeight = ClampNonNegative(contentHeight); + layout.maxOffset = (std::max)(layout.contentHeight - layout.bounds.height, 0.0f); + layout.verticalOffset = ClampRange(verticalOffset, 0.0f, layout.maxOffset); + layout.hasScrollbar = + layout.maxOffset > 0.0f && + layout.bounds.width > metrics.scrollbarWidth + metrics.scrollbarInset * 2.0f; + + if (!layout.hasScrollbar) { + layout.contentRect = layout.bounds; + return layout; + } + + const float trackHeight = (std::max)(layout.bounds.height - metrics.scrollbarInset * 2.0f, 0.0f); + layout.scrollbarTrackRect = ::XCEngine::UI::UIRect( + layout.bounds.x + layout.bounds.width - metrics.scrollbarInset - metrics.scrollbarWidth, + layout.bounds.y + metrics.scrollbarInset, + metrics.scrollbarWidth, + trackHeight); + layout.contentRect = ::XCEngine::UI::UIRect( + layout.bounds.x, + layout.bounds.y, + (std::max)(layout.scrollbarTrackRect.x - layout.bounds.x - metrics.scrollbarInset, 0.0f), + layout.bounds.height); + + const float visibleRatio = layout.contentHeight > 0.0f + ? ClampRange(layout.bounds.height / layout.contentHeight, 0.0f, 1.0f) + : 1.0f; + const float thumbHeight = + ClampRange( + layout.scrollbarTrackRect.height * visibleRatio, + metrics.minThumbHeight, + layout.scrollbarTrackRect.height); + const float thumbTravel = (std::max)(layout.scrollbarTrackRect.height - thumbHeight, 0.0f); + const float thumbOffset = + layout.maxOffset > 0.0f + ? thumbTravel * (layout.verticalOffset / layout.maxOffset) + : 0.0f; + layout.scrollbarThumbRect = ::XCEngine::UI::UIRect( + layout.scrollbarTrackRect.x, + layout.scrollbarTrackRect.y + thumbOffset, + layout.scrollbarTrackRect.width, + thumbHeight); + return layout; +} + +::XCEngine::UI::UIPoint ResolveUIEditorScrollViewContentOrigin( + const UIEditorScrollViewLayout& layout) { + return ::XCEngine::UI::UIPoint(layout.contentRect.x, layout.contentRect.y - layout.verticalOffset); +} + +UIEditorScrollViewHitTarget HitTestUIEditorScrollView( + const UIEditorScrollViewLayout& layout, + const ::XCEngine::UI::UIPoint& point) { + if (layout.hasScrollbar && + IsUIEditorScrollViewPointInside(layout.scrollbarThumbRect, point)) { + return { UIEditorScrollViewHitTargetKind::ScrollbarThumb }; + } + + if (layout.hasScrollbar && + IsUIEditorScrollViewPointInside(layout.scrollbarTrackRect, point)) { + return { UIEditorScrollViewHitTargetKind::ScrollbarTrack }; + } + + if (IsUIEditorScrollViewPointInside(layout.bounds, point)) { + return { UIEditorScrollViewHitTargetKind::Content }; + } + + return {}; +} + +void AppendUIEditorScrollViewBackground( + ::XCEngine::UI::UIDrawList& drawList, + const UIEditorScrollViewLayout& layout, + const UIEditorScrollViewState& state, + const UIEditorScrollViewPalette& palette, + const UIEditorScrollViewMetrics& 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); + + if (!layout.hasScrollbar) { + return; + } + + drawList.AddFilledRect( + layout.scrollbarTrackRect, + palette.scrollbarTrackColor, + metrics.scrollbarWidth * 0.5f); + const ::XCEngine::UI::UIColor thumbColor = + state.draggingScrollbarThumb + ? palette.scrollbarThumbActiveColor + : (state.scrollbarHovered ? palette.scrollbarThumbHoverColor : palette.scrollbarThumbColor); + drawList.AddFilledRect( + layout.scrollbarThumbRect, + thumbColor, + metrics.scrollbarWidth * 0.5f); +} + +} // namespace XCEngine::UI::Editor::Widgets diff --git a/tests/UI/Editor/integration/CMakeLists.txt b/tests/UI/Editor/integration/CMakeLists.txt index 5df9f3fd..7f192dae 100644 --- a/tests/UI/Editor/integration/CMakeLists.txt +++ b/tests/UI/Editor/integration/CMakeLists.txt @@ -53,6 +53,16 @@ if(TARGET editor_ui_tree_view_basic_validation) editor_ui_tree_view_basic_validation) endif() +if(TARGET editor_ui_property_grid_basic_validation) + list(APPEND EDITOR_UI_INTEGRATION_TARGETS + editor_ui_property_grid_basic_validation) +endif() + +if(TARGET editor_ui_list_view_basic_validation) + list(APPEND EDITOR_UI_INTEGRATION_TARGETS + editor_ui_list_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 817114d6..2ccc07bf 100644 --- a/tests/UI/Editor/integration/README.md +++ b/tests/UI/Editor/integration/README.md @@ -22,8 +22,11 @@ Layout: - `shell/menu_bar_basic/`: menu bar open/close/hover/dispatch only - `shell/context_menu_basic/`: context menu root/submenu/dismiss/dispatch only - `shell/panel_frame_basic/`: panel frame layout/state/hit-test only +- `shell/scroll_view_basic/`: ScrollView viewport, clip, thumb drag, wheel offset only +- `shell/property_grid_basic/`: PropertyGrid section toggle, field selection, value edit, keyboard navigation 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/list_view_basic/`: ListView row layout, selection, focus, keyboard navigation, 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 @@ -77,6 +80,16 @@ Scenarios: Executable: `XCUIEditorPanelFrameBasicValidation.exe` Scope: panel frame header/body/footer layout, focus/active/hover chrome, pin/close hit target only +- `editor.shell.scroll_view_basic` + Build target: `editor_ui_scroll_view_basic_validation` + Executable: `XCUIEditorScrollViewBasicValidation.exe` + Scope: ScrollView viewport clip, wheel scrolling, thumb drag, focus, and hit-test only + +- `editor.shell.property_grid_basic` + Build target: `editor_ui_property_grid_basic_validation` + Executable: `XCUIEditorPropertyGridBasicValidation.exe` + Scope: PropertyGrid 基础控件验证;只检查 section toggle、field selection、value edit、Enter/Esc、keyboard navigation,不涉及业务 Inspector + - `editor.shell.status_bar_basic` Build target: `editor_ui_status_bar_basic_validation` Executable: `XCUIEditorStatusBarBasicValidation.exe` @@ -87,6 +100,11 @@ Scenarios: Executable: `XCUIEditorTreeViewBasicValidation.exe` Scope: TreeView 基础控件验证;只检查行缩进、disclosure 展开/折叠、selection、hover/focus 和 hit-test,不涉及业务面板 +- `editor.shell.list_view_basic` + Build target: `editor_ui_list_view_basic_validation` + Executable: `XCUIEditorListViewBasicValidation.exe` + Scope: ListView 基础控件验证;只检查 row 排列、selection、hover/focus、Up/Down/Home/End 键盘导航和 hit-test,不涉及业务面板 + - `editor.shell.tab_strip_basic` Build target: `editor_ui_tab_strip_basic_validation` Executable: `XCUIEditorTabStripBasicValidation.exe` @@ -174,12 +192,17 @@ Selected controls: - `shell/panel_frame_basic/` Move the mouse over the preview panel, click `Body / Pin / Close`, toggle `Active / Focus / Closable / Footer`, press `F12`. +- `shell/scroll_view_basic/` + 把鼠标移到右侧日志区内滚轮滚动,拖拽 scrollbar thumb,检查 `Hover / Focused / Thumb Dragging / Offset / Has Scrollbar / Result`,按 `重置`、`截图(F12)` 或直接按 `F12`。 + - `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/list_view_basic/` + 先看顶部中文说明“这个测试在验证什么功能”,再点击列表行,并在列表获得 focus 后按 `Up / Down / Home / End`,检查 `Hover / Focused / Selected / Current / 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 12478d31..39f9a7f1 100644 --- a/tests/UI/Editor/integration/shell/CMakeLists.txt +++ b/tests/UI/Editor/integration/shell/CMakeLists.txt @@ -16,9 +16,18 @@ 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}/property_grid_basic/CMakeLists.txt") + add_subdirectory(property_grid_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}/list_view_basic/CMakeLists.txt") + add_subdirectory(list_view_basic) +endif() +if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/scroll_view_basic/CMakeLists.txt") + add_subdirectory(scroll_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/list_view_basic/CMakeLists.txt b/tests/UI/Editor/integration/shell/list_view_basic/CMakeLists.txt new file mode 100644 index 00000000..f462c15e --- /dev/null +++ b/tests/UI/Editor/integration/shell/list_view_basic/CMakeLists.txt @@ -0,0 +1,30 @@ +add_executable(editor_ui_list_view_basic_validation WIN32 + main.cpp +) + +target_include_directories(editor_ui_list_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_list_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_list_view_basic_validation PRIVATE /utf-8 /FS) + set_property(TARGET editor_ui_list_view_basic_validation PROPERTY + MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>DLL") +endif() + +target_link_libraries(editor_ui_list_view_basic_validation PRIVATE + XCUIEditorLib + XCUIEditorHost +) + +set_target_properties(editor_ui_list_view_basic_validation PROPERTIES + OUTPUT_NAME "XCUIEditorListViewBasicValidation" +) diff --git a/tests/UI/Editor/integration/shell/list_view_basic/captures/.gitkeep b/tests/UI/Editor/integration/shell/list_view_basic/captures/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/tests/UI/Editor/integration/shell/list_view_basic/captures/.gitkeep @@ -0,0 +1 @@ + diff --git a/tests/UI/Editor/integration/shell/list_view_basic/main.cpp b/tests/UI/Editor/integration/shell/list_view_basic/main.cpp new file mode 100644 index 00000000..e1060504 --- /dev/null +++ b/tests/UI/Editor/integration/shell/list_view_basic/main.cpp @@ -0,0 +1,767 @@ +#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::Input::KeyCode; +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::UISelectionModel; +using XCEngine::UI::Editor::Host::AutoScreenshotController; +using XCEngine::UI::Editor::Host::NativeRenderer; +using XCEngine::UI::Editor::UIEditorListViewInteractionFrame; +using XCEngine::UI::Editor::UIEditorListViewInteractionResult; +using XCEngine::UI::Editor::UIEditorListViewInteractionState; +using XCEngine::UI::Editor::UpdateUIEditorListViewInteraction; +using XCEngine::UI::Editor::Widgets::AppendUIEditorListViewBackground; +using XCEngine::UI::Editor::Widgets::AppendUIEditorListViewForeground; +using XCEngine::UI::Editor::Widgets::HitTestUIEditorListView; +using XCEngine::UI::Editor::Widgets::UIEditorListViewHitTarget; +using XCEngine::UI::Editor::Widgets::UIEditorListViewHitTargetKind; +using XCEngine::UI::Editor::Widgets::UIEditorListViewItem; + +constexpr const wchar_t* kWindowClassName = L"XCUIEditorListViewBasicValidation"; +constexpr const wchar_t* kWindowTitle = L"XCUI Editor | ListView 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 listRect = {}; + 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; +} + +std::int32_t MapListNavigationKey(UINT keyCode) { + switch (keyCode) { + case VK_UP: + return static_cast(KeyCode::Up); + case VK_DOWN: + return static_cast(KeyCode::Down); + case VK_HOME: + return static_cast(KeyCode::Home); + case VK_END: + return static_cast(KeyCode::End); + default: + return static_cast(KeyCode::None); + } +} + +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.listRect = 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 BuildListItems() { + return { + { "scene", "Scene.unity", "Scene | 最近修改: 1 分钟前", 0.0f }, + { "material", "Metal.mat", "Material | 4 个属性", 0.0f }, + { "script", "PlayerController.cs", "C# Script | 1.8 KB", 0.0f }, + { "texture", "Checker.png", "Texture2D | 1024x1024", 0.0f }, + { "prefab", "Robot.prefab", "Prefab | 7 个组件", 0.0f } + }; +} + +std::string JoinItems(const std::vector& items) { + std::ostringstream stream = {}; + for (std::size_t index = 0u; index < items.size(); ++index) { + if (index > 0u) { + stream << " | "; + } + stream << items[index].primaryText; + } + return stream.str(); +} + +std::string DescribeHitTarget( + const UIEditorListViewHitTarget& hitTarget, + const std::vector& items) { + if (hitTarget.itemIndex >= items.size()) { + return "无"; + } + + return "row: " + items[hitTarget.itemIndex].primaryText; +} + +UIInputEvent MakePointerEvent( + UIInputEventType type, + const UIPoint& position, + UIPointerButton button = UIPointerButton::None) { + UIInputEvent event = {}; + event.type = type; + event.position = position; + event.pointerButton = button; + return event; +} + +UIInputEvent MakeKeyEvent(std::int32_t keyCode) { + UIInputEvent event = {}; + event.type = UIInputEventType::KeyDown; + event.keyCode = keyCode; + 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_RBUTTONDOWN: + if (app != nullptr) { + app->HandleRightButtonDown( + static_cast(GET_X_LPARAM(lParam)), + static_cast(GET_Y_LPARAM(lParam))); + return 0; + } + break; + + case WM_RBUTTONUP: + if (app != nullptr) { + app->HandleRightButtonUp( + static_cast(GET_X_LPARAM(lParam)), + static_cast(GET_Y_LPARAM(lParam))); + return 0; + } + break; + + case WM_KEYDOWN: + case WM_SYSKEYDOWN: + if (app != nullptr) { + if (wParam == VK_F12) { + app->m_autoScreenshot.RequestCapture("manual_f12"); + app->m_lastResult = "已请求截图,输出到 captures/latest.png"; + InvalidateRect(hwnd, nullptr, FALSE); + return 0; + } + + const std::int32_t keyCode = MapListNavigationKey(static_cast(wParam)); + if (keyCode != static_cast(KeyCode::None)) { + app->HandleNavigationKey(keyCode); + 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/list_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 = BuildListItems(); + m_selectionModel = {}; + m_selectionModel.SetSelection("material"); + m_interactionState = {}; + m_interactionState.listViewState.focused = true; + m_mousePosition = UIPoint(-1000.0f, -1000.0f); + m_hoveredAction = ActionId::Reset; + m_hasHoveredAction = false; + m_lastResult = "已重置到默认列表状态"; + RefreshListFrame(); + } + + void RefreshListFrame() { + if (m_hwnd == nullptr) { + return; + } + + const ScenarioLayout layout = GetLayout(); + m_listFrame = + UpdateUIEditorListViewInteraction( + m_interactionState, + m_selectionModel, + layout.listRect, + m_items, + {}); + } + + void OnResize(UINT width, UINT height) { + if (width == 0u || height == 0u) { + return; + } + + m_renderer.Resize(width, height); + RefreshListFrame(); + } + + 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); + + PumpListEvents({ MakePointerEvent(UIInputEventType::PointerMove, m_mousePosition) }); + InvalidateRect(m_hwnd, nullptr, FALSE); + } + + void HandleMouseLeave() { + m_mousePosition = UIPoint(-1000.0f, -1000.0f); + m_hasHoveredAction = false; + PumpListEvents({ 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; + } + + PumpListEvents({ 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.listViewState.focused; + const bool insideList = ContainsPoint(layout.listRect, x, y); + const UIEditorListViewInteractionResult result = + PumpListEvents({ MakePointerEvent(UIInputEventType::PointerButtonUp, m_mousePosition, UIPointerButton::Left) }); + UpdateResultText(result, wasFocused, insideList); + InvalidateRect(m_hwnd, nullptr, FALSE); + } + + void HandleRightButtonDown(float x, float y) { + m_mousePosition = UIPoint(x, y); + PumpListEvents({ MakePointerEvent(UIInputEventType::PointerButtonDown, m_mousePosition, UIPointerButton::Right) }); + InvalidateRect(m_hwnd, nullptr, FALSE); + } + + void HandleRightButtonUp(float x, float y) { + m_mousePosition = UIPoint(x, y); + const UIEditorListViewInteractionResult result = + PumpListEvents({ MakePointerEvent(UIInputEventType::PointerButtonUp, m_mousePosition, UIPointerButton::Right) }); + UpdateResultText(result, m_interactionState.listViewState.focused, true); + InvalidateRect(m_hwnd, nullptr, FALSE); + } + + void HandleNavigationKey(std::int32_t keyCode) { + const UIEditorListViewInteractionResult result = + PumpListEvents({ MakeKeyEvent(keyCode) }); + UpdateResultText(result, m_interactionState.listViewState.focused, true); + 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; + } + + UIEditorListViewInteractionResult PumpListEvents(std::vector events) { + const ScenarioLayout layout = GetLayout(); + m_listFrame = + UpdateUIEditorListViewInteraction( + m_interactionState, + m_selectionModel, + layout.listRect, + m_items, + events); + return m_listFrame.result; + } + + void UpdateResultText( + const UIEditorListViewInteractionResult& result, + bool wasFocused, + bool insideList) { + if (result.keyboardNavigated && !result.selectedItemId.empty()) { + m_lastResult = "键盘导航选择: " + result.selectedItemId; + return; + } + + if (result.secondaryClicked && !result.selectedItemId.empty()) { + m_lastResult = "右键命中行: " + result.selectedItemId; + return; + } + + if (result.selectionChanged && !result.selectedItemId.empty()) { + m_lastResult = "选中行: " + result.selectedItemId; + return; + } + + if (!insideList && wasFocused && !m_interactionState.listViewState.focused) { + m_lastResult = "点击列表外空白: focus 已清除,selection 保留"; + return; + } + + if (insideList) { + 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); + RefreshListFrame(); + + const UIEditorListViewHitTarget currentHit = + HitTestUIEditorListView(m_listFrame.layout, m_mousePosition); + + UIDrawData drawData = {}; + UIDrawList& drawList = drawData.EmplaceDrawList("EditorListViewBasic"); + drawList.AddFilledRect(UIRect(0.0f, 0.0f, width, height), kWindowBg); + + DrawCard( + drawList, + layout.introRect, + "这个测试在验证什么功能", + "只验证 Editor ListView 基础控件,不涉及任何业务面板。"); + drawList.AddText( + UIPoint(layout.introRect.x + 16.0f, layout.introRect.y + 72.0f), + "1. 验证列表行的垂直排列是否稳定:主标题和次标题不能互相挤压。", + kTextPrimary, + 12.0f); + drawList.AddText( + UIPoint(layout.introRect.x + 16.0f, layout.introRect.y + 94.0f), + "2. 点击 row 只切换 selection;hover、selected、focused 三种状态要能区分。", + kTextPrimary, + 12.0f); + drawList.AddText( + UIPoint(layout.introRect.x + 16.0f, layout.introRect.y + 116.0f), + "3. 当列表获得 focus 后,按 Up / Down / Home / End 应稳定移动当前选择。", + 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 / current / items。"); + 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.listViewState.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), + std::string("Current: ") + + (m_interactionState.keyboardNavigation.HasCurrentIndex() + ? std::to_string(m_interactionState.keyboardNavigation.GetCurrentIndex()) + : std::string("(none)")), + kTextMuted, + 12.0f); + drawList.AddText( + UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 166.0f), + "Items(" + std::to_string(m_items.size()) + "): " + JoinItems(m_items), + 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/list_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, "ListView 预览", "这里只放一个 ListView,不混入 Project/Console 等业务内容。"); + AppendUIEditorListViewBackground( + drawList, + m_listFrame.layout, + m_items, + m_selectionModel, + m_interactionState.listViewState); + AppendUIEditorListViewForeground(drawList, m_listFrame.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 = {}; + UIEditorListViewInteractionState m_interactionState = {}; + UIEditorListViewInteractionFrame m_listFrame = {}; + 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/integration/shell/property_grid_basic/CMakeLists.txt b/tests/UI/Editor/integration/shell/property_grid_basic/CMakeLists.txt new file mode 100644 index 00000000..ad5b11c0 --- /dev/null +++ b/tests/UI/Editor/integration/shell/property_grid_basic/CMakeLists.txt @@ -0,0 +1,30 @@ +add_executable(editor_ui_property_grid_basic_validation WIN32 + main.cpp +) + +target_include_directories(editor_ui_property_grid_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_property_grid_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_property_grid_basic_validation PRIVATE /utf-8 /FS) + set_property(TARGET editor_ui_property_grid_basic_validation PROPERTY + MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>DLL") +endif() + +target_link_libraries(editor_ui_property_grid_basic_validation PRIVATE + XCUIEditorLib + XCUIEditorHost +) + +set_target_properties(editor_ui_property_grid_basic_validation PROPERTIES + OUTPUT_NAME "XCUIEditorPropertyGridBasicValidation" +) diff --git a/tests/UI/Editor/integration/shell/property_grid_basic/captures/.gitkeep b/tests/UI/Editor/integration/shell/property_grid_basic/captures/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/tests/UI/Editor/integration/shell/property_grid_basic/captures/.gitkeep @@ -0,0 +1 @@ + diff --git a/tests/UI/Editor/integration/shell/property_grid_basic/main.cpp b/tests/UI/Editor/integration/shell/property_grid_basic/main.cpp new file mode 100644 index 00000000..02416eaf --- /dev/null +++ b/tests/UI/Editor/integration/shell/property_grid_basic/main.cpp @@ -0,0 +1,907 @@ +#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 + +#ifndef XCENGINE_EDITOR_UI_TESTS_REPO_ROOT +#define XCENGINE_EDITOR_UI_TESTS_REPO_ROOT "." +#endif + +namespace { + +using XCEngine::Input::KeyCode; +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::UIPropertyEditModel; +using XCEngine::UI::Widgets::UISelectionModel; +using XCEngine::UI::Editor::Host::AutoScreenshotController; +using XCEngine::UI::Editor::Host::NativeRenderer; +using XCEngine::UI::Editor::UIEditorPropertyGridInteractionFrame; +using XCEngine::UI::Editor::UIEditorPropertyGridInteractionResult; +using XCEngine::UI::Editor::UIEditorPropertyGridInteractionState; +using XCEngine::UI::Editor::UpdateUIEditorPropertyGridInteraction; +using XCEngine::UI::Editor::Widgets::AppendUIEditorPropertyGridBackground; +using XCEngine::UI::Editor::Widgets::AppendUIEditorPropertyGridForeground; +using XCEngine::UI::Editor::Widgets::HitTestUIEditorPropertyGrid; +using XCEngine::UI::Editor::Widgets::UIEditorPropertyGridField; +using XCEngine::UI::Editor::Widgets::UIEditorPropertyGridHitTarget; +using XCEngine::UI::Editor::Widgets::UIEditorPropertyGridHitTargetKind; +using XCEngine::UI::Editor::Widgets::UIEditorPropertyGridSection; + +constexpr const wchar_t* kWindowClassName = L"XCUIEditorPropertyGridBasicValidation"; +constexpr const wchar_t* kWindowTitle = L"XCUI Editor | PropertyGrid 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 gridRect = {}; + 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; +} + +std::int32_t MapEditorKey(UINT keyCode) { + switch (keyCode) { + case VK_UP: + return static_cast(KeyCode::Up); + case VK_DOWN: + return static_cast(KeyCode::Down); + case VK_HOME: + return static_cast(KeyCode::Home); + case VK_END: + return static_cast(KeyCode::End); + case VK_LEFT: + return static_cast(KeyCode::Left); + case VK_RIGHT: + return static_cast(KeyCode::Right); + case VK_RETURN: + return static_cast(KeyCode::Enter); + case VK_ESCAPE: + return static_cast(KeyCode::Escape); + case VK_BACK: + return static_cast(KeyCode::Backspace); + case VK_DELETE: + return static_cast(KeyCode::Delete); + default: + return static_cast(KeyCode::None); + } +} + +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)(220.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.gridRect = 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 BuildSections() { + return { + { + "transform", + "Transform", + { + { "position", "Position", "0, 0, 0", false, 0.0f }, + { "rotation", "Rotation", "0, 45, 0", false, 0.0f }, + { "scale", "Scale", "1, 1, 1", false, 0.0f } + }, + 0.0f + }, + { + "material", + "Material", + { + { "shader", "Shader", "Standard/Lit", false, 0.0f }, + { "queue", "Render Queue", "2000", false, 0.0f }, + { "guid", "GUID", "asset-guid-001", true, 0.0f } + }, + 0.0f + }, + { + "metadata", + "Metadata", + { + { "tag", "Tag", "", false, 0.0f }, + { "layer", "Layer", "Default", false, 0.0f } + }, + 0.0f + } + }; +} + +std::string DescribeHitTarget( + const UIEditorPropertyGridHitTarget& hitTarget, + const std::vector& sections) { + if (hitTarget.kind == UIEditorPropertyGridHitTargetKind::SectionHeader && + hitTarget.sectionIndex < sections.size()) { + return "section: " + sections[hitTarget.sectionIndex].title; + } + + if ((hitTarget.kind == UIEditorPropertyGridHitTargetKind::FieldRow || + hitTarget.kind == UIEditorPropertyGridHitTargetKind::ValueBox) && + hitTarget.sectionIndex < sections.size() && + hitTarget.fieldIndex < sections[hitTarget.sectionIndex].fields.size()) { + const UIEditorPropertyGridField& field = + sections[hitTarget.sectionIndex].fields[hitTarget.fieldIndex]; + return (hitTarget.kind == UIEditorPropertyGridHitTargetKind::ValueBox ? "value: " : "field: ") + + field.label; + } + + return "(none)"; +} + +std::string BuildExpandedSummary(const UIExpansionModel& expansionModel) { + std::ostringstream stream = {}; + stream << (expansionModel.IsExpanded("transform") ? "Transform" : "-"); + stream << " / " << (expansionModel.IsExpanded("material") ? "Material" : "-"); + stream << " / " << (expansionModel.IsExpanded("metadata") ? "Metadata" : "-"); + return stream.str(); +} + +UIInputEvent MakePointerEvent( + UIInputEventType type, + const UIPoint& position, + UIPointerButton button = UIPointerButton::None) { + UIInputEvent event = {}; + event.type = type; + event.position = position; + event.pointerButton = button; + return event; +} + +UIInputEvent MakeKeyEvent(std::int32_t keyCode) { + UIInputEvent event = {}; + event.type = UIInputEventType::KeyDown; + event.keyCode = keyCode; + return event; +} + +UIInputEvent MakeCharacterEvent(std::uint32_t character) { + UIInputEvent event = {}; + event.type = UIInputEventType::Character; + event.character = character; + 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_RBUTTONDOWN: + if (app != nullptr) { + app->HandleRightButtonDown( + static_cast(GET_X_LPARAM(lParam)), + static_cast(GET_Y_LPARAM(lParam))); + return 0; + } + break; + + case WM_RBUTTONUP: + if (app != nullptr) { + app->HandleRightButtonUp( + static_cast(GET_X_LPARAM(lParam)), + static_cast(GET_Y_LPARAM(lParam))); + return 0; + } + break; + + case WM_KEYDOWN: + case WM_SYSKEYDOWN: + if (app != nullptr) { + if (wParam == VK_F12) { + app->m_autoScreenshot.RequestCapture("manual_f12"); + app->m_lastResult = "已请求截图,输出到 captures/latest.png"; + InvalidateRect(hwnd, nullptr, FALSE); + return 0; + } + + const std::int32_t keyCode = MapEditorKey(static_cast(wParam)); + if (keyCode != static_cast(KeyCode::None)) { + app->HandleKeyDown(keyCode); + return 0; + } + } + break; + + case WM_CHAR: + if (app != nullptr && + wParam >= 32 && + wParam != VK_RETURN && + wParam != VK_ESCAPE && + wParam != VK_TAB) { + app->HandleCharacter(static_cast(wParam)); + 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/property_grid_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_sections = BuildSections(); + m_selectionModel = {}; + m_selectionModel.SetSelection("rotation"); + m_expansionModel = {}; + m_expansionModel.Expand("transform"); + m_expansionModel.Expand("material"); + m_expansionModel.Expand("metadata"); + m_propertyEditModel = {}; + m_interactionState = {}; + m_interactionState.propertyGridState.focused = true; + m_mousePosition = UIPoint(-1000.0f, -1000.0f); + m_hoveredAction = ActionId::Reset; + m_hasHoveredAction = false; + m_lastResult = "已重置到默认 PropertyGrid 状态"; + m_lastCommit = "(none)"; + RefreshGridFrame(); + } + + void RefreshGridFrame() { + if (m_hwnd == nullptr) { + return; + } + + const ScenarioLayout layout = GetLayout(); + m_gridFrame = + UpdateUIEditorPropertyGridInteraction( + m_interactionState, + m_selectionModel, + m_expansionModel, + m_propertyEditModel, + layout.gridRect, + m_sections, + {}); + } + + void ApplyCommittedValue(const UIEditorPropertyGridInteractionResult& result) { + if (!result.editCommitted || result.committedFieldId.empty()) { + return; + } + + for (UIEditorPropertyGridSection& section : m_sections) { + for (UIEditorPropertyGridField& field : section.fields) { + if (field.fieldId == result.committedFieldId) { + field.valueText = result.committedValue; + m_lastCommit = result.committedFieldId + " = " + result.committedValue; + return; + } + } + } + } + + void OnResize(UINT width, UINT height) { + if (width == 0u || height == 0u) { + return; + } + + m_renderer.Resize(width, height); + RefreshGridFrame(); + } + + 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); + + PumpGridEvents({ MakePointerEvent(UIInputEventType::PointerMove, m_mousePosition) }); + InvalidateRect(m_hwnd, nullptr, FALSE); + } + + void HandleMouseLeave() { + m_mousePosition = UIPoint(-1000.0f, -1000.0f); + m_hasHoveredAction = false; + PumpGridEvents({ 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; + } + + PumpGridEvents({ 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.propertyGridState.focused; + const bool insideGrid = ContainsPoint(layout.gridRect, x, y); + const UIEditorPropertyGridInteractionResult result = + PumpGridEvents({ MakePointerEvent(UIInputEventType::PointerButtonUp, m_mousePosition, UIPointerButton::Left) }); + UpdateResultText(result, wasFocused, insideGrid); + InvalidateRect(m_hwnd, nullptr, FALSE); + } + + void HandleRightButtonDown(float x, float y) { + m_mousePosition = UIPoint(x, y); + PumpGridEvents({ MakePointerEvent(UIInputEventType::PointerButtonDown, m_mousePosition, UIPointerButton::Right) }); + InvalidateRect(m_hwnd, nullptr, FALSE); + } + + void HandleRightButtonUp(float x, float y) { + m_mousePosition = UIPoint(x, y); + const UIEditorPropertyGridInteractionResult result = + PumpGridEvents({ MakePointerEvent(UIInputEventType::PointerButtonUp, m_mousePosition, UIPointerButton::Right) }); + UpdateResultText(result, m_interactionState.propertyGridState.focused, true); + InvalidateRect(m_hwnd, nullptr, FALSE); + } + + void HandleKeyDown(std::int32_t keyCode) { + const UIEditorPropertyGridInteractionResult result = + PumpGridEvents({ MakeKeyEvent(keyCode) }); + UpdateResultText(result, m_interactionState.propertyGridState.focused, true); + InvalidateRect(m_hwnd, nullptr, FALSE); + } + + void HandleCharacter(std::uint32_t character) { + const UIEditorPropertyGridInteractionResult result = + PumpGridEvents({ MakeCharacterEvent(character) }); + UpdateResultText(result, m_interactionState.propertyGridState.focused, true); + 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; + } + + UIEditorPropertyGridInteractionResult PumpGridEvents(std::vector events) { + const ScenarioLayout layout = GetLayout(); + m_gridFrame = + UpdateUIEditorPropertyGridInteraction( + m_interactionState, + m_selectionModel, + m_expansionModel, + m_propertyEditModel, + layout.gridRect, + m_sections, + std::move(events)); + ApplyCommittedValue(m_gridFrame.result); + return m_gridFrame.result; + } + + void UpdateResultText( + const UIEditorPropertyGridInteractionResult& result, + bool wasFocused, + bool insideGrid) { + if (result.editCommitted) { + m_lastResult = "提交字段: " + result.committedFieldId + " = " + result.committedValue; + return; + } + + if (result.editCanceled) { + m_lastResult = "取消编辑: " + result.activeFieldId; + return; + } + + if (result.editValueChanged) { + m_lastResult = "编辑中: " + result.activeFieldId; + return; + } + + if (result.editStarted) { + m_lastResult = "开始编辑: " + result.activeFieldId; + return; + } + + if (result.sectionToggled) { + m_lastResult = "切换分组: " + result.toggledSectionId; + return; + } + + if (result.keyboardNavigated && !result.selectedFieldId.empty()) { + m_lastResult = "键盘选中: " + result.selectedFieldId; + return; + } + + if (result.selectionChanged && !result.selectedFieldId.empty()) { + m_lastResult = "选中字段: " + result.selectedFieldId; + return; + } + + if (result.secondaryClicked && !result.selectedFieldId.empty()) { + m_lastResult = "右键命中字段: " + result.selectedFieldId; + return; + } + + if (!insideGrid && wasFocused && !m_interactionState.propertyGridState.focused) { + m_lastResult = "点击外部后 focus 已清除"; + return; + } + + if (insideGrid) { + m_lastResult = "Grid 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); + RefreshGridFrame(); + + const UIEditorPropertyGridHitTarget currentHit = + HitTestUIEditorPropertyGrid(m_gridFrame.layout, m_mousePosition); + + UIDrawData drawData = {}; + UIDrawList& drawList = drawData.EmplaceDrawList("EditorPropertyGridBasic"); + drawList.AddFilledRect(UIRect(0.0f, 0.0f, width, height), kWindowBg); + + DrawCard( + drawList, + layout.introRect, + "这个测试在验证什么功能", + "只验证 Editor PropertyGrid 基础控件,不涉及任何 Inspector 业务逻辑。"); + drawList.AddText( + UIPoint(layout.introRect.x + 16.0f, layout.introRect.y + 72.0f), + "1. 点击 section header,检查展开/折叠是否稳定,字段布局不能歪。", + kTextPrimary, + 12.0f); + drawList.AddText( + UIPoint(layout.introRect.x + 16.0f, layout.introRect.y + 94.0f), + "2. 点击 field row 只切换 selection;点击 value box 才进入编辑态。", + kTextPrimary, + 12.0f); + drawList.AddText( + UIPoint(layout.introRect.x + 16.0f, layout.introRect.y + 116.0f), + "3. 编辑态输入字符,按 Enter commit,按 Esc cancel,read-only 字段不能进编辑。", + kTextPrimary, + 12.0f); + drawList.AddText( + UIPoint(layout.introRect.x + 16.0f, layout.introRect.y + 138.0f), + "4. Grid 获得 focus 后按 Up / Down / Home / End,检查字段导航和 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 / edit / commit。"); + drawList.AddText( + UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 70.0f), + "Hover: " + DescribeHitTarget(currentHit, m_sections), + kTextPrimary, + 12.0f); + drawList.AddText( + UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 94.0f), + std::string("Focused: ") + (m_interactionState.propertyGridState.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), + "Active Edit: " + + (m_propertyEditModel.HasActiveEdit() ? m_propertyEditModel.GetActiveFieldId() : std::string("(none)")), + kTextMuted, + 12.0f); + drawList.AddText( + UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 166.0f), + "Staged: " + + (m_propertyEditModel.HasActiveEdit() ? m_propertyEditModel.GetStagedValue() : std::string("(none)")), + kTextMuted, + 12.0f); + drawList.AddText( + UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 190.0f), + "Expanded: " + BuildExpandedSummary(m_expansionModel), + kTextMuted, + 12.0f); + drawList.AddText( + UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 214.0f), + "Last Commit: " + m_lastCommit, + kTextPrimary, + 12.0f); + drawList.AddText( + UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 238.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/property_grid_basic/captures/") + : m_autoScreenshot.GetLastCaptureSummary()); + drawList.AddText( + UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 262.0f), + captureSummary, + kTextWeak, + 12.0f); + + DrawCard(drawList, layout.previewRect, "PropertyGrid 预览", "这里只放一个 PropertyGrid,不混入任何业务面板。"); + AppendUIEditorPropertyGridBackground( + drawList, + m_gridFrame.layout, + m_sections, + m_selectionModel, + m_propertyEditModel, + m_interactionState.propertyGridState); + AppendUIEditorPropertyGridForeground( + drawList, + m_gridFrame.layout, + m_sections, + m_propertyEditModel); + + 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_sections = {}; + UISelectionModel m_selectionModel = {}; + UIExpansionModel m_expansionModel = {}; + UIPropertyEditModel m_propertyEditModel = {}; + UIEditorPropertyGridInteractionState m_interactionState = {}; + UIEditorPropertyGridInteractionFrame m_gridFrame = {}; + UIPoint m_mousePosition = UIPoint(-1000.0f, -1000.0f); + ActionId m_hoveredAction = ActionId::Reset; + bool m_hasHoveredAction = false; + std::string m_lastResult = {}; + std::string m_lastCommit = {}; +}; + +} // namespace + +int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) { + return ScenarioApp().Run(hInstance, nCmdShow); +} diff --git a/tests/UI/Editor/integration/shell/scroll_view_basic/CMakeLists.txt b/tests/UI/Editor/integration/shell/scroll_view_basic/CMakeLists.txt new file mode 100644 index 00000000..98cebfb0 --- /dev/null +++ b/tests/UI/Editor/integration/shell/scroll_view_basic/CMakeLists.txt @@ -0,0 +1,30 @@ +add_executable(editor_ui_scroll_view_basic_validation WIN32 + main.cpp +) + +target_include_directories(editor_ui_scroll_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_scroll_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_scroll_view_basic_validation PRIVATE /utf-8 /FS) + set_property(TARGET editor_ui_scroll_view_basic_validation PROPERTY + MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>DLL") +endif() + +target_link_libraries(editor_ui_scroll_view_basic_validation PRIVATE + XCUIEditorLib + XCUIEditorHost +) + +set_target_properties(editor_ui_scroll_view_basic_validation PROPERTIES + OUTPUT_NAME "XCUIEditorScrollViewBasicValidation" +) diff --git a/tests/UI/Editor/integration/shell/scroll_view_basic/captures/.gitkeep b/tests/UI/Editor/integration/shell/scroll_view_basic/captures/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/tests/UI/Editor/integration/shell/scroll_view_basic/captures/.gitkeep @@ -0,0 +1 @@ + diff --git a/tests/UI/Editor/integration/shell/scroll_view_basic/main.cpp b/tests/UI/Editor/integration/shell/scroll_view_basic/main.cpp new file mode 100644 index 00000000..e682076c --- /dev/null +++ b/tests/UI/Editor/integration/shell/scroll_view_basic/main.cpp @@ -0,0 +1,759 @@ +#ifndef NOMINMAX +#define NOMINMAX +#endif + +#include +#include +#include "Host/AutoScreenshot.h" +#include "Host/NativeRenderer.h" + +#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::Editor::Host::AutoScreenshotController; +using XCEngine::UI::Editor::Host::NativeRenderer; +using XCEngine::UI::Editor::UIEditorScrollViewInteractionFrame; +using XCEngine::UI::Editor::UIEditorScrollViewInteractionResult; +using XCEngine::UI::Editor::UIEditorScrollViewInteractionState; +using XCEngine::UI::Editor::UpdateUIEditorScrollViewInteraction; +using XCEngine::UI::Editor::Widgets::AppendUIEditorScrollViewBackground; +using XCEngine::UI::Editor::Widgets::HitTestUIEditorScrollView; +using XCEngine::UI::Editor::Widgets::ResolveUIEditorScrollViewContentOrigin; +using XCEngine::UI::Editor::Widgets::UIEditorScrollViewHitTarget; +using XCEngine::UI::Editor::Widgets::UIEditorScrollViewHitTargetKind; + +constexpr const wchar_t* kWindowClassName = L"XCUIEditorScrollViewBasicValidation"; +constexpr const wchar_t* kWindowTitle = L"XCUI Editor | ScrollView 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 scrollRect = {}; + 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, 84.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.scrollRect = 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 + 32.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 BuildLogLines() { + std::vector lines = { + "Line 01 - ScrollView validation log", + "Line 02 - Hover inside the viewport, then use wheel", + "Line 03 - Offset should grow when wheel scrolls down", + "Line 04 - Offset should clamp at the bottom boundary", + "Line 05 - Drag the thumb to verify direct scrollbar control", + "Line 06 - Clicking empty content should only change focus", + "Line 07 - ScrollView is Editor foundation, not any business panel", + "Line 08 - PropertyGrid and ListView will reuse this viewport contract", + "Line 09 - Line spacing should remain clipped inside viewport", + "Line 10 - The right thumb should stay aligned to overflow", + "Line 11 - Extra row", + "Line 12 - Extra row", + "Line 13 - Extra row", + "Line 14 - Extra row", + "Line 15 - Extra row", + "Line 16 - Extra row", + }; + + for (int index = 11; index <= 40; ++index) { + lines.push_back( + "Line " + + std::string(index < 10 ? "0" : "") + + std::to_string(index) + + " - Extra row"); + } + + return lines; +} + +std::string DescribeHitTarget(const UIEditorScrollViewHitTarget& hitTarget) { + switch (hitTarget.kind) { + case UIEditorScrollViewHitTargetKind::Content: + return "content"; + case UIEditorScrollViewHitTargetKind::ScrollbarTrack: + return "scrollbar-track"; + case UIEditorScrollViewHitTargetKind::ScrollbarThumb: + return "scrollbar-thumb"; + case UIEditorScrollViewHitTargetKind::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; +} + +UIInputEvent MakeWheelEvent(const UIPoint& position, float wheelDelta) { + UIInputEvent event = {}; + event.type = UIInputEventType::PointerWheel; + event.position = position; + event.wheelDelta = wheelDelta; + 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_MOUSEWHEEL: + if (app != nullptr) { + POINT point = { GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam) }; + ScreenToClient(hwnd, &point); + app->HandleMouseWheel( + static_cast(point.x), + static_cast(point.y), + static_cast(GET_WHEEL_DELTA_WPARAM(wParam)) / static_cast(WHEEL_DELTA)); + 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/scroll_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); + } + + float ResolveContentHeight() const { + constexpr float lineHeight = 28.0f; + constexpr float topPadding = 12.0f; + return topPadding * 2.0f + static_cast(m_logLines.size()) * lineHeight; + } + + void ResetScenario() { + m_logLines = BuildLogLines(); + m_verticalOffset = 0.0f; + m_interactionState = {}; + m_mousePosition = UIPoint(-1000.0f, -1000.0f); + m_hoveredAction = ActionId::Reset; + m_hasHoveredAction = false; + m_lastResult = "已重置到 " + std::to_string(m_logLines.size()) + " 行默认滚动位置"; + RefreshScrollFrame(); + } + + void RefreshScrollFrame() { + if (m_hwnd == nullptr) { + return; + } + + const ScenarioLayout layout = GetLayout(); + m_scrollFrame = + UpdateUIEditorScrollViewInteraction( + m_interactionState, + m_verticalOffset, + layout.scrollRect, + ResolveContentHeight(), + {}); + } + + void OnResize(UINT width, UINT height) { + if (width == 0u || height == 0u) { + return; + } + + m_renderer.Resize(width, height); + RefreshScrollFrame(); + } + + 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); + + PumpScrollEvents({ MakePointerEvent(UIInputEventType::PointerMove, m_mousePosition) }); + InvalidateRect(m_hwnd, nullptr, FALSE); + } + + void HandleMouseLeave() { + m_mousePosition = UIPoint(-1000.0f, -1000.0f); + m_hasHoveredAction = false; + PumpScrollEvents({ MakePointerEvent(UIInputEventType::PointerLeave, m_mousePosition) }); + InvalidateRect(m_hwnd, nullptr, FALSE); + } + + void HandleMouseWheel(float x, float y, float wheelDelta) { + m_mousePosition = UIPoint(x, y); + const UIEditorScrollViewInteractionResult result = + PumpScrollEvents({ MakeWheelEvent(m_mousePosition, wheelDelta) }); + if (result.offsetChanged) { + m_lastResult = "滚轮滚动: offset 已更新"; + } + 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; + } + + const UIEditorScrollViewInteractionResult result = + PumpScrollEvents({ MakePointerEvent(UIInputEventType::PointerButtonDown, m_mousePosition, UIPointerButton::Left) }); + if (result.startedThumbDrag) { + SetCapture(m_hwnd); + } + UpdateResultText(result, ContainsPoint(layout.scrollRect, x, y)); + 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 UIEditorScrollViewInteractionResult result = + PumpScrollEvents({ MakePointerEvent(UIInputEventType::PointerButtonUp, m_mousePosition, UIPointerButton::Left) }); + if (!m_interactionState.scrollViewState.draggingScrollbarThumb && GetCapture() == m_hwnd) { + ReleaseCapture(); + } + UpdateResultText(result, ContainsPoint(layout.scrollRect, x, y)); + 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; + } + + UIEditorScrollViewInteractionResult PumpScrollEvents(std::vector events) { + const ScenarioLayout layout = GetLayout(); + m_scrollFrame = + UpdateUIEditorScrollViewInteraction( + m_interactionState, + m_verticalOffset, + layout.scrollRect, + ResolveContentHeight(), + std::move(events)); + return m_scrollFrame.result; + } + + void UpdateResultText( + const UIEditorScrollViewInteractionResult& result, + bool insideScrollView) { + if (result.startedThumbDrag) { + m_lastResult = "开始拖拽 scrollbar thumb"; + return; + } + + if (result.endedThumbDrag) { + m_lastResult = "结束拖拽 scrollbar thumb"; + return; + } + + if (result.offsetChanged) { + m_lastResult = "滚动位置变化"; + return; + } + + if (result.focusChanged) { + m_lastResult = m_interactionState.scrollViewState.focused + ? "ScrollView 获得 focus" + : "ScrollView focus 已清除"; + return; + } + + if (insideScrollView) { + m_lastResult = "点击内容区域: 只验证 focus / hover / scrollbar"; + 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); + RefreshScrollFrame(); + + const UIEditorScrollViewHitTarget currentHit = + HitTestUIEditorScrollView(m_scrollFrame.layout, m_mousePosition); + + UIDrawData drawData = {}; + UIDrawList& drawList = drawData.EmplaceDrawList("EditorScrollViewBasic"); + drawList.AddFilledRect(UIRect(0.0f, 0.0f, width, height), kWindowBg); + + DrawCard( + drawList, + layout.introRect, + "这个测试在验证什么功能", + "只验证 Editor ScrollView 基础控件,不涉及任何业务面板。"); + drawList.AddText( + UIPoint(layout.introRect.x + 16.0f, layout.introRect.y + 72.0f), + "1. 把鼠标放到右侧日志区内滚动滚轮:内容应上下移动,offset 应变化。", + kTextPrimary, + 12.0f); + drawList.AddText( + UIPoint(layout.introRect.x + 16.0f, layout.introRect.y + 94.0f), + "2. 继续滚到边界后 offset 要被 clamp,不能越界。", + kTextPrimary, + 12.0f); + drawList.AddText( + UIPoint(layout.introRect.x + 16.0f, layout.introRect.y + 116.0f), + "3. 拖拽 scrollbar thumb,内容位置应同步变化。", + kTextPrimary, + 12.0f); + drawList.AddText( + UIPoint(layout.introRect.x + 16.0f, layout.introRect.y + 138.0f), + "4. 点击内容区只改变 focus;点击外部空白后 focus 应清除。", + 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 / thumb-drag / offset / overflow。"); + drawList.AddText( + UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 70.0f), + "Hover: " + DescribeHitTarget(currentHit), + kTextPrimary, + 12.0f); + drawList.AddText( + UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 94.0f), + std::string("Focused: ") + (m_interactionState.scrollViewState.focused ? "开" : "关"), + kTextPrimary, + 12.0f); + drawList.AddText( + UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 118.0f), + std::string("Thumb Dragging: ") + + (m_interactionState.scrollViewState.draggingScrollbarThumb ? "是" : "否"), + kTextSuccess, + 12.0f); + drawList.AddText( + UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 142.0f), + "Offset: " + std::to_string(static_cast(m_verticalOffset)) + + " / " + std::to_string(static_cast(m_scrollFrame.layout.maxOffset)), + kTextMuted, + 12.0f); + drawList.AddText( + UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 166.0f), + std::string("Has Scrollbar: ") + (m_scrollFrame.layout.hasScrollbar ? "是" : "否"), + kTextMuted, + 12.0f); + drawList.AddText( + UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 190.0f), + "Lines: " + std::to_string(m_logLines.size()), + kTextMuted, + 12.0f); + drawList.AddText( + UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 214.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/scroll_view_basic/captures/") + : m_autoScreenshot.GetLastCaptureSummary()); + drawList.AddText( + UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 238.0f), + captureSummary, + kTextWeak, + 12.0f); + + DrawCard(drawList, layout.previewRect, "ScrollView 预览", "这里只放一个 ScrollView,不混入任何上层业务内容。"); + AppendUIEditorScrollViewBackground( + drawList, + m_scrollFrame.layout, + m_interactionState.scrollViewState); + + const UIPoint contentOrigin = ResolveUIEditorScrollViewContentOrigin(m_scrollFrame.layout); + drawList.PushClipRect(m_scrollFrame.layout.contentRect); + for (std::size_t index = 0; index < m_logLines.size(); ++index) { + drawList.AddText( + UIPoint( + contentOrigin.x + 16.0f, + contentOrigin.y + 12.0f + static_cast(index) * 28.0f), + m_logLines[index], + kTextPrimary, + 12.0f); + } + drawList.PopClipRect(); + + 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_logLines = {}; + UIEditorScrollViewInteractionState m_interactionState = {}; + UIEditorScrollViewInteractionFrame m_scrollFrame = {}; + float m_verticalOffset = 0.0f; + 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 a893365b..be8cdf5e 100644 --- a/tests/UI/Editor/unit/CMakeLists.txt +++ b/tests/UI/Editor/unit/CMakeLists.txt @@ -12,12 +12,18 @@ set(EDITOR_UI_UNIT_TEST_SOURCES test_ui_editor_panel_content_host.cpp test_ui_editor_panel_host_lifecycle.cpp test_ui_editor_panel_registry.cpp + test_ui_editor_property_grid.cpp + test_ui_editor_property_grid_interaction.cpp test_ui_editor_shell_compose.cpp test_ui_editor_shell_interaction.cpp test_ui_editor_collection_primitives.cpp test_ui_editor_dock_host.cpp + test_ui_editor_list_view.cpp + test_ui_editor_list_view_interaction.cpp test_ui_editor_panel_chrome.cpp test_ui_editor_panel_frame.cpp + test_ui_editor_scroll_view.cpp + test_ui_editor_scroll_view_interaction.cpp test_ui_editor_status_bar.cpp test_ui_editor_tab_strip.cpp test_ui_editor_tree_view.cpp diff --git a/tests/UI/Editor/unit/test_ui_editor_list_view.cpp b/tests/UI/Editor/unit/test_ui_editor_list_view.cpp new file mode 100644 index 00000000..0ab967e6 --- /dev/null +++ b/tests/UI/Editor/unit/test_ui_editor_list_view.cpp @@ -0,0 +1,113 @@ +#include + +#include + +namespace { + +using XCEngine::UI::UIPoint; +using XCEngine::UI::UIRect; +using XCEngine::UI::Widgets::UISelectionModel; +using XCEngine::UI::Editor::Widgets::AppendUIEditorListViewBackground; +using XCEngine::UI::Editor::Widgets::AppendUIEditorListViewForeground; +using XCEngine::UI::Editor::Widgets::BuildUIEditorListViewLayout; +using XCEngine::UI::Editor::Widgets::FindUIEditorListViewItemIndex; +using XCEngine::UI::Editor::Widgets::HitTestUIEditorListView; +using XCEngine::UI::Editor::Widgets::UIEditorListViewHitTargetKind; +using XCEngine::UI::Editor::Widgets::UIEditorListViewItem; +using XCEngine::UI::Editor::Widgets::UIEditorListViewState; + +bool ContainsTextCommand( + const XCEngine::UI::UIDrawData& drawData, + std::string_view text) { + for (const auto& drawList : drawData.GetDrawLists()) { + for (const auto& command : drawList.GetCommands()) { + if (command.type == XCEngine::UI::UIDrawCommandType::Text && + command.text == text) { + return true; + } + } + } + + return false; +} + +std::vector BuildListItems() { + return { + { "scene", "Scene.unity", "最近修改: 1 分钟前", 0.0f }, + { "material", "Metal.mat", "Material | 4 个属性", 0.0f }, + { "script", "PlayerController.cs", "", 0.0f } + }; +} + +} // namespace + +TEST(UIEditorListViewTest, FindItemIndexReturnsMatchingStableIndex) { + const auto items = BuildListItems(); + + EXPECT_EQ(FindUIEditorListViewItemIndex(items, "scene"), 0u); + EXPECT_EQ(FindUIEditorListViewItemIndex(items, "material"), 1u); + EXPECT_EQ(FindUIEditorListViewItemIndex(items, "missing"), static_cast(-1)); +} + +TEST(UIEditorListViewTest, LayoutBuildsRowsAndSecondaryTextRects) { + const auto items = BuildListItems(); + const auto layout = BuildUIEditorListViewLayout( + UIRect(10.0f, 20.0f, 320.0f, 240.0f), + items); + + ASSERT_EQ(layout.rowRects.size(), items.size()); + EXPECT_EQ(layout.itemIndices[0], 0u); + EXPECT_EQ(layout.itemIndices[2], 2u); + + EXPECT_FLOAT_EQ(layout.rowRects[0].x, 10.0f); + EXPECT_FLOAT_EQ(layout.rowRects[0].y, 20.0f); + EXPECT_FLOAT_EQ(layout.rowRects[1].y, 66.0f); + EXPECT_FLOAT_EQ(layout.primaryTextRects[0].x, 20.0f); + EXPECT_TRUE(layout.hasSecondaryText[0]); + EXPECT_TRUE(layout.hasSecondaryText[1]); + EXPECT_FALSE(layout.hasSecondaryText[2]); + EXPECT_GT(layout.secondaryTextRects[0].height, 0.0f); + EXPECT_FLOAT_EQ(layout.secondaryTextRects[2].height, 0.0f); +} + +TEST(UIEditorListViewTest, HitTestResolvesRowsAndReturnsNoneOutsideBounds) { + const auto items = BuildListItems(); + const auto layout = BuildUIEditorListViewLayout( + UIRect(0.0f, 0.0f, 320.0f, 240.0f), + items); + + const auto hit = HitTestUIEditorListView(layout, UIPoint(80.0f, 72.0f)); + EXPECT_EQ(hit.kind, UIEditorListViewHitTargetKind::Row); + EXPECT_EQ(hit.visibleIndex, 1u); + EXPECT_EQ(hit.itemIndex, 1u); + + const auto miss = HitTestUIEditorListView(layout, UIPoint(360.0f, 20.0f)); + EXPECT_EQ(miss.kind, UIEditorListViewHitTargetKind::None); +} + +TEST(UIEditorListViewTest, BackgroundAndForegroundEmitStableCommands) { + const auto items = BuildListItems(); + UISelectionModel selectionModel = {}; + selectionModel.SetSelection("material"); + UIEditorListViewState state = {}; + state.focused = true; + state.hoveredItemId = "script"; + + const auto layout = BuildUIEditorListViewLayout( + UIRect(0.0f, 0.0f, 320.0f, 240.0f), + items); + + XCEngine::UI::UIDrawData drawData = {}; + auto& drawList = drawData.EmplaceDrawList("ListView"); + AppendUIEditorListViewBackground(drawList, layout, items, selectionModel, state); + AppendUIEditorListViewForeground(drawList, layout, items); + + const auto& commands = drawList.GetCommands(); + ASSERT_GE(commands.size(), 7u); + EXPECT_EQ(commands[0].type, XCEngine::UI::UIDrawCommandType::FilledRect); + EXPECT_EQ(commands[1].type, XCEngine::UI::UIDrawCommandType::RectOutline); + EXPECT_EQ(commands[2].type, XCEngine::UI::UIDrawCommandType::FilledRect); + EXPECT_EQ(commands[3].type, XCEngine::UI::UIDrawCommandType::FilledRect); + EXPECT_TRUE(ContainsTextCommand(drawData, "Scene.unity")); + EXPECT_TRUE(ContainsTextCommand(drawData, "最近修改: 1 分钟前")); +} diff --git a/tests/UI/Editor/unit/test_ui_editor_list_view_interaction.cpp b/tests/UI/Editor/unit/test_ui_editor_list_view_interaction.cpp new file mode 100644 index 00000000..159acd3f --- /dev/null +++ b/tests/UI/Editor/unit/test_ui_editor_list_view_interaction.cpp @@ -0,0 +1,232 @@ +#include + +#include + +#include + +namespace { + +using XCEngine::Input::KeyCode; +using XCEngine::UI::UIInputEvent; +using XCEngine::UI::UIInputEventType; +using XCEngine::UI::UIPoint; +using XCEngine::UI::UIPointerButton; +using XCEngine::UI::UIRect; +using XCEngine::UI::Widgets::UISelectionModel; +using XCEngine::UI::Editor::UIEditorListViewInteractionState; +using XCEngine::UI::Editor::UpdateUIEditorListViewInteraction; +using XCEngine::UI::Editor::Widgets::UIEditorListViewHitTargetKind; +using XCEngine::UI::Editor::Widgets::UIEditorListViewItem; + +std::vector BuildListItems() { + return { + { "scene", "Scene.unity", "最近修改: 1 分钟前", 0.0f }, + { "material", "Metal.mat", "Material | 4 个属性", 0.0f }, + { "script", "PlayerController.cs", "", 0.0f }, + { "texture", "Checker.png", "Texture2D | 1024x1024", 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 MakeKeyDown(KeyCode keyCode) { + UIInputEvent event = {}; + event.type = UIInputEventType::KeyDown; + event.keyCode = static_cast(keyCode); + 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(UIEditorListViewInteractionTest, PointerMoveUpdatesHoveredItemAndHitTarget) { + const auto items = BuildListItems(); + UISelectionModel selectionModel = {}; + UIEditorListViewInteractionState state = {}; + + const auto initialFrame = UpdateUIEditorListViewInteraction( + state, + selectionModel, + UIRect(0.0f, 0.0f, 320.0f, 240.0f), + items, + {}); + const UIPoint materialCenter = RectCenter(initialFrame.layout.rowRects[1]); + + const auto frame = UpdateUIEditorListViewInteraction( + state, + selectionModel, + UIRect(0.0f, 0.0f, 320.0f, 240.0f), + items, + { MakePointerMove(materialCenter.x, materialCenter.y) }); + + EXPECT_EQ(frame.result.hitTarget.kind, UIEditorListViewHitTargetKind::Row); + EXPECT_EQ(frame.result.hitTarget.itemIndex, 1u); + EXPECT_EQ(state.listViewState.hoveredItemId, "material"); +} + +TEST(UIEditorListViewInteractionTest, LeftClickRowSelectsItemAndFocusesList) { + const auto items = BuildListItems(); + UISelectionModel selectionModel = {}; + UIEditorListViewInteractionState state = {}; + + const auto initialFrame = UpdateUIEditorListViewInteraction( + state, + selectionModel, + UIRect(0.0f, 0.0f, 320.0f, 240.0f), + items, + {}); + const UIPoint scriptCenter = RectCenter(initialFrame.layout.rowRects[2]); + + const auto frame = UpdateUIEditorListViewInteraction( + state, + selectionModel, + UIRect(0.0f, 0.0f, 320.0f, 240.0f), + items, + { + MakePointerDown(scriptCenter.x, scriptCenter.y), + MakePointerUp(scriptCenter.x, scriptCenter.y) + }); + + EXPECT_TRUE(frame.result.consumed); + EXPECT_TRUE(frame.result.selectionChanged); + EXPECT_EQ(frame.result.selectedItemId, "script"); + EXPECT_EQ(frame.result.selectedIndex, 2u); + EXPECT_TRUE(selectionModel.IsSelected("script")); + EXPECT_TRUE(state.listViewState.focused); + EXPECT_TRUE(state.keyboardNavigation.HasCurrentIndex()); + EXPECT_EQ(state.keyboardNavigation.GetCurrentIndex(), 2u); +} + +TEST(UIEditorListViewInteractionTest, RightClickRowSelectsItemAndMarksSecondaryClick) { + const auto items = BuildListItems(); + UISelectionModel selectionModel = {}; + UIEditorListViewInteractionState state = {}; + + const auto initialFrame = UpdateUIEditorListViewInteraction( + state, + selectionModel, + UIRect(0.0f, 0.0f, 320.0f, 240.0f), + items, + {}); + const UIPoint textureCenter = RectCenter(initialFrame.layout.rowRects[3]); + + const auto frame = UpdateUIEditorListViewInteraction( + state, + selectionModel, + UIRect(0.0f, 0.0f, 320.0f, 240.0f), + items, + { + MakePointerDown(textureCenter.x, textureCenter.y, UIPointerButton::Right), + MakePointerUp(textureCenter.x, textureCenter.y, UIPointerButton::Right) + }); + + EXPECT_TRUE(frame.result.consumed); + EXPECT_TRUE(frame.result.secondaryClicked); + EXPECT_EQ(frame.result.selectedItemId, "texture"); + EXPECT_TRUE(selectionModel.IsSelected("texture")); + EXPECT_TRUE(state.listViewState.focused); +} + +TEST(UIEditorListViewInteractionTest, ArrowAndHomeEndKeysDriveSelectionWhenFocused) { + const auto items = BuildListItems(); + UISelectionModel selectionModel = {}; + selectionModel.SetSelection("material"); + UIEditorListViewInteractionState state = {}; + state.listViewState.focused = true; + + auto frame = UpdateUIEditorListViewInteraction( + state, + selectionModel, + UIRect(0.0f, 0.0f, 320.0f, 240.0f), + items, + { MakeKeyDown(KeyCode::Down) }); + EXPECT_TRUE(frame.result.keyboardNavigated); + EXPECT_EQ(frame.result.selectedItemId, "script"); + EXPECT_TRUE(selectionModel.IsSelected("script")); + + frame = UpdateUIEditorListViewInteraction( + state, + selectionModel, + UIRect(0.0f, 0.0f, 320.0f, 240.0f), + items, + { MakeKeyDown(KeyCode::Home) }); + EXPECT_TRUE(frame.result.keyboardNavigated); + EXPECT_EQ(frame.result.selectedItemId, "scene"); + EXPECT_TRUE(selectionModel.IsSelected("scene")); + + frame = UpdateUIEditorListViewInteraction( + state, + selectionModel, + UIRect(0.0f, 0.0f, 320.0f, 240.0f), + items, + { MakeKeyDown(KeyCode::End) }); + EXPECT_TRUE(frame.result.keyboardNavigated); + EXPECT_EQ(frame.result.selectedItemId, "texture"); + EXPECT_TRUE(selectionModel.IsSelected("texture")); +} + +TEST(UIEditorListViewInteractionTest, OutsideClickAndFocusLostClearFocusAndHoverButKeepSelection) { + const auto items = BuildListItems(); + UISelectionModel selectionModel = {}; + selectionModel.SetSelection("material"); + UIEditorListViewInteractionState state = {}; + state.listViewState.focused = true; + state.listViewState.hoveredItemId = "material"; + + auto frame = UpdateUIEditorListViewInteraction( + state, + selectionModel, + UIRect(0.0f, 0.0f, 320.0f, 240.0f), + items, + { MakePointerDown(360.0f, 260.0f), MakePointerUp(360.0f, 260.0f) }); + EXPECT_FALSE(state.listViewState.focused); + EXPECT_EQ(frame.result.hitTarget.kind, UIEditorListViewHitTargetKind::None); + EXPECT_TRUE(selectionModel.IsSelected("material")); + + frame = UpdateUIEditorListViewInteraction( + state, + selectionModel, + UIRect(0.0f, 0.0f, 320.0f, 240.0f), + items, + { MakePointerLeave(), MakeFocusLost() }); + EXPECT_FALSE(state.listViewState.focused); + EXPECT_TRUE(state.listViewState.hoveredItemId.empty()); + EXPECT_FALSE(state.hasPointerPosition); + EXPECT_TRUE(selectionModel.IsSelected("material")); +} diff --git a/tests/UI/Editor/unit/test_ui_editor_property_grid.cpp b/tests/UI/Editor/unit/test_ui_editor_property_grid.cpp new file mode 100644 index 00000000..942e5447 --- /dev/null +++ b/tests/UI/Editor/unit/test_ui_editor_property_grid.cpp @@ -0,0 +1,189 @@ +#include + +#include + +namespace { + +using XCEngine::UI::UIPoint; +using XCEngine::UI::UIRect; +using XCEngine::UI::Widgets::UIExpansionModel; +using XCEngine::UI::Widgets::UIPropertyEditModel; +using XCEngine::UI::Widgets::UISelectionModel; +using XCEngine::UI::Editor::Widgets::AppendUIEditorPropertyGridBackground; +using XCEngine::UI::Editor::Widgets::AppendUIEditorPropertyGridForeground; +using XCEngine::UI::Editor::Widgets::BuildUIEditorPropertyGridLayout; +using XCEngine::UI::Editor::Widgets::FindUIEditorPropertyGridFieldLocation; +using XCEngine::UI::Editor::Widgets::FindUIEditorPropertyGridSectionIndex; +using XCEngine::UI::Editor::Widgets::FindUIEditorPropertyGridVisibleFieldIndex; +using XCEngine::UI::Editor::Widgets::HitTestUIEditorPropertyGrid; +using XCEngine::UI::Editor::Widgets::UIEditorPropertyGridField; +using XCEngine::UI::Editor::Widgets::UIEditorPropertyGridHitTargetKind; +using XCEngine::UI::Editor::Widgets::UIEditorPropertyGridSection; +using XCEngine::UI::Editor::Widgets::UIEditorPropertyGridState; + +bool ContainsTextCommand( + const XCEngine::UI::UIDrawData& drawData, + std::string_view text) { + for (const auto& drawList : drawData.GetDrawLists()) { + for (const auto& command : drawList.GetCommands()) { + if (command.type == XCEngine::UI::UIDrawCommandType::Text && + command.text == text) { + return true; + } + } + } + + return false; +} + +std::vector BuildSections() { + return { + { + "transform", + "Transform", + { + { "position", "Position", "0, 0, 0", false, 0.0f }, + { "rotation", "Rotation", "0, 45, 0", false, 0.0f } + }, + 0.0f + }, + { + "rendering", + "Rendering", + { + { "material", "Material", "Metal", false, 0.0f }, + { "guid", "GUID", "asset-guid-001", true, 0.0f } + }, + 0.0f + } + }; +} + +UIPoint RectCenter(const XCEngine::UI::UIRect& rect) { + return UIPoint(rect.x + rect.width * 0.5f, rect.y + rect.height * 0.5f); +} + +} // namespace + +TEST(UIEditorPropertyGridTest, FindSectionAndFieldLocationReturnStableIndices) { + const auto sections = BuildSections(); + + EXPECT_EQ(FindUIEditorPropertyGridSectionIndex(sections, "transform"), 0u); + EXPECT_EQ(FindUIEditorPropertyGridSectionIndex(sections, "rendering"), 1u); + EXPECT_EQ( + FindUIEditorPropertyGridSectionIndex(sections, "missing"), + static_cast(-1)); + + const auto materialLocation = + FindUIEditorPropertyGridFieldLocation(sections, "material"); + EXPECT_TRUE(materialLocation.IsValid()); + EXPECT_EQ(materialLocation.sectionIndex, 1u); + EXPECT_EQ(materialLocation.fieldIndex, 0u); + + const auto missingLocation = + FindUIEditorPropertyGridFieldLocation(sections, "unknown"); + EXPECT_FALSE(missingLocation.IsValid()); +} + +TEST(UIEditorPropertyGridTest, LayoutBuildsSectionHeadersAndVisibleFieldRects) { + const auto sections = BuildSections(); + UIExpansionModel expansionModel = {}; + expansionModel.Expand("transform"); + + const auto layout = BuildUIEditorPropertyGridLayout( + UIRect(10.0f, 20.0f, 420.0f, 240.0f), + sections, + expansionModel); + + ASSERT_EQ(layout.sectionHeaderRects.size(), sections.size()); + EXPECT_EQ(layout.visibleFieldIndices.size(), 2u); + EXPECT_EQ(layout.visibleFieldSectionIndices[0], 0u); + EXPECT_EQ(layout.visibleFieldIndices[1], 1u); + EXPECT_FLOAT_EQ(layout.sectionHeaderRects[0].x, 18.0f); + EXPECT_FLOAT_EQ(layout.sectionHeaderRects[0].y, 28.0f); + EXPECT_FLOAT_EQ(layout.fieldValueRects[0].x, 254.0f); + EXPECT_GT(layout.fieldValueRects[0].width, 0.0f); + + EXPECT_EQ( + FindUIEditorPropertyGridVisibleFieldIndex(layout, "position", sections), + 0u); + EXPECT_EQ( + FindUIEditorPropertyGridVisibleFieldIndex(layout, "material", sections), + static_cast(-1)); +} + +TEST(UIEditorPropertyGridTest, HitTestResolvesHeaderRowAndValueBox) { + const auto sections = BuildSections(); + UIExpansionModel expansionModel = {}; + expansionModel.Expand("transform"); + expansionModel.Expand("rendering"); + + const auto layout = BuildUIEditorPropertyGridLayout( + UIRect(0.0f, 0.0f, 420.0f, 320.0f), + sections, + expansionModel); + + const auto headerHit = + HitTestUIEditorPropertyGrid(layout, RectCenter(layout.sectionHeaderRects[0])); + EXPECT_EQ(headerHit.kind, UIEditorPropertyGridHitTargetKind::SectionHeader); + EXPECT_EQ(headerHit.sectionIndex, 0u); + + const auto rowHit = HitTestUIEditorPropertyGrid( + layout, + UIPoint( + layout.fieldRowRects[1].x + 16.0f, + layout.fieldRowRects[1].y + layout.fieldRowRects[1].height * 0.5f)); + EXPECT_EQ(rowHit.kind, UIEditorPropertyGridHitTargetKind::FieldRow); + EXPECT_EQ(rowHit.sectionIndex, 0u); + EXPECT_EQ(rowHit.fieldIndex, 1u); + + const auto valueHit = + HitTestUIEditorPropertyGrid(layout, RectCenter(layout.fieldValueRects[2])); + EXPECT_EQ(valueHit.kind, UIEditorPropertyGridHitTargetKind::ValueBox); + EXPECT_EQ(valueHit.sectionIndex, 1u); + EXPECT_EQ(valueHit.fieldIndex, 0u); +} + +TEST(UIEditorPropertyGridTest, BackgroundAndForegroundEmitStableCommands) { + const auto sections = BuildSections(); + UISelectionModel selectionModel = {}; + selectionModel.SetSelection("rotation"); + UIExpansionModel expansionModel = {}; + expansionModel.Expand("transform"); + expansionModel.Expand("rendering"); + UIPropertyEditModel propertyEditModel = {}; + propertyEditModel.BeginEdit("material", "Metal"); + propertyEditModel.UpdateStagedValue("Mat_Inst"); + UIEditorPropertyGridState state = {}; + state.focused = true; + state.hoveredFieldId = "position"; + + const auto layout = BuildUIEditorPropertyGridLayout( + UIRect(0.0f, 0.0f, 420.0f, 320.0f), + sections, + expansionModel); + + XCEngine::UI::UIDrawData drawData = {}; + auto& drawList = drawData.EmplaceDrawList("PropertyGrid"); + AppendUIEditorPropertyGridBackground( + drawList, + layout, + sections, + selectionModel, + propertyEditModel, + state); + AppendUIEditorPropertyGridForeground( + drawList, + layout, + sections, + propertyEditModel); + + const auto& commands = drawList.GetCommands(); + ASSERT_GE(commands.size(), 12u); + EXPECT_EQ(commands[0].type, XCEngine::UI::UIDrawCommandType::FilledRect); + EXPECT_EQ(commands[1].type, XCEngine::UI::UIDrawCommandType::RectOutline); + EXPECT_TRUE(ContainsTextCommand(drawData, "Transform")); + EXPECT_TRUE(ContainsTextCommand(drawData, "Position")); + EXPECT_TRUE(ContainsTextCommand(drawData, "Mat_Inst")); + EXPECT_TRUE(ContainsTextCommand(drawData, "EDIT")); +} diff --git a/tests/UI/Editor/unit/test_ui_editor_property_grid_interaction.cpp b/tests/UI/Editor/unit/test_ui_editor_property_grid_interaction.cpp new file mode 100644 index 00000000..eee58050 --- /dev/null +++ b/tests/UI/Editor/unit/test_ui_editor_property_grid_interaction.cpp @@ -0,0 +1,368 @@ +#include + +#include + +#include + +namespace { + +using XCEngine::Input::KeyCode; +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::UIPropertyEditModel; +using XCEngine::UI::Widgets::UISelectionModel; +using XCEngine::UI::Editor::UIEditorPropertyGridInteractionState; +using XCEngine::UI::Editor::UpdateUIEditorPropertyGridInteraction; +using XCEngine::UI::Editor::Widgets::UIEditorPropertyGridSection; + +std::vector BuildSections() { + return { + { + "transform", + "Transform", + { + { "position", "Position", "10", false, 0.0f }, + { "rotation", "Rotation", "0", false, 0.0f } + }, + 0.0f + }, + { + "metadata", + "Metadata", + { + { "tag", "Tag", "", false, 0.0f }, + { "guid", "GUID", "asset-guid-001", true, 0.0f } + }, + 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 MakeKeyDown(KeyCode keyCode) { + UIInputEvent event = {}; + event.type = UIInputEventType::KeyDown; + event.keyCode = static_cast(keyCode); + return event; +} + +UIInputEvent MakeCharacter(char character) { + UIInputEvent event = {}; + event.type = UIInputEventType::Character; + event.character = static_cast(character); + return event; +} + +UIPoint RectCenter(const XCEngine::UI::UIRect& rect) { + return UIPoint(rect.x + rect.width * 0.5f, rect.y + rect.height * 0.5f); +} + +void ExpandAll(UIExpansionModel& expansionModel) { + expansionModel.Expand("transform"); + expansionModel.Expand("metadata"); +} + +} // namespace + +TEST(UIEditorPropertyGridInteractionTest, PointerMoveUpdatesHoveredSectionAndField) { + const auto sections = BuildSections(); + UISelectionModel selectionModel = {}; + UIExpansionModel expansionModel = {}; + ExpandAll(expansionModel); + UIPropertyEditModel propertyEditModel = {}; + UIEditorPropertyGridInteractionState state = {}; + + const auto initialFrame = UpdateUIEditorPropertyGridInteraction( + state, + selectionModel, + expansionModel, + propertyEditModel, + UIRect(0.0f, 0.0f, 420.0f, 320.0f), + sections, + {}); + + auto frame = UpdateUIEditorPropertyGridInteraction( + state, + selectionModel, + expansionModel, + propertyEditModel, + UIRect(0.0f, 0.0f, 420.0f, 320.0f), + sections, + { MakePointerMove( + initialFrame.layout.sectionHeaderRects[0].x + 24.0f, + initialFrame.layout.sectionHeaderRects[0].y + 16.0f) }); + EXPECT_EQ(state.propertyGridState.hoveredSectionId, "transform"); + EXPECT_TRUE(state.propertyGridState.hoveredFieldId.empty()); + + frame = UpdateUIEditorPropertyGridInteraction( + state, + selectionModel, + expansionModel, + propertyEditModel, + UIRect(0.0f, 0.0f, 420.0f, 320.0f), + sections, + { MakePointerMove( + initialFrame.layout.fieldRowRects[1].x + 16.0f, + initialFrame.layout.fieldRowRects[1].y + 16.0f) }); + EXPECT_EQ(frame.result.hitTarget.fieldIndex, 1u); + EXPECT_EQ(state.propertyGridState.hoveredFieldId, "rotation"); +} + +TEST(UIEditorPropertyGridInteractionTest, LeftClickSectionHeaderTogglesExpansion) { + const auto sections = BuildSections(); + UISelectionModel selectionModel = {}; + UIExpansionModel expansionModel = {}; + expansionModel.Expand("transform"); + UIPropertyEditModel propertyEditModel = {}; + UIEditorPropertyGridInteractionState state = {}; + + const auto initialFrame = UpdateUIEditorPropertyGridInteraction( + state, + selectionModel, + expansionModel, + propertyEditModel, + UIRect(0.0f, 0.0f, 420.0f, 320.0f), + sections, + {}); + const UIPoint metadataHeaderCenter = RectCenter(initialFrame.layout.sectionHeaderRects[1]); + + const auto frame = UpdateUIEditorPropertyGridInteraction( + state, + selectionModel, + expansionModel, + propertyEditModel, + UIRect(0.0f, 0.0f, 420.0f, 320.0f), + sections, + { + MakePointerDown(metadataHeaderCenter.x, metadataHeaderCenter.y), + MakePointerUp(metadataHeaderCenter.x, metadataHeaderCenter.y) + }); + + EXPECT_TRUE(frame.result.sectionToggled); + EXPECT_EQ(frame.result.toggledSectionId, "metadata"); + EXPECT_TRUE(expansionModel.IsExpanded("metadata")); + EXPECT_TRUE(state.propertyGridState.focused); +} + +TEST(UIEditorPropertyGridInteractionTest, LeftClickFieldRowSelectsFieldAndFocusesGrid) { + const auto sections = BuildSections(); + UISelectionModel selectionModel = {}; + UIExpansionModel expansionModel = {}; + ExpandAll(expansionModel); + UIPropertyEditModel propertyEditModel = {}; + UIEditorPropertyGridInteractionState state = {}; + + const auto initialFrame = UpdateUIEditorPropertyGridInteraction( + state, + selectionModel, + expansionModel, + propertyEditModel, + UIRect(0.0f, 0.0f, 420.0f, 320.0f), + sections, + {}); + const UIPoint rowCenter = RectCenter(initialFrame.layout.fieldRowRects[1]); + + const auto frame = UpdateUIEditorPropertyGridInteraction( + state, + selectionModel, + expansionModel, + propertyEditModel, + UIRect(0.0f, 0.0f, 420.0f, 320.0f), + sections, + { + MakePointerDown(rowCenter.x, rowCenter.y), + MakePointerUp(rowCenter.x, rowCenter.y) + }); + + EXPECT_TRUE(frame.result.consumed); + EXPECT_TRUE(frame.result.selectionChanged); + EXPECT_EQ(frame.result.selectedFieldId, "rotation"); + EXPECT_TRUE(selectionModel.IsSelected("rotation")); + EXPECT_TRUE(state.propertyGridState.focused); +} + +TEST(UIEditorPropertyGridInteractionTest, ValueBoxEditCanCommitWithEnter) { + const auto sections = BuildSections(); + UISelectionModel selectionModel = {}; + UIExpansionModel expansionModel = {}; + ExpandAll(expansionModel); + UIPropertyEditModel propertyEditModel = {}; + UIEditorPropertyGridInteractionState state = {}; + + const auto initialFrame = UpdateUIEditorPropertyGridInteraction( + state, + selectionModel, + expansionModel, + propertyEditModel, + UIRect(0.0f, 0.0f, 420.0f, 320.0f), + sections, + {}); + const UIPoint tagValueCenter = RectCenter(initialFrame.layout.fieldValueRects[2]); + + auto frame = UpdateUIEditorPropertyGridInteraction( + state, + selectionModel, + expansionModel, + propertyEditModel, + UIRect(0.0f, 0.0f, 420.0f, 320.0f), + sections, + { + MakePointerDown(tagValueCenter.x, tagValueCenter.y), + MakePointerUp(tagValueCenter.x, tagValueCenter.y) + }); + EXPECT_TRUE(frame.result.consumed); + EXPECT_EQ(frame.result.selectedFieldId, "tag"); + EXPECT_EQ(frame.result.activeFieldId, "tag"); + EXPECT_TRUE(propertyEditModel.HasActiveEdit()); + + frame = UpdateUIEditorPropertyGridInteraction( + state, + selectionModel, + expansionModel, + propertyEditModel, + UIRect(0.0f, 0.0f, 420.0f, 320.0f), + sections, + { + MakeCharacter('A'), + MakeCharacter('B'), + MakeKeyDown(KeyCode::Enter) + }); + + EXPECT_TRUE(frame.result.editCommitted); + EXPECT_EQ(frame.result.committedFieldId, "tag"); + EXPECT_EQ(frame.result.committedValue, "AB"); + EXPECT_FALSE(propertyEditModel.HasActiveEdit()); +} + +TEST(UIEditorPropertyGridInteractionTest, EscapeCancelsEditAndOutsideClickClearsFocus) { + const auto sections = BuildSections(); + UISelectionModel selectionModel = {}; + UIExpansionModel expansionModel = {}; + ExpandAll(expansionModel); + UIPropertyEditModel propertyEditModel = {}; + UIEditorPropertyGridInteractionState state = {}; + + const auto initialFrame = UpdateUIEditorPropertyGridInteraction( + state, + selectionModel, + expansionModel, + propertyEditModel, + UIRect(0.0f, 0.0f, 420.0f, 320.0f), + sections, + {}); + const UIPoint tagValueCenter = RectCenter(initialFrame.layout.fieldValueRects[2]); + + UpdateUIEditorPropertyGridInteraction( + state, + selectionModel, + expansionModel, + propertyEditModel, + UIRect(0.0f, 0.0f, 420.0f, 320.0f), + sections, + { + MakePointerDown(tagValueCenter.x, tagValueCenter.y), + MakePointerUp(tagValueCenter.x, tagValueCenter.y) + }); + + auto frame = UpdateUIEditorPropertyGridInteraction( + state, + selectionModel, + expansionModel, + propertyEditModel, + UIRect(0.0f, 0.0f, 420.0f, 320.0f), + sections, + { + MakeCharacter('A'), + MakeKeyDown(KeyCode::Escape) + }); + EXPECT_TRUE(frame.result.editCanceled); + EXPECT_FALSE(propertyEditModel.HasActiveEdit()); + EXPECT_TRUE(selectionModel.IsSelected("tag")); + + frame = UpdateUIEditorPropertyGridInteraction( + state, + selectionModel, + expansionModel, + propertyEditModel, + UIRect(0.0f, 0.0f, 420.0f, 320.0f), + sections, + { + MakePointerDown(520.0f, 360.0f), + MakePointerUp(520.0f, 360.0f) + }); + EXPECT_FALSE(state.propertyGridState.focused); + EXPECT_TRUE(selectionModel.IsSelected("tag")); +} + +TEST(UIEditorPropertyGridInteractionTest, ArrowAndHomeEndKeysNavigateVisibleFields) { + const auto sections = BuildSections(); + UISelectionModel selectionModel = {}; + selectionModel.SetSelection("rotation"); + UIExpansionModel expansionModel = {}; + ExpandAll(expansionModel); + UIPropertyEditModel propertyEditModel = {}; + UIEditorPropertyGridInteractionState state = {}; + state.propertyGridState.focused = true; + + auto frame = UpdateUIEditorPropertyGridInteraction( + state, + selectionModel, + expansionModel, + propertyEditModel, + UIRect(0.0f, 0.0f, 420.0f, 320.0f), + sections, + { MakeKeyDown(KeyCode::Down) }); + EXPECT_TRUE(frame.result.keyboardNavigated); + EXPECT_EQ(frame.result.selectedFieldId, "tag"); + EXPECT_TRUE(selectionModel.IsSelected("tag")); + + frame = UpdateUIEditorPropertyGridInteraction( + state, + selectionModel, + expansionModel, + propertyEditModel, + UIRect(0.0f, 0.0f, 420.0f, 320.0f), + sections, + { MakeKeyDown(KeyCode::Home) }); + EXPECT_TRUE(frame.result.keyboardNavigated); + EXPECT_EQ(frame.result.selectedFieldId, "position"); + EXPECT_TRUE(selectionModel.IsSelected("position")); + + frame = UpdateUIEditorPropertyGridInteraction( + state, + selectionModel, + expansionModel, + propertyEditModel, + UIRect(0.0f, 0.0f, 420.0f, 320.0f), + sections, + { MakeKeyDown(KeyCode::End) }); + EXPECT_TRUE(frame.result.keyboardNavigated); + EXPECT_EQ(frame.result.selectedFieldId, "guid"); + EXPECT_TRUE(selectionModel.IsSelected("guid")); +} diff --git a/tests/UI/Editor/unit/test_ui_editor_scroll_view.cpp b/tests/UI/Editor/unit/test_ui_editor_scroll_view.cpp new file mode 100644 index 00000000..f7751f64 --- /dev/null +++ b/tests/UI/Editor/unit/test_ui_editor_scroll_view.cpp @@ -0,0 +1,101 @@ +#include + +#include +#include + +namespace { + +using XCEngine::UI::UIDrawCommandType; +using XCEngine::UI::UIDrawList; +using XCEngine::UI::UIPoint; +using XCEngine::UI::UIRect; +using XCEngine::UI::Editor::Widgets::AppendUIEditorScrollViewBackground; +using XCEngine::UI::Editor::Widgets::BuildUIEditorScrollViewLayout; +using XCEngine::UI::Editor::Widgets::ClampUIEditorScrollViewOffset; +using XCEngine::UI::Editor::Widgets::HitTestUIEditorScrollView; +using XCEngine::UI::Editor::Widgets::ResolveUIEditorScrollViewContentOrigin; +using XCEngine::UI::Editor::Widgets::UIEditorScrollViewHitTargetKind; +using XCEngine::UI::Editor::Widgets::UIEditorScrollViewLayout; +using XCEngine::UI::Editor::Widgets::UIEditorScrollViewMetrics; +using XCEngine::UI::Editor::Widgets::UIEditorScrollViewState; + +TEST(UIEditorScrollViewTest, LayoutReservesScrollbarTrackAndThumbFromOverflow) { + UIEditorScrollViewMetrics metrics = {}; + metrics.scrollbarWidth = 12.0f; + metrics.scrollbarInset = 4.0f; + metrics.minThumbHeight = 24.0f; + + const UIEditorScrollViewLayout layout = + BuildUIEditorScrollViewLayout(UIRect(10.0f, 20.0f, 240.0f, 120.0f), 360.0f, 60.0f, metrics); + + EXPECT_TRUE(layout.hasScrollbar); + EXPECT_FLOAT_EQ(layout.maxOffset, 240.0f); + EXPECT_FLOAT_EQ(layout.verticalOffset, 60.0f); + EXPECT_FLOAT_EQ(layout.contentRect.width, 220.0f); + EXPECT_FLOAT_EQ(layout.scrollbarTrackRect.x, 234.0f); + EXPECT_FLOAT_EQ(layout.scrollbarTrackRect.height, 112.0f); + EXPECT_NEAR(layout.scrollbarThumbRect.y, 42.666f, 0.01f); + EXPECT_NEAR(layout.scrollbarThumbRect.height, 37.333f, 0.01f); + + const UIPoint contentOrigin = ResolveUIEditorScrollViewContentOrigin(layout); + EXPECT_FLOAT_EQ(contentOrigin.x, layout.contentRect.x); + EXPECT_FLOAT_EQ(contentOrigin.y, layout.contentRect.y - 60.0f); +} + +TEST(UIEditorScrollViewTest, ClampAndNoScrollbarWhenContentFitsViewport) { + const UIEditorScrollViewLayout layout = + BuildUIEditorScrollViewLayout(UIRect(0.0f, 0.0f, 200.0f, 100.0f), 80.0f, 42.0f); + + EXPECT_FALSE(layout.hasScrollbar); + EXPECT_FLOAT_EQ(layout.maxOffset, 0.0f); + EXPECT_FLOAT_EQ(layout.verticalOffset, 0.0f); + EXPECT_FLOAT_EQ(layout.contentRect.width, 200.0f); + EXPECT_FLOAT_EQ( + ClampUIEditorScrollViewOffset(UIRect(0.0f, 0.0f, 200.0f, 100.0f), 320.0f, 400.0f), + 220.0f); +} + +TEST(UIEditorScrollViewTest, HitTestPrioritizesThumbThenTrackThenContent) { + const UIEditorScrollViewLayout layout = + BuildUIEditorScrollViewLayout(UIRect(10.0f, 20.0f, 240.0f, 120.0f), 360.0f, 60.0f); + + EXPECT_EQ( + HitTestUIEditorScrollView( + layout, + UIPoint( + layout.scrollbarThumbRect.x + 4.0f, + layout.scrollbarThumbRect.y + 4.0f)).kind, + UIEditorScrollViewHitTargetKind::ScrollbarThumb); + EXPECT_EQ( + HitTestUIEditorScrollView( + layout, + UIPoint( + layout.scrollbarTrackRect.x + 4.0f, + layout.scrollbarTrackRect.y + layout.scrollbarTrackRect.height - 4.0f)).kind, + UIEditorScrollViewHitTargetKind::ScrollbarTrack); + EXPECT_EQ( + HitTestUIEditorScrollView(layout, UIPoint(layout.contentRect.x + 12.0f, layout.contentRect.y + 12.0f)).kind, + UIEditorScrollViewHitTargetKind::Content); + EXPECT_EQ( + HitTestUIEditorScrollView(layout, UIPoint(4.0f, 8.0f)).kind, + UIEditorScrollViewHitTargetKind::None); +} + +TEST(UIEditorScrollViewTest, BackgroundCommandsEmitStableChrome) { + const UIEditorScrollViewLayout layout = + BuildUIEditorScrollViewLayout(UIRect(0.0f, 0.0f, 220.0f, 120.0f), 320.0f, 80.0f); + UIEditorScrollViewState state = {}; + state.focused = true; + state.scrollbarHovered = true; + + UIDrawList drawList("ScrollView"); + AppendUIEditorScrollViewBackground(drawList, layout, state); + + ASSERT_EQ(drawList.GetCommandCount(), 4u); + EXPECT_EQ(drawList.GetCommands()[0].type, UIDrawCommandType::FilledRect); + EXPECT_EQ(drawList.GetCommands()[1].type, UIDrawCommandType::RectOutline); + EXPECT_EQ(drawList.GetCommands()[2].type, UIDrawCommandType::FilledRect); + EXPECT_EQ(drawList.GetCommands()[3].type, UIDrawCommandType::FilledRect); +} + +} // namespace diff --git a/tests/UI/Editor/unit/test_ui_editor_scroll_view_interaction.cpp b/tests/UI/Editor/unit/test_ui_editor_scroll_view_interaction.cpp new file mode 100644 index 00000000..7d172b64 --- /dev/null +++ b/tests/UI/Editor/unit/test_ui_editor_scroll_view_interaction.cpp @@ -0,0 +1,176 @@ +#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::Editor::UIEditorScrollViewInteractionState; +using XCEngine::UI::Editor::UpdateUIEditorScrollViewInteraction; +using XCEngine::UI::Editor::Widgets::UIEditorScrollViewHitTargetKind; + +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) { + UIInputEvent event = {}; + event.type = UIInputEventType::PointerButtonDown; + event.position = UIPoint(x, y); + event.pointerButton = UIPointerButton::Left; + return event; +} + +UIInputEvent MakePointerUp(float x, float y) { + UIInputEvent event = {}; + event.type = UIInputEventType::PointerButtonUp; + event.position = UIPoint(x, y); + event.pointerButton = UIPointerButton::Left; + return event; +} + +UIInputEvent MakePointerWheel(float x, float y, float wheelDelta) { + UIInputEvent event = {}; + event.type = UIInputEventType::PointerWheel; + event.position = UIPoint(x, y); + event.wheelDelta = wheelDelta; + return event; +} + +UIInputEvent MakeFocusLost() { + UIInputEvent event = {}; + event.type = UIInputEventType::FocusLost; + return event; +} + +TEST(UIEditorScrollViewInteractionTest, WheelInsideViewportUpdatesOffsetAndClamps) { + UIEditorScrollViewInteractionState state = {}; + float verticalOffset = 0.0f; + + auto frame = UpdateUIEditorScrollViewInteraction( + state, + verticalOffset, + UIRect(0.0f, 0.0f, 200.0f, 100.0f), + 360.0f, + { MakePointerWheel(24.0f, 32.0f, -1.0f) }); + EXPECT_TRUE(frame.result.consumed); + EXPECT_TRUE(frame.result.offsetChanged); + EXPECT_FLOAT_EQ(verticalOffset, 48.0f); + EXPECT_EQ(frame.result.hitTarget.kind, UIEditorScrollViewHitTargetKind::Content); + + frame = UpdateUIEditorScrollViewInteraction( + state, + verticalOffset, + UIRect(0.0f, 0.0f, 200.0f, 100.0f), + 360.0f, + { MakePointerWheel(24.0f, 32.0f, -20.0f) }); + EXPECT_TRUE(frame.result.offsetChanged); + EXPECT_FLOAT_EQ(verticalOffset, 260.0f); +} + +TEST(UIEditorScrollViewInteractionTest, ThumbDragUpdatesOffsetAcrossPointerMoves) { + UIEditorScrollViewInteractionState state = {}; + float verticalOffset = 0.0f; + + const auto initialFrame = UpdateUIEditorScrollViewInteraction( + state, + verticalOffset, + UIRect(0.0f, 0.0f, 220.0f, 120.0f), + 420.0f, + {}); + const float thumbX = initialFrame.layout.scrollbarThumbRect.x + 4.0f; + const float thumbY = initialFrame.layout.scrollbarThumbRect.y + 8.0f; + + auto frame = UpdateUIEditorScrollViewInteraction( + state, + verticalOffset, + UIRect(0.0f, 0.0f, 220.0f, 120.0f), + 420.0f, + { MakePointerDown(thumbX, thumbY) }); + EXPECT_TRUE(frame.result.startedThumbDrag); + EXPECT_TRUE(state.scrollViewState.draggingScrollbarThumb); + + frame = UpdateUIEditorScrollViewInteraction( + state, + verticalOffset, + UIRect(0.0f, 0.0f, 220.0f, 120.0f), + 420.0f, + { MakePointerMove(thumbX, thumbY + 40.0f) }); + EXPECT_TRUE(frame.result.offsetChanged); + EXPECT_GT(verticalOffset, 0.0f); + + frame = UpdateUIEditorScrollViewInteraction( + state, + verticalOffset, + UIRect(0.0f, 0.0f, 220.0f, 120.0f), + 420.0f, + { MakePointerUp(thumbX, thumbY + 40.0f) }); + EXPECT_TRUE(frame.result.endedThumbDrag); + EXPECT_FALSE(state.scrollViewState.draggingScrollbarThumb); +} + +TEST(UIEditorScrollViewInteractionTest, ContentClickFocusesAndOutsideClickClearsFocus) { + UIEditorScrollViewInteractionState state = {}; + float verticalOffset = 0.0f; + + auto frame = UpdateUIEditorScrollViewInteraction( + state, + verticalOffset, + UIRect(10.0f, 20.0f, 220.0f, 120.0f), + 420.0f, + { MakePointerDown(36.0f, 48.0f) }); + EXPECT_TRUE(frame.result.focusChanged); + EXPECT_TRUE(state.scrollViewState.focused); + EXPECT_EQ(frame.result.hitTarget.kind, UIEditorScrollViewHitTargetKind::Content); + + frame = UpdateUIEditorScrollViewInteraction( + state, + verticalOffset, + UIRect(10.0f, 20.0f, 220.0f, 120.0f), + 420.0f, + { MakePointerDown(300.0f, 220.0f) }); + EXPECT_TRUE(frame.result.focusChanged); + EXPECT_FALSE(state.scrollViewState.focused); +} + +TEST(UIEditorScrollViewInteractionTest, FocusLostClearsHoverAndThumbDragState) { + UIEditorScrollViewInteractionState state = {}; + float verticalOffset = 0.0f; + + const auto initialFrame = UpdateUIEditorScrollViewInteraction( + state, + verticalOffset, + UIRect(0.0f, 0.0f, 220.0f, 120.0f), + 420.0f, + {}); + const float thumbX = initialFrame.layout.scrollbarThumbRect.x + 4.0f; + const float thumbY = initialFrame.layout.scrollbarThumbRect.y + 8.0f; + + UpdateUIEditorScrollViewInteraction( + state, + verticalOffset, + UIRect(0.0f, 0.0f, 220.0f, 120.0f), + 420.0f, + { MakePointerDown(thumbX, thumbY) }); + ASSERT_TRUE(state.scrollViewState.draggingScrollbarThumb); + + const auto frame = UpdateUIEditorScrollViewInteraction( + state, + verticalOffset, + UIRect(0.0f, 0.0f, 220.0f, 120.0f), + 420.0f, + { MakeFocusLost() }); + EXPECT_TRUE(frame.result.focusChanged); + EXPECT_FALSE(state.scrollViewState.focused); + EXPECT_FALSE(state.scrollViewState.draggingScrollbarThumb); + EXPECT_FALSE(state.hasPointerPosition); +} + +} // namespace