From 620717f8b44d32b1a9856b000bf36c7cc37c7b97 Mon Sep 17 00:00:00 2001 From: ssdfasd <2156608475@qq.com> Date: Mon, 6 Apr 2026 22:32:40 +0800 Subject: [PATCH] Add core popup overlay primitive --- engine/CMakeLists.txt | 2 + .../XCEngine/UI/Widgets/UIPopupOverlayModel.h | 113 +++++ engine/src/UI/Widgets/UIPopupOverlayModel.cpp | 303 +++++++++++ .../UI/Core/integration/input/CMakeLists.txt | 2 + .../input/popup_menu_overlay/CMakeLists.txt | 35 ++ .../input/popup_menu_overlay/README.md | 10 + .../input/popup_menu_overlay/View.xcui | 18 + .../input/popup_menu_overlay/main.cpp | 8 + .../UI/Core/integration/shared/CMakeLists.txt | 1 + .../integration/shared/src/Application.cpp | 17 +- .../Core/integration/shared/src/Application.h | 2 + .../shared/src/CoreValidationScenario.cpp | 13 +- .../src/PopupMenuOverlayValidationScene.cpp | 471 ++++++++++++++++++ .../src/PopupMenuOverlayValidationScene.h | 69 +++ tests/UI/Core/unit/CMakeLists.txt | 1 + .../Core/unit/test_ui_popup_overlay_model.cpp | 200 ++++++++ 16 files changed, 1261 insertions(+), 4 deletions(-) create mode 100644 engine/include/XCEngine/UI/Widgets/UIPopupOverlayModel.h create mode 100644 engine/src/UI/Widgets/UIPopupOverlayModel.cpp create mode 100644 tests/UI/Core/integration/input/popup_menu_overlay/CMakeLists.txt create mode 100644 tests/UI/Core/integration/input/popup_menu_overlay/README.md create mode 100644 tests/UI/Core/integration/input/popup_menu_overlay/View.xcui create mode 100644 tests/UI/Core/integration/input/popup_menu_overlay/main.cpp create mode 100644 tests/UI/Core/integration/shared/src/PopupMenuOverlayValidationScene.cpp create mode 100644 tests/UI/Core/integration/shared/src/PopupMenuOverlayValidationScene.h create mode 100644 tests/UI/Core/unit/test_ui_popup_overlay_model.cpp diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt index 4d02b455..1408c24c 100644 --- a/engine/CMakeLists.txt +++ b/engine/CMakeLists.txt @@ -547,11 +547,13 @@ add_library(XCEngine STATIC ${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Text/UITextInputController.cpp ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Widgets/UIExpansionModel.h ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Widgets/UIKeyboardNavigationModel.h + ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Widgets/UIPopupOverlayModel.h ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Widgets/UIPropertyEditModel.h ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Widgets/UISelectionModel.h ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Widgets/UITabStripModel.h ${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Widgets/UIExpansionModel.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Widgets/UIKeyboardNavigationModel.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Widgets/UIPopupOverlayModel.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Widgets/UIPropertyEditModel.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Widgets/UISelectionModel.cpp ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Runtime/UIScreenTypes.h diff --git a/engine/include/XCEngine/UI/Widgets/UIPopupOverlayModel.h b/engine/include/XCEngine/UI/Widgets/UIPopupOverlayModel.h new file mode 100644 index 00000000..a8524352 --- /dev/null +++ b/engine/include/XCEngine/UI/Widgets/UIPopupOverlayModel.h @@ -0,0 +1,113 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include + +namespace XCEngine { +namespace UI { +namespace Widgets { + +enum class UIPopupPlacement : std::uint8_t { + BottomStart = 0, + BottomEnd, + TopStart, + TopEnd, + RightStart, + RightEnd, + LeftStart, + LeftEnd +}; + +enum class UIPopupDismissReason : std::uint8_t { + None = 0, + Programmatic, + EscapeKey, + PointerOutside, + FocusLoss +}; + +struct UIPopupPlacementResult { + UIRect rect = {}; + UIPopupPlacement effectivePlacement = UIPopupPlacement::BottomStart; + bool clampedX = false; + bool clampedY = false; +}; + +struct UIPopupOverlayEntry { + std::string popupId = {}; + std::string parentPopupId = {}; + UIRect anchorRect = {}; + UIInputPath anchorPath = {}; + UIInputPath surfacePath = {}; + UIPopupPlacement placement = UIPopupPlacement::BottomStart; + bool dismissOnPointerOutside = true; + bool dismissOnEscape = true; + bool dismissOnFocusLoss = true; +}; + +struct UIPopupOverlayMutationResult { + bool changed = false; + std::string openedPopupId = {}; + std::vector closedPopupIds = {}; + UIPopupDismissReason dismissReason = UIPopupDismissReason::None; +}; + +UIPopupPlacementResult ResolvePopupPlacementRect( + const UIRect& anchorRect, + const UISize& popupSize, + const UIRect& viewportRect, + UIPopupPlacement placement); + +class UIPopupOverlayModel { +public: + static constexpr std::size_t InvalidIndex = static_cast(-1); + + const std::vector& GetPopupChain() const { + return m_popupChain; + } + + bool HasOpenPopups() const { + return !m_popupChain.empty(); + } + + std::size_t GetPopupCount() const { + return m_popupChain.size(); + } + + const UIPopupOverlayEntry* GetRootPopup() const; + const UIPopupOverlayEntry* GetTopmostPopup() const; + const UIPopupOverlayEntry* FindPopup(std::string_view popupId) const; + + UIPopupOverlayMutationResult OpenPopup(UIPopupOverlayEntry entry); + UIPopupOverlayMutationResult ClosePopup( + std::string_view popupId, + UIPopupDismissReason dismissReason = UIPopupDismissReason::Programmatic); + UIPopupOverlayMutationResult CloseAll( + UIPopupDismissReason dismissReason = UIPopupDismissReason::Programmatic); + UIPopupOverlayMutationResult DismissFromEscape(); + UIPopupOverlayMutationResult DismissFromPointerDown(const UIInputPath& hitPath); + UIPopupOverlayMutationResult DismissFromFocusLoss(const UIInputPath& focusedPath); + +private: + std::size_t FindPopupIndex(std::string_view popupId) const; + std::size_t FindDeepestContainingPopupIndex(const UIInputPath& path) const; + UIPopupOverlayMutationResult CloseFromIndex( + std::size_t index, + UIPopupDismissReason dismissReason); + + static bool PopupContainsPath( + const UIPopupOverlayEntry& entry, + const UIInputPath& path); + + std::vector m_popupChain = {}; +}; + +} // namespace Widgets +} // namespace UI +} // namespace XCEngine diff --git a/engine/src/UI/Widgets/UIPopupOverlayModel.cpp b/engine/src/UI/Widgets/UIPopupOverlayModel.cpp new file mode 100644 index 00000000..0e0f2dd1 --- /dev/null +++ b/engine/src/UI/Widgets/UIPopupOverlayModel.cpp @@ -0,0 +1,303 @@ +#include + +#include +#include + +namespace XCEngine { +namespace UI { +namespace Widgets { + +namespace { + +float ClampNonNegative(float value) { + return (std::max)(0.0f, value); +} + +float GetRectRight(const UIRect& rect) { + return rect.x + ClampNonNegative(rect.width); +} + +float GetRectBottom(const UIRect& rect) { + return rect.y + ClampNonNegative(rect.height); +} + +bool IsPathPrefix(const UIInputPath& prefix, const UIInputPath& path) { + if (prefix.Empty() || prefix.Size() > path.Size()) { + return false; + } + + return GetUIInputPathCommonPrefixLength(prefix, path) == prefix.Size(); +} + +UIPopupPlacement FlipPlacement(UIPopupPlacement placement) { + switch (placement) { + case UIPopupPlacement::BottomStart: + return UIPopupPlacement::TopStart; + case UIPopupPlacement::BottomEnd: + return UIPopupPlacement::TopEnd; + case UIPopupPlacement::TopStart: + return UIPopupPlacement::BottomStart; + case UIPopupPlacement::TopEnd: + return UIPopupPlacement::BottomEnd; + case UIPopupPlacement::RightStart: + return UIPopupPlacement::LeftStart; + case UIPopupPlacement::RightEnd: + return UIPopupPlacement::LeftEnd; + case UIPopupPlacement::LeftStart: + return UIPopupPlacement::RightStart; + case UIPopupPlacement::LeftEnd: + return UIPopupPlacement::RightEnd; + default: + return placement; + } +} + +bool CanPlace(const UIRect& anchorRect, const UISize& popupSize, const UIRect& viewportRect, UIPopupPlacement placement) { + const float popupWidth = ClampNonNegative(popupSize.width); + const float popupHeight = ClampNonNegative(popupSize.height); + const float viewportRight = GetRectRight(viewportRect); + const float viewportBottom = GetRectBottom(viewportRect); + + switch (placement) { + case UIPopupPlacement::BottomStart: + case UIPopupPlacement::BottomEnd: + return GetRectBottom(anchorRect) + popupHeight <= viewportBottom; + case UIPopupPlacement::TopStart: + case UIPopupPlacement::TopEnd: + return anchorRect.y - popupHeight >= viewportRect.y; + case UIPopupPlacement::RightStart: + case UIPopupPlacement::RightEnd: + return GetRectRight(anchorRect) + popupWidth <= viewportRight; + case UIPopupPlacement::LeftStart: + case UIPopupPlacement::LeftEnd: + return anchorRect.x - popupWidth >= viewportRect.x; + default: + return true; + } +} + +UIPoint ResolvePlacementOrigin( + const UIRect& anchorRect, + const UISize& popupSize, + UIPopupPlacement placement) { + const float popupWidth = ClampNonNegative(popupSize.width); + const float popupHeight = ClampNonNegative(popupSize.height); + + switch (placement) { + case UIPopupPlacement::BottomStart: + return UIPoint(anchorRect.x, GetRectBottom(anchorRect)); + case UIPopupPlacement::BottomEnd: + return UIPoint(GetRectRight(anchorRect) - popupWidth, GetRectBottom(anchorRect)); + case UIPopupPlacement::TopStart: + return UIPoint(anchorRect.x, anchorRect.y - popupHeight); + case UIPopupPlacement::TopEnd: + return UIPoint(GetRectRight(anchorRect) - popupWidth, anchorRect.y - popupHeight); + case UIPopupPlacement::RightStart: + return UIPoint(GetRectRight(anchorRect), anchorRect.y); + case UIPopupPlacement::RightEnd: + return UIPoint(GetRectRight(anchorRect), GetRectBottom(anchorRect) - popupHeight); + case UIPopupPlacement::LeftStart: + return UIPoint(anchorRect.x - popupWidth, anchorRect.y); + case UIPopupPlacement::LeftEnd: + return UIPoint(anchorRect.x - popupWidth, GetRectBottom(anchorRect) - popupHeight); + default: + return UIPoint(anchorRect.x, GetRectBottom(anchorRect)); + } +} + +float ClampRectOrigin(float origin, float viewportStart, float viewportExtent, float rectExtent, bool& clamped) { + const float safeViewportExtent = ClampNonNegative(viewportExtent); + const float safeRectExtent = ClampNonNegative(rectExtent); + const float minOrigin = viewportStart; + const float maxOrigin = (std::max)(minOrigin, viewportStart + safeViewportExtent - safeRectExtent); + const float clampedOrigin = (std::clamp)(origin, minOrigin, maxOrigin); + clamped = clampedOrigin != origin; + return clampedOrigin; +} + +} // namespace + +UIPopupPlacementResult ResolvePopupPlacementRect( + const UIRect& anchorRect, + const UISize& popupSize, + const UIRect& viewportRect, + UIPopupPlacement placement) { + UIPopupPlacementResult result = {}; + result.effectivePlacement = placement; + + const UIPopupPlacement flippedPlacement = FlipPlacement(placement); + if (!CanPlace(anchorRect, popupSize, viewportRect, placement) && + CanPlace(anchorRect, popupSize, viewportRect, flippedPlacement)) { + result.effectivePlacement = flippedPlacement; + } + + const UIPoint origin = ResolvePlacementOrigin(anchorRect, popupSize, result.effectivePlacement); + result.rect.width = ClampNonNegative(popupSize.width); + result.rect.height = ClampNonNegative(popupSize.height); + result.rect.x = ClampRectOrigin(origin.x, viewportRect.x, viewportRect.width, result.rect.width, result.clampedX); + result.rect.y = ClampRectOrigin(origin.y, viewportRect.y, viewportRect.height, result.rect.height, result.clampedY); + return result; +} + +const UIPopupOverlayEntry* UIPopupOverlayModel::GetRootPopup() const { + return m_popupChain.empty() ? nullptr : &m_popupChain.front(); +} + +const UIPopupOverlayEntry* UIPopupOverlayModel::GetTopmostPopup() const { + return m_popupChain.empty() ? nullptr : &m_popupChain.back(); +} + +const UIPopupOverlayEntry* UIPopupOverlayModel::FindPopup(std::string_view popupId) const { + const std::size_t index = FindPopupIndex(popupId); + return index == InvalidIndex ? nullptr : &m_popupChain[index]; +} + +UIPopupOverlayMutationResult UIPopupOverlayModel::OpenPopup(UIPopupOverlayEntry entry) { + UIPopupOverlayMutationResult result = {}; + if (entry.popupId.empty()) { + return result; + } + + if (entry.parentPopupId.empty()) { + result = CloseAll(UIPopupDismissReason::Programmatic); + m_popupChain.clear(); + m_popupChain.push_back(std::move(entry)); + result.changed = true; + result.openedPopupId = m_popupChain.back().popupId; + return result; + } + + const std::size_t parentIndex = FindPopupIndex(entry.parentPopupId); + if (parentIndex == InvalidIndex || entry.popupId == entry.parentPopupId) { + return result; + } + + for (std::size_t index = 0; index <= parentIndex; ++index) { + if (m_popupChain[index].popupId == entry.popupId) { + return result; + } + } + + if (parentIndex + 1u < m_popupChain.size()) { + result = CloseFromIndex(parentIndex + 1u, UIPopupDismissReason::Programmatic); + } + + m_popupChain.push_back(std::move(entry)); + result.changed = true; + result.openedPopupId = m_popupChain.back().popupId; + return result; +} + +UIPopupOverlayMutationResult UIPopupOverlayModel::ClosePopup( + std::string_view popupId, + UIPopupDismissReason dismissReason) { + return CloseFromIndex(FindPopupIndex(popupId), dismissReason); +} + +UIPopupOverlayMutationResult UIPopupOverlayModel::CloseAll( + UIPopupDismissReason dismissReason) { + return CloseFromIndex(0u, dismissReason); +} + +UIPopupOverlayMutationResult UIPopupOverlayModel::DismissFromEscape() { + for (std::size_t index = m_popupChain.size(); index > 0u; --index) { + if (m_popupChain[index - 1u].dismissOnEscape) { + return CloseFromIndex(index - 1u, UIPopupDismissReason::EscapeKey); + } + } + + return {}; +} + +UIPopupOverlayMutationResult UIPopupOverlayModel::DismissFromPointerDown(const UIInputPath& hitPath) { + const std::size_t containingIndex = FindDeepestContainingPopupIndex(hitPath); + if (containingIndex == InvalidIndex) { + for (std::size_t index = 0; index < m_popupChain.size(); ++index) { + if (m_popupChain[index].dismissOnPointerOutside) { + return CloseFromIndex(index, UIPopupDismissReason::PointerOutside); + } + } + return {}; + } + + for (std::size_t index = containingIndex + 1u; index < m_popupChain.size(); ++index) { + if (m_popupChain[index].dismissOnPointerOutside) { + return CloseFromIndex(index, UIPopupDismissReason::PointerOutside); + } + } + + return {}; +} + +UIPopupOverlayMutationResult UIPopupOverlayModel::DismissFromFocusLoss(const UIInputPath& focusedPath) { + const std::size_t containingIndex = FindDeepestContainingPopupIndex(focusedPath); + if (containingIndex == InvalidIndex) { + for (std::size_t index = 0; index < m_popupChain.size(); ++index) { + if (m_popupChain[index].dismissOnFocusLoss) { + return CloseFromIndex(index, UIPopupDismissReason::FocusLoss); + } + } + return {}; + } + + for (std::size_t index = containingIndex + 1u; index < m_popupChain.size(); ++index) { + if (m_popupChain[index].dismissOnFocusLoss) { + return CloseFromIndex(index, UIPopupDismissReason::FocusLoss); + } + } + + return {}; +} + +std::size_t UIPopupOverlayModel::FindPopupIndex(std::string_view popupId) const { + if (popupId.empty()) { + return InvalidIndex; + } + + for (std::size_t index = 0; index < m_popupChain.size(); ++index) { + if (m_popupChain[index].popupId == popupId) { + return index; + } + } + + return InvalidIndex; +} + +std::size_t UIPopupOverlayModel::FindDeepestContainingPopupIndex(const UIInputPath& path) const { + std::size_t containingIndex = InvalidIndex; + for (std::size_t index = 0; index < m_popupChain.size(); ++index) { + if (PopupContainsPath(m_popupChain[index], path)) { + containingIndex = index; + } + } + return containingIndex; +} + +UIPopupOverlayMutationResult UIPopupOverlayModel::CloseFromIndex( + std::size_t index, + UIPopupDismissReason dismissReason) { + UIPopupOverlayMutationResult result = {}; + if (index >= m_popupChain.size()) { + return result; + } + + result.dismissReason = dismissReason; + result.changed = true; + result.closedPopupIds.reserve(m_popupChain.size() - index); + for (std::size_t popupIndex = index; popupIndex < m_popupChain.size(); ++popupIndex) { + result.closedPopupIds.push_back(m_popupChain[popupIndex].popupId); + } + + m_popupChain.resize(index); + return result; +} + +bool UIPopupOverlayModel::PopupContainsPath( + const UIPopupOverlayEntry& entry, + const UIInputPath& path) { + return IsPathPrefix(entry.anchorPath, path) || IsPathPrefix(entry.surfacePath, path); +} + +} // namespace Widgets +} // namespace UI +} // namespace XCEngine diff --git a/tests/UI/Core/integration/input/CMakeLists.txt b/tests/UI/Core/integration/input/CMakeLists.txt index 8005c612..1e9c546d 100644 --- a/tests/UI/Core/integration/input/CMakeLists.txt +++ b/tests/UI/Core/integration/input/CMakeLists.txt @@ -1,4 +1,5 @@ add_subdirectory(keyboard_focus) +add_subdirectory(popup_menu_overlay) add_subdirectory(pointer_states) add_subdirectory(scroll_view) add_subdirectory(shortcut_scope) @@ -6,6 +7,7 @@ add_subdirectory(shortcut_scope) add_custom_target(core_ui_input_integration_tests DEPENDS core_ui_input_keyboard_focus_validation + core_ui_input_popup_menu_overlay_validation core_ui_input_pointer_states_validation core_ui_input_scroll_view_validation core_ui_input_shortcut_scope_validation diff --git a/tests/UI/Core/integration/input/popup_menu_overlay/CMakeLists.txt b/tests/UI/Core/integration/input/popup_menu_overlay/CMakeLists.txt new file mode 100644 index 00000000..48545f42 --- /dev/null +++ b/tests/UI/Core/integration/input/popup_menu_overlay/CMakeLists.txt @@ -0,0 +1,35 @@ +set(CORE_UI_INPUT_POPUP_MENU_OVERLAY_RESOURCES + View.xcui + ${CMAKE_SOURCE_DIR}/tests/UI/Core/integration/shared/themes/core_validation.xctheme +) + +add_executable(core_ui_input_popup_menu_overlay_validation WIN32 + main.cpp + ${CORE_UI_INPUT_POPUP_MENU_OVERLAY_RESOURCES} +) + +target_include_directories(core_ui_input_popup_menu_overlay_validation PRIVATE + ${CMAKE_SOURCE_DIR}/tests/UI/Core/integration/shared/src + ${CMAKE_SOURCE_DIR}/engine/include +) + +target_compile_definitions(core_ui_input_popup_menu_overlay_validation PRIVATE + UNICODE + _UNICODE +) + +if(MSVC) + target_compile_options(core_ui_input_popup_menu_overlay_validation PRIVATE /utf-8 /FS) + set_property(TARGET core_ui_input_popup_menu_overlay_validation PROPERTY + MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>DLL") +endif() + +target_link_libraries(core_ui_input_popup_menu_overlay_validation PRIVATE + core_ui_integration_host +) + +set_target_properties(core_ui_input_popup_menu_overlay_validation PROPERTIES + OUTPUT_NAME "XCUICoreInputPopupMenuOverlayValidation" +) + +source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES View.xcui) diff --git a/tests/UI/Core/integration/input/popup_menu_overlay/README.md b/tests/UI/Core/integration/input/popup_menu_overlay/README.md new file mode 100644 index 00000000..8dbace69 --- /dev/null +++ b/tests/UI/Core/integration/input/popup_menu_overlay/README.md @@ -0,0 +1,10 @@ +# Core Input | Popup Menu Overlay + +这个场景只验证 `Core popup / menu overlay primitive`: + +- 根 popup 打开与关闭 +- submenu 展开与分支关闭 +- overlay 空白区点击 dismiss +- popup 打开时阻断底层按钮命中 + +不验证 Editor 菜单栏,不验证业务命令。 diff --git a/tests/UI/Core/integration/input/popup_menu_overlay/View.xcui b/tests/UI/Core/integration/input/popup_menu_overlay/View.xcui new file mode 100644 index 00000000..017cde86 --- /dev/null +++ b/tests/UI/Core/integration/input/popup_menu_overlay/View.xcui @@ -0,0 +1,18 @@ + + + + + + + + + + + + diff --git a/tests/UI/Core/integration/input/popup_menu_overlay/main.cpp b/tests/UI/Core/integration/input/popup_menu_overlay/main.cpp new file mode 100644 index 00000000..7d064537 --- /dev/null +++ b/tests/UI/Core/integration/input/popup_menu_overlay/main.cpp @@ -0,0 +1,8 @@ +#include "Application.h" + +int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) { + return XCEngine::Tests::CoreUI::RunCoreUIValidationApp( + hInstance, + nCmdShow, + "core.input.popup_menu_overlay"); +} diff --git a/tests/UI/Core/integration/shared/CMakeLists.txt b/tests/UI/Core/integration/shared/CMakeLists.txt index 2e406eb4..1f9100ea 100644 --- a/tests/UI/Core/integration/shared/CMakeLists.txt +++ b/tests/UI/Core/integration/shared/CMakeLists.txt @@ -30,6 +30,7 @@ add_library(core_ui_integration_host STATIC src/AutoScreenshot.cpp src/Application.cpp src/NativeRenderer.cpp + src/PopupMenuOverlayValidationScene.cpp ) target_include_directories(core_ui_integration_host diff --git a/tests/UI/Core/integration/shared/src/Application.cpp b/tests/UI/Core/integration/shared/src/Application.cpp index 68251e27..e2472972 100644 --- a/tests/UI/Core/integration/shared/src/Application.cpp +++ b/tests/UI/Core/integration/shared/src/Application.cpp @@ -300,13 +300,20 @@ void Application::RenderFrame() { m_pendingInputEvents.clear(); UIDrawData drawData = {}; + const UIRect viewportRect(0.0f, 0.0f, width, height); + const bool windowFocused = GetForegroundWindow() == m_hwnd; + if (m_activeScenario != nullptr && + m_activeScenario->id == PopupMenuOverlayValidationScene::ScenarioId) { + m_popupMenuOverlayScene.Update(frameEvents, viewportRect, windowFocused); + } + if (m_useStructuredScreen && m_screenPlayer.IsLoaded()) { UIScreenFrameInput input = {}; - input.viewportRect = UIRect(0.0f, 0.0f, width, height); + input.viewportRect = viewportRect; input.events = std::move(frameEvents); input.deltaTimeSeconds = deltaTimeSeconds; input.frameIndex = ++m_frameIndex; - input.focused = GetForegroundWindow() == m_hwnd; + input.focused = windowFocused; const auto& frame = m_screenPlayer.Update(input); for (const auto& drawList : frame.drawData.GetDrawLists()) { @@ -319,6 +326,11 @@ void Application::RenderFrame() { m_runtimeError = frame.errorMessage; } + if (m_activeScenario != nullptr && + m_activeScenario->id == PopupMenuOverlayValidationScene::ScenarioId) { + m_popupMenuOverlayScene.AppendDrawData(drawData, viewportRect); + } + if (drawData.Empty()) { m_runtimeStatus = "Core UI Validation | Load Error"; if (m_runtimeError.empty() && !m_screenPlayer.IsLoaded()) { @@ -435,6 +447,7 @@ bool Application::LoadStructuredScreen(const char* triggerReason) { : (scenarioLoadWarning.empty() ? m_screenPlayer.GetLastError() : scenarioLoadWarning + " | " + m_screenPlayer.GetLastError()); + m_popupMenuOverlayScene.Reset(); RebuildTrackedFileStates(); return loaded; } diff --git a/tests/UI/Core/integration/shared/src/Application.h b/tests/UI/Core/integration/shared/src/Application.h index ac331de9..92b48629 100644 --- a/tests/UI/Core/integration/shared/src/Application.h +++ b/tests/UI/Core/integration/shared/src/Application.h @@ -8,6 +8,7 @@ #include "CoreValidationScenario.h" #include "InputModifierTracker.h" #include "NativeRenderer.h" +#include "PopupMenuOverlayValidationScene.h" #include #include @@ -72,6 +73,7 @@ private: std::uint64_t m_frameIndex = 0; std::vector<::XCEngine::UI::UIInputEvent> m_pendingInputEvents = {}; Host::InputModifierTracker m_inputModifierTracker = {}; + PopupMenuOverlayValidationScene m_popupMenuOverlayScene = {}; bool m_trackingMouseLeave = false; bool m_useStructuredScreen = false; std::string m_runtimeStatus = {}; diff --git a/tests/UI/Core/integration/shared/src/CoreValidationScenario.cpp b/tests/UI/Core/integration/shared/src/CoreValidationScenario.cpp index 72191d07..5eef948c 100644 --- a/tests/UI/Core/integration/shared/src/CoreValidationScenario.cpp +++ b/tests/UI/Core/integration/shared/src/CoreValidationScenario.cpp @@ -24,8 +24,8 @@ fs::path RepoRelative(const char* relativePath) { return (RepoRootPath() / relativePath).lexically_normal(); } -const std::array& GetCoreValidationScenarios() { - static const std::array scenarios = { { +const std::array& GetCoreValidationScenarios() { + static const std::array scenarios = { { { "core.input.keyboard_focus", UIValidationDomain::Core, @@ -35,6 +35,15 @@ const std::array& GetCoreValidationScenarios() { RepoRelative("tests/UI/Core/integration/shared/themes/core_validation.xctheme"), RepoRelative("tests/UI/Core/integration/input/keyboard_focus/captures") }, + { + "core.input.popup_menu_overlay", + UIValidationDomain::Core, + "input", + "Core Input | Popup Menu Overlay", + RepoRelative("tests/UI/Core/integration/input/popup_menu_overlay/View.xcui"), + RepoRelative("tests/UI/Core/integration/shared/themes/core_validation.xctheme"), + RepoRelative("tests/UI/Core/integration/input/popup_menu_overlay/captures") + }, { "core.input.pointer_states", UIValidationDomain::Core, diff --git a/tests/UI/Core/integration/shared/src/PopupMenuOverlayValidationScene.cpp b/tests/UI/Core/integration/shared/src/PopupMenuOverlayValidationScene.cpp new file mode 100644 index 00000000..281a0cef --- /dev/null +++ b/tests/UI/Core/integration/shared/src/PopupMenuOverlayValidationScene.cpp @@ -0,0 +1,471 @@ +#include "PopupMenuOverlayValidationScene.h" + +#include + +#include +#include +#include + +namespace XCEngine::Tests::CoreUI { + +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::UIInputPath; +using ::XCEngine::UI::UIPoint; +using ::XCEngine::UI::UIRect; +using ::XCEngine::UI::UISize; +using ::XCEngine::UI::Widgets::ResolvePopupPlacementRect; +using ::XCEngine::UI::Widgets::UIPopupDismissReason; +using ::XCEngine::UI::Widgets::UIPopupOverlayEntry; +using ::XCEngine::UI::Widgets::UIPopupPlacement; + +constexpr UIColor kLabPanelBg(0.12f, 0.12f, 0.12f, 1.0f); +constexpr UIColor kLabPanelBorder(0.24f, 0.24f, 0.24f, 1.0f); +constexpr UIColor kStatusBg(0.16f, 0.16f, 0.16f, 1.0f); +constexpr UIColor kStatusBorder(0.28f, 0.28f, 0.28f, 1.0f); +constexpr UIColor kControlBg(0.22f, 0.22f, 0.22f, 1.0f); +constexpr UIColor kControlHover(0.31f, 0.31f, 0.31f, 1.0f); +constexpr UIColor kControlDisabled(0.18f, 0.18f, 0.18f, 1.0f); +constexpr UIColor kPopupBg(0.17f, 0.17f, 0.17f, 1.0f); +constexpr UIColor kPopupHover(0.30f, 0.30f, 0.30f, 1.0f); +constexpr UIColor kPopupBorder(0.38f, 0.38f, 0.38f, 1.0f); +constexpr UIColor kScrim(0.05f, 0.05f, 0.05f, 0.48f); +constexpr UIColor kTextPrimary(0.93f, 0.93f, 0.93f, 1.0f); +constexpr UIColor kTextMuted(0.72f, 0.72f, 0.72f, 1.0f); + +const UIInputPath kTriggerPath = { 10u, 11u }; +const UIInputPath kBackgroundButtonPath = { 20u, 21u }; +const UIInputPath kRootSurfacePath = { 30u, 31u }; +const UIInputPath kRootActionPath = { 30u, 31u, 32u }; +const UIInputPath kRootSubmenuPath = { 30u, 31u, 33u }; +const UIInputPath kChildSurfacePath = { 40u, 41u }; +const UIInputPath kChildActionPath = { 40u, 41u, 42u }; + +std::string DescribePath(const UIInputPath& path) { + if (path == kTriggerPath) { + return "Open Menu"; + } + if (path == kBackgroundButtonPath) { + return "Background Button"; + } + if (path == kRootActionPath) { + return "Root Action"; + } + if (path == kRootSubmenuPath) { + return "Open Submenu"; + } + if (path == kRootSurfacePath) { + return "Root Popup Surface"; + } + if (path == kChildActionPath) { + return "Leaf Action"; + } + if (path == kChildSurfacePath) { + return "Child Popup Surface"; + } + return "Overlay Blank"; +} + +void DrawPanel( + UIDrawList& drawList, + const UIRect& rect, + const UIColor& fillColor, + const UIColor& borderColor, + float rounding) { + drawList.AddFilledRect(rect, fillColor, rounding); + drawList.AddRectOutline(rect, borderColor, 1.0f, rounding); +} + +void DrawLabel( + UIDrawList& drawList, + const UIRect& rect, + std::string text, + const UIColor& textColor, + float fontSize = 13.0f) { + drawList.AddText( + UIPoint(rect.x + 12.0f, rect.y + 10.0f), + std::move(text), + textColor, + fontSize); +} + +} // namespace + +void PopupMenuOverlayValidationScene::Reset() { + m_popupModel = {}; + m_pointerPosition = {}; + m_hasPointer = false; + m_backgroundClickCount = 0; + m_resultText = "Result: Ready"; +} + +void PopupMenuOverlayValidationScene::Update( + const std::vector& events, + const UIRect& viewportRect, + bool windowFocused) { + (void)windowFocused; + const Geometry geometry = BuildGeometry(viewportRect); + + for (const UIInputEvent& event : events) { + switch (event.type) { + case UIInputEventType::PointerMove: + m_pointerPosition = event.position; + m_hasPointer = true; + HandlePointerHover(geometry, event.position); + break; + case UIInputEventType::PointerLeave: + m_hasPointer = false; + break; + case UIInputEventType::PointerButtonDown: + if (event.pointerButton == ::XCEngine::UI::UIPointerButton::Left) { + m_pointerPosition = event.position; + m_hasPointer = true; + HandlePointerDown(geometry, event.position); + } + break; + case UIInputEventType::KeyDown: + if (event.keyCode == static_cast(KeyCode::Escape)) { + HandleEscapeKey(); + } + break; + case UIInputEventType::FocusLost: { + const auto dismissResult = m_popupModel.DismissFromFocusLoss({}); + if (dismissResult.changed) { + SetResult("Result: window focus lost, popup chain closed"); + } + break; + } + default: + break; + } + } +} + +void PopupMenuOverlayValidationScene::AppendDrawData( + UIDrawData& drawData, + const UIRect& viewportRect) const { + const Geometry geometry = BuildGeometry(viewportRect); + const UIInputPath hoverPath = + m_hasPointer ? HitTest(geometry, m_pointerPosition) : UIInputPath(); + + UIDrawList& drawList = drawData.EmplaceDrawList("Core Popup Menu Overlay Lab"); + DrawPanel(drawList, geometry.labRect, kLabPanelBg, kLabPanelBorder, 12.0f); + DrawPanel(drawList, geometry.backgroundButtonRect, kControlBg, kStatusBorder, 8.0f); + DrawLabel( + drawList, + geometry.backgroundButtonRect, + "Background Button", + !HasOpenPopups() && hoverPath == kBackgroundButtonPath ? kTextPrimary : kTextMuted); + + if (HasOpenPopups()) { + drawList.AddFilledRect(geometry.labRect, kScrim, 12.0f); + } + + DrawPanel(drawList, geometry.statusRect, kStatusBg, kStatusBorder, 10.0f); + drawList.AddText( + UIPoint(geometry.statusRect.x + 12.0f, geometry.statusRect.y + 10.0f), + "测试内容:Core Popup / Menu Overlay Primitive", + kTextPrimary, + 14.0f); + drawList.AddText( + UIPoint(geometry.statusRect.x + 12.0f, geometry.statusRect.y + 32.0f), + m_resultText, + kTextMuted, + 12.0f); + drawList.AddText( + UIPoint(geometry.statusRect.x + 12.0f, geometry.statusRect.y + 50.0f), + "Popup Chain: " + FormatPopupChain(), + kTextMuted, + 12.0f); + drawList.AddText( + UIPoint(geometry.statusRect.x + 320.0f, geometry.statusRect.y + 32.0f), + "Hover: " + DescribeHoverTarget(geometry), + kTextMuted, + 12.0f); + drawList.AddText( + UIPoint(geometry.statusRect.x + 320.0f, geometry.statusRect.y + 50.0f), + "Background Hits: " + std::to_string(m_backgroundClickCount), + kTextMuted, + 12.0f); + + const UIColor triggerColor = + hoverPath == kTriggerPath ? kControlHover : kControlBg; + DrawPanel(drawList, geometry.triggerRect, triggerColor, kStatusBorder, 8.0f); + DrawLabel(drawList, geometry.triggerRect, "Open Menu", kTextPrimary); + + if (IsRootOpen()) { + DrawPanel(drawList, geometry.rootPopupRect, kPopupBg, kPopupBorder, 10.0f); + + const UIColor rootActionColor = + hoverPath == kRootActionPath ? kPopupHover : kPopupBg; + DrawPanel(drawList, geometry.rootActionRect, rootActionColor, kStatusBorder, 8.0f); + DrawLabel(drawList, geometry.rootActionRect, "Root Action", kTextPrimary); + + const UIColor submenuColor = + hoverPath == kRootSubmenuPath || hoverPath == kChildSurfacePath || hoverPath == kChildActionPath + ? kPopupHover + : kPopupBg; + DrawPanel(drawList, geometry.rootSubmenuRect, submenuColor, kStatusBorder, 8.0f); + DrawLabel(drawList, geometry.rootSubmenuRect, "Open Submenu >", kTextPrimary); + } + + if (IsSubmenuOpen()) { + DrawPanel(drawList, geometry.childPopupRect, kPopupBg, kPopupBorder, 10.0f); + const UIColor childActionColor = + hoverPath == kChildActionPath ? kPopupHover : kPopupBg; + DrawPanel(drawList, geometry.childActionRect, childActionColor, kStatusBorder, 8.0f); + DrawLabel(drawList, geometry.childActionRect, "Leaf Action", kTextPrimary); + } +} + +PopupMenuOverlayValidationScene::Geometry PopupMenuOverlayValidationScene::BuildGeometry( + const UIRect& viewportRect) const { + Geometry geometry = {}; + + const float availableWidth = (std::max)(280.0f, viewportRect.width - 48.0f); + const float availableHeight = (std::max)(280.0f, viewportRect.height - 244.0f); + geometry.labRect = UIRect( + 24.0f, + 220.0f, + (std::min)(760.0f, availableWidth), + (std::min)(380.0f, availableHeight)); + + geometry.statusRect = UIRect( + geometry.labRect.x + 20.0f, + geometry.labRect.y + 20.0f, + geometry.labRect.width - 40.0f, + 78.0f); + geometry.triggerRect = UIRect( + geometry.labRect.x + 20.0f, + geometry.statusRect.y + geometry.statusRect.height + 18.0f, + 126.0f, + 40.0f); + geometry.backgroundButtonRect = UIRect( + geometry.labRect.x + geometry.labRect.width - 220.0f, + geometry.labRect.y + geometry.labRect.height - 64.0f, + 180.0f, + 40.0f); + + const auto rootPlacement = ResolvePopupPlacementRect( + geometry.triggerRect, + UISize(220.0f, 112.0f), + geometry.labRect, + UIPopupPlacement::BottomStart); + geometry.rootPopupRect = rootPlacement.rect; + geometry.rootActionRect = UIRect( + geometry.rootPopupRect.x + 10.0f, + geometry.rootPopupRect.y + 10.0f, + geometry.rootPopupRect.width - 20.0f, + 40.0f); + geometry.rootSubmenuRect = UIRect( + geometry.rootPopupRect.x + 10.0f, + geometry.rootPopupRect.y + 58.0f, + geometry.rootPopupRect.width - 20.0f, + 40.0f); + + const auto childPlacement = ResolvePopupPlacementRect( + geometry.rootSubmenuRect, + UISize(180.0f, 60.0f), + geometry.labRect, + UIPopupPlacement::RightStart); + geometry.childPopupRect = childPlacement.rect; + geometry.childActionRect = UIRect( + geometry.childPopupRect.x + 10.0f, + geometry.childPopupRect.y + 10.0f, + geometry.childPopupRect.width - 20.0f, + 40.0f); + return geometry; +} + +UIInputPath PopupMenuOverlayValidationScene::HitTest( + const Geometry& geometry, + const UIPoint& position) const { + if (IsSubmenuOpen()) { + if (RectContains(geometry.childActionRect, position)) { + return kChildActionPath; + } + if (RectContains(geometry.childPopupRect, position)) { + return kChildSurfacePath; + } + } + + if (IsRootOpen()) { + if (RectContains(geometry.rootActionRect, position)) { + return kRootActionPath; + } + if (RectContains(geometry.rootSubmenuRect, position)) { + return kRootSubmenuPath; + } + if (RectContains(geometry.rootPopupRect, position)) { + return kRootSurfacePath; + } + } + + if (RectContains(geometry.triggerRect, position)) { + return kTriggerPath; + } + + if (!HasOpenPopups() && RectContains(geometry.backgroundButtonRect, position)) { + return kBackgroundButtonPath; + } + + return {}; +} + +void PopupMenuOverlayValidationScene::HandlePointerDown( + const Geometry& geometry, + const UIPoint& position) { + const UIInputPath hitPath = HitTest(geometry, position); + if (hitPath == kTriggerPath) { + if (IsRootOpen()) { + m_popupModel.CloseAll(UIPopupDismissReason::Programmatic); + SetResult("Result: root popup closed"); + } else { + OpenRootPopup(geometry); + SetResult("Result: root popup opened"); + } + return; + } + + if (hitPath == kRootActionPath) { + m_popupModel.CloseAll(UIPopupDismissReason::Programmatic); + SetResult("Result: Root Action dispatched"); + return; + } + + if (hitPath == kRootSubmenuPath) { + if (!IsSubmenuOpen()) { + OpenSubmenuPopup(geometry); + SetResult("Result: submenu opened"); + } + return; + } + + if (hitPath == kChildActionPath) { + m_popupModel.CloseAll(UIPopupDismissReason::Programmatic); + SetResult("Result: Leaf Action dispatched"); + return; + } + + const auto dismissResult = m_popupModel.DismissFromPointerDown(hitPath); + if (dismissResult.changed) { + if (dismissResult.closedPopupIds.size() == 1u && + dismissResult.closedPopupIds.front() == "submenu") { + SetResult("Result: click stayed in root popup, submenu closed"); + } else { + SetResult("Result: click hit overlay blank, popup chain closed"); + } + return; + } + + if (hitPath == kBackgroundButtonPath) { + ++m_backgroundClickCount; + SetResult("Result: Background Button dispatched #" + std::to_string(m_backgroundClickCount)); + return; + } +} + +void PopupMenuOverlayValidationScene::HandlePointerHover( + const Geometry& geometry, + const UIPoint& position) { + const UIInputPath hitPath = HitTest(geometry, position); + if (!IsRootOpen()) { + return; + } + + if (hitPath == kRootSubmenuPath && !IsSubmenuOpen()) { + OpenSubmenuPopup(geometry); + SetResult("Result: submenu opened by hover"); + return; + } + + if (hitPath == kRootActionPath && IsSubmenuOpen()) { + m_popupModel.ClosePopup("submenu", UIPopupDismissReason::Programmatic); + SetResult("Result: submenu collapsed by hover"); + } +} + +void PopupMenuOverlayValidationScene::HandleEscapeKey() { + const auto dismissResult = m_popupModel.DismissFromEscape(); + if (dismissResult.changed) { + SetResult("Result: Escape closed topmost popup"); + } +} + +void PopupMenuOverlayValidationScene::OpenRootPopup(const Geometry& geometry) { + UIPopupOverlayEntry entry = {}; + entry.popupId = "root"; + entry.anchorRect = geometry.triggerRect; + entry.anchorPath = kTriggerPath; + entry.surfacePath = kRootSurfacePath; + entry.placement = UIPopupPlacement::BottomStart; + m_popupModel.OpenPopup(std::move(entry)); +} + +void PopupMenuOverlayValidationScene::OpenSubmenuPopup(const Geometry& geometry) { + UIPopupOverlayEntry entry = {}; + entry.popupId = "submenu"; + entry.parentPopupId = "root"; + entry.anchorRect = geometry.rootSubmenuRect; + entry.anchorPath = kRootSubmenuPath; + entry.surfacePath = kChildSurfacePath; + entry.placement = UIPopupPlacement::RightStart; + m_popupModel.OpenPopup(std::move(entry)); +} + +void PopupMenuOverlayValidationScene::SetResult(std::string text) { + m_resultText = std::move(text); +} + +std::string PopupMenuOverlayValidationScene::FormatPopupChain() const { + if (!HasOpenPopups()) { + return "(empty)"; + } + + std::string chain = {}; + const auto& popups = m_popupModel.GetPopupChain(); + for (std::size_t index = 0; index < popups.size(); ++index) { + if (!chain.empty()) { + chain += " > "; + } + chain += popups[index].popupId; + } + return chain; +} + +std::string PopupMenuOverlayValidationScene::DescribeHoverTarget(const Geometry& geometry) const { + if (!m_hasPointer) { + return "(none)"; + } + + return DescribePath(HitTest(geometry, m_pointerPosition)); +} + +bool PopupMenuOverlayValidationScene::HasOpenPopups() const { + return m_popupModel.HasOpenPopups(); +} + +bool PopupMenuOverlayValidationScene::IsRootOpen() const { + return m_popupModel.FindPopup("root") != nullptr; +} + +bool PopupMenuOverlayValidationScene::IsSubmenuOpen() const { + return m_popupModel.FindPopup("submenu") != nullptr; +} + +bool PopupMenuOverlayValidationScene::RectContains( + const UIRect& rect, + const UIPoint& position) { + return position.x >= rect.x && + position.x <= rect.x + rect.width && + position.y >= rect.y && + position.y <= rect.y + rect.height; +} + +} // namespace XCEngine::Tests::CoreUI diff --git a/tests/UI/Core/integration/shared/src/PopupMenuOverlayValidationScene.h b/tests/UI/Core/integration/shared/src/PopupMenuOverlayValidationScene.h new file mode 100644 index 00000000..89ba0acb --- /dev/null +++ b/tests/UI/Core/integration/shared/src/PopupMenuOverlayValidationScene.h @@ -0,0 +1,69 @@ +#pragma once + +#include +#include + +#include +#include +#include + +namespace XCEngine::Tests::CoreUI { + +class PopupMenuOverlayValidationScene { +public: + static constexpr const char* ScenarioId = "core.input.popup_menu_overlay"; + + void Reset(); + void Update( + const std::vector<::XCEngine::UI::UIInputEvent>& events, + const ::XCEngine::UI::UIRect& viewportRect, + bool windowFocused); + void AppendDrawData( + ::XCEngine::UI::UIDrawData& drawData, + const ::XCEngine::UI::UIRect& viewportRect) const; + +private: + struct Geometry { + ::XCEngine::UI::UIRect labRect = {}; + ::XCEngine::UI::UIRect statusRect = {}; + ::XCEngine::UI::UIRect triggerRect = {}; + ::XCEngine::UI::UIRect backgroundButtonRect = {}; + ::XCEngine::UI::UIRect rootPopupRect = {}; + ::XCEngine::UI::UIRect rootActionRect = {}; + ::XCEngine::UI::UIRect rootSubmenuRect = {}; + ::XCEngine::UI::UIRect childPopupRect = {}; + ::XCEngine::UI::UIRect childActionRect = {}; + }; + + Geometry BuildGeometry(const ::XCEngine::UI::UIRect& viewportRect) const; + ::XCEngine::UI::UIInputPath HitTest( + const Geometry& geometry, + const ::XCEngine::UI::UIPoint& position) const; + void HandlePointerDown( + const Geometry& geometry, + const ::XCEngine::UI::UIPoint& position); + void HandlePointerHover( + const Geometry& geometry, + const ::XCEngine::UI::UIPoint& position); + void HandleEscapeKey(); + void OpenRootPopup(const Geometry& geometry); + void OpenSubmenuPopup(const Geometry& geometry); + void SetResult(std::string text); + std::string FormatPopupChain() const; + std::string DescribeHoverTarget(const Geometry& geometry) const; + bool HasOpenPopups() const; + bool IsRootOpen() const; + bool IsSubmenuOpen() const; + + static bool RectContains( + const ::XCEngine::UI::UIRect& rect, + const ::XCEngine::UI::UIPoint& position); + + ::XCEngine::UI::Widgets::UIPopupOverlayModel m_popupModel = {}; + ::XCEngine::UI::UIPoint m_pointerPosition = {}; + bool m_hasPointer = false; + std::uint32_t m_backgroundClickCount = 0; + std::string m_resultText = "Result: Ready"; +}; + +} // namespace XCEngine::Tests::CoreUI diff --git a/tests/UI/Core/unit/CMakeLists.txt b/tests/UI/Core/unit/CMakeLists.txt index a078ce6b..b5038eda 100644 --- a/tests/UI/Core/unit/CMakeLists.txt +++ b/tests/UI/Core/unit/CMakeLists.txt @@ -7,6 +7,7 @@ set(CORE_UI_TEST_SOURCES ${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_flat_hierarchy_helpers.cpp ${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_input_dispatcher.cpp ${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_keyboard_navigation_model.cpp + ${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_popup_overlay_model.cpp ${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_property_edit_model.cpp ${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_selection_model.cpp ${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_style_system.cpp diff --git a/tests/UI/Core/unit/test_ui_popup_overlay_model.cpp b/tests/UI/Core/unit/test_ui_popup_overlay_model.cpp new file mode 100644 index 00000000..de9972c0 --- /dev/null +++ b/tests/UI/Core/unit/test_ui_popup_overlay_model.cpp @@ -0,0 +1,200 @@ +#include + +#include + +#include + +namespace { + +using XCEngine::UI::UIInputPath; +using XCEngine::UI::UIRect; +using XCEngine::UI::UISize; +using XCEngine::UI::Widgets::ResolvePopupPlacementRect; +using XCEngine::UI::Widgets::UIPopupDismissReason; +using XCEngine::UI::Widgets::UIPopupOverlayEntry; +using XCEngine::UI::Widgets::UIPopupOverlayModel; +using XCEngine::UI::Widgets::UIPopupPlacement; + +UIPopupOverlayEntry MakePopup( + const char* popupId, + const char* parentPopupId, + UIInputPath surfacePath, + UIInputPath anchorPath = {}) { + UIPopupOverlayEntry entry = {}; + entry.popupId = popupId; + entry.parentPopupId = parentPopupId; + entry.surfacePath = std::move(surfacePath); + entry.anchorPath = std::move(anchorPath); + return entry; +} + +void ExpectClosedIds( + const std::vector& actual, + std::initializer_list expected) { + ASSERT_EQ(actual.size(), expected.size()); + + std::size_t index = 0u; + for (const char* value : expected) { + EXPECT_EQ(actual[index], value); + ++index; + } +} + +} // namespace + +TEST(UIPopupOverlayModelTest, OpenRootPopupReplacesExistingChain) { + UIPopupOverlayModel model = {}; + + auto result = model.OpenPopup(MakePopup("root-a", "", UIInputPath{10u, 11u})); + EXPECT_TRUE(result.changed); + EXPECT_EQ(result.openedPopupId, "root-a"); + EXPECT_TRUE(result.closedPopupIds.empty()); + ASSERT_EQ(model.GetPopupCount(), 1u); + ASSERT_NE(model.GetRootPopup(), nullptr); + EXPECT_EQ(model.GetRootPopup()->popupId, "root-a"); + + ASSERT_TRUE(model.OpenPopup(MakePopup("child-a", "root-a", UIInputPath{20u, 21u})).changed); + + result = model.OpenPopup(MakePopup("root-b", "", UIInputPath{30u, 31u})); + EXPECT_TRUE(result.changed); + EXPECT_EQ(result.openedPopupId, "root-b"); + EXPECT_EQ(result.dismissReason, UIPopupDismissReason::Programmatic); + ExpectClosedIds(result.closedPopupIds, {"root-a", "child-a"}); + ASSERT_EQ(model.GetPopupCount(), 1u); + EXPECT_EQ(model.GetTopmostPopup()->popupId, "root-b"); +} + +TEST(UIPopupOverlayModelTest, OpenSubmenuTruncatesPreviousBranchBeforeAppendingNewPopup) { + UIPopupOverlayModel model = {}; + ASSERT_TRUE(model.OpenPopup(MakePopup("root", "", UIInputPath{100u, 110u})).changed); + ASSERT_TRUE(model.OpenPopup(MakePopup("child-a", "root", UIInputPath{200u, 210u}, UIInputPath{100u, 110u, 120u})).changed); + ASSERT_TRUE(model.OpenPopup(MakePopup("grandchild", "child-a", UIInputPath{300u, 310u}, UIInputPath{200u, 210u, 220u})).changed); + + const auto result = + model.OpenPopup(MakePopup("child-b", "root", UIInputPath{400u, 410u}, UIInputPath{100u, 110u, 130u})); + + EXPECT_TRUE(result.changed); + EXPECT_EQ(result.openedPopupId, "child-b"); + EXPECT_EQ(result.dismissReason, UIPopupDismissReason::Programmatic); + ExpectClosedIds(result.closedPopupIds, {"child-a", "grandchild"}); + ASSERT_EQ(model.GetPopupCount(), 2u); + EXPECT_EQ(model.GetPopupChain()[0].popupId, "root"); + EXPECT_EQ(model.GetPopupChain()[1].popupId, "child-b"); +} + +TEST(UIPopupOverlayModelTest, ClosePopupRemovesTargetAndDescendants) { + UIPopupOverlayModel model = {}; + ASSERT_TRUE(model.OpenPopup(MakePopup("root", "", UIInputPath{100u, 110u})).changed); + ASSERT_TRUE(model.OpenPopup(MakePopup("child", "root", UIInputPath{200u, 210u})).changed); + ASSERT_TRUE(model.OpenPopup(MakePopup("grandchild", "child", UIInputPath{300u, 310u})).changed); + + const auto result = model.ClosePopup("child", UIPopupDismissReason::Programmatic); + + EXPECT_TRUE(result.changed); + EXPECT_EQ(result.dismissReason, UIPopupDismissReason::Programmatic); + ExpectClosedIds(result.closedPopupIds, {"child", "grandchild"}); + ASSERT_EQ(model.GetPopupCount(), 1u); + EXPECT_EQ(model.GetTopmostPopup()->popupId, "root"); +} + +TEST(UIPopupOverlayModelTest, PointerDismissClosesOnlyDescendantsOutsideContainingPopup) { + UIPopupOverlayModel model = {}; + ASSERT_TRUE(model.OpenPopup(MakePopup("root", "", UIInputPath{100u, 110u}, UIInputPath{1u, 2u})).changed); + ASSERT_TRUE(model.OpenPopup(MakePopup("child", "root", UIInputPath{200u, 210u}, UIInputPath{100u, 110u, 120u})).changed); + + const auto result = model.DismissFromPointerDown(UIInputPath{100u, 110u, 130u}); + + EXPECT_TRUE(result.changed); + EXPECT_EQ(result.dismissReason, UIPopupDismissReason::PointerOutside); + ExpectClosedIds(result.closedPopupIds, {"child"}); + ASSERT_EQ(model.GetPopupCount(), 1u); + EXPECT_EQ(model.GetTopmostPopup()->popupId, "root"); +} + +TEST(UIPopupOverlayModelTest, PointerDismissOutsideAllClosesFirstDismissablePopupBranch) { + UIPopupOverlayModel model = {}; + ASSERT_TRUE(model.OpenPopup(MakePopup("root", "", UIInputPath{100u, 110u})).changed); + UIPopupOverlayEntry child = MakePopup("child", "root", UIInputPath{200u, 210u}); + child.dismissOnPointerOutside = true; + ASSERT_TRUE(model.OpenPopup(std::move(child)).changed); + + const auto result = model.DismissFromPointerDown(UIInputPath{999u, 1000u}); + + EXPECT_TRUE(result.changed); + EXPECT_EQ(result.dismissReason, UIPopupDismissReason::PointerOutside); + ExpectClosedIds(result.closedPopupIds, {"root", "child"}); + EXPECT_FALSE(model.HasOpenPopups()); +} + +TEST(UIPopupOverlayModelTest, EscapeDismissClosesTopmostDismissablePopup) { + UIPopupOverlayModel model = {}; + + UIPopupOverlayEntry root = MakePopup("root", "", UIInputPath{10u, 11u}); + root.dismissOnEscape = false; + ASSERT_TRUE(model.OpenPopup(std::move(root)).changed); + + UIPopupOverlayEntry child = MakePopup("child", "root", UIInputPath{20u, 21u}); + child.dismissOnEscape = true; + ASSERT_TRUE(model.OpenPopup(std::move(child)).changed); + + UIPopupOverlayEntry grandchild = MakePopup("grandchild", "child", UIInputPath{30u, 31u}); + grandchild.dismissOnEscape = false; + ASSERT_TRUE(model.OpenPopup(std::move(grandchild)).changed); + + const auto result = model.DismissFromEscape(); + + EXPECT_TRUE(result.changed); + EXPECT_EQ(result.dismissReason, UIPopupDismissReason::EscapeKey); + ExpectClosedIds(result.closedPopupIds, {"child", "grandchild"}); + ASSERT_EQ(model.GetPopupCount(), 1u); + EXPECT_EQ(model.GetTopmostPopup()->popupId, "root"); +} + +TEST(UIPopupOverlayModelTest, FocusLossDismissClosesDescendantsThatLoseContainment) { + UIPopupOverlayModel model = {}; + ASSERT_TRUE(model.OpenPopup(MakePopup("root", "", UIInputPath{100u, 110u})).changed); + + UIPopupOverlayEntry child = MakePopup("child", "root", UIInputPath{200u, 210u}, UIInputPath{100u, 110u, 120u}); + child.dismissOnFocusLoss = true; + ASSERT_TRUE(model.OpenPopup(std::move(child)).changed); + + const auto result = model.DismissFromFocusLoss(UIInputPath{100u, 110u, 130u}); + + EXPECT_TRUE(result.changed); + EXPECT_EQ(result.dismissReason, UIPopupDismissReason::FocusLoss); + ExpectClosedIds(result.closedPopupIds, {"child"}); + ASSERT_EQ(model.GetPopupCount(), 1u); + EXPECT_EQ(model.GetTopmostPopup()->popupId, "root"); +} + +TEST(UIPopupOverlayModelTest, ResolvePopupPlacementFlipsWhenPreferredDirectionDoesNotFit) { + const auto result = ResolvePopupPlacementRect( + UIRect(20.0f, 60.0f, 20.0f, 20.0f), + UISize(40.0f, 40.0f), + UIRect(0.0f, 0.0f, 100.0f, 100.0f), + UIPopupPlacement::BottomStart); + + EXPECT_EQ(result.effectivePlacement, UIPopupPlacement::TopStart); + EXPECT_FLOAT_EQ(result.rect.x, 20.0f); + EXPECT_FLOAT_EQ(result.rect.y, 20.0f); + EXPECT_FLOAT_EQ(result.rect.width, 40.0f); + EXPECT_FLOAT_EQ(result.rect.height, 40.0f); + EXPECT_FALSE(result.clampedX); + EXPECT_FALSE(result.clampedY); +} + +TEST(UIPopupOverlayModelTest, ResolvePopupPlacementClampsWithinViewportWhenNoDirectionFitsCleanly) { + const auto result = ResolvePopupPlacementRect( + UIRect(5.0f, 10.0f, 10.0f, 10.0f), + UISize(40.0f, 20.0f), + UIRect(0.0f, 0.0f, 30.0f, 30.0f), + UIPopupPlacement::BottomEnd); + + EXPECT_EQ(result.effectivePlacement, UIPopupPlacement::BottomEnd); + EXPECT_FLOAT_EQ(result.rect.x, 0.0f); + EXPECT_FLOAT_EQ(result.rect.y, 10.0f); + EXPECT_FLOAT_EQ(result.rect.width, 40.0f); + EXPECT_FLOAT_EQ(result.rect.height, 20.0f); + EXPECT_TRUE(result.clampedX); + EXPECT_TRUE(result.clampedY); +}