Add core popup overlay primitive

This commit is contained in:
2026-04-06 22:32:40 +08:00
parent 9568cf0a16
commit 620717f8b4
16 changed files with 1261 additions and 4 deletions

View File

@@ -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

View File

@@ -0,0 +1,113 @@
#pragma once
#include <XCEngine/UI/Input/UIInputPath.h>
#include <XCEngine/UI/Types.h>
#include <cstddef>
#include <cstdint>
#include <string>
#include <string_view>
#include <vector>
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<std::string> 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<std::size_t>(-1);
const std::vector<UIPopupOverlayEntry>& 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<UIPopupOverlayEntry> m_popupChain = {};
};
} // namespace Widgets
} // namespace UI
} // namespace XCEngine

View File

@@ -0,0 +1,303 @@
#include <XCEngine/UI/Widgets/UIPopupOverlayModel.h>
#include <algorithm>
#include <utility>
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

View File

@@ -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

View File

@@ -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$<$<CONFIG:Debug>: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)

View File

@@ -0,0 +1,10 @@
# Core Input | Popup Menu Overlay
这个场景只验证 `Core popup / menu overlay primitive`
- 根 popup 打开与关闭
- submenu 展开与分支关闭
- overlay 空白区点击 dismiss
- popup 打开时阻断底层按钮命中
不验证 Editor 菜单栏,不验证业务命令。

View File

@@ -0,0 +1,18 @@
<View
name="CoreInputPopupMenuOverlay"
theme="../../shared/themes/core_validation.xctheme">
<Column padding="24" gap="16">
<Card
title="测试内容Core Popup / Menu Overlay Primitive"
subtitle="只验证 popup 打开关闭、overlay 遮挡、outside click dismiss 与 hover 展开子菜单"
tone="accent"
height="160">
<Column gap="8">
<Text text="1. 点击下方交互区里的 Open Menu根 popup 应在 overlay 层弹出。" />
<Text text="2. hover 到 Open Submenu 后,右侧应直接弹出 child popup再点根 popup 空白区,应该只关闭 child popup。" />
<Text text="3. popup 打开时Background Button 不应在同一次点击里被命中;先 dismiss再点它才应生效。" />
<Text text="4. 按 Esc 应关闭当前最上层 popup点击 overlay 空白区应关闭整条 popup 链。" />
</Column>
</Card>
</Column>
</View>

View File

@@ -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");
}

View File

@@ -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

View File

@@ -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;
}

View File

@@ -8,6 +8,7 @@
#include "CoreValidationScenario.h"
#include "InputModifierTracker.h"
#include "NativeRenderer.h"
#include "PopupMenuOverlayValidationScene.h"
#include <XCEngine/UI/Runtime/UIScreenDocumentHost.h>
#include <XCEngine/UI/Runtime/UIScreenPlayer.h>
@@ -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 = {};

View File

@@ -24,8 +24,8 @@ fs::path RepoRelative(const char* relativePath) {
return (RepoRootPath() / relativePath).lexically_normal();
}
const std::array<CoreValidationScenario, 9>& GetCoreValidationScenarios() {
static const std::array<CoreValidationScenario, 9> scenarios = { {
const std::array<CoreValidationScenario, 10>& GetCoreValidationScenarios() {
static const std::array<CoreValidationScenario, 10> scenarios = { {
{
"core.input.keyboard_focus",
UIValidationDomain::Core,
@@ -35,6 +35,15 @@ const std::array<CoreValidationScenario, 9>& 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,

View File

@@ -0,0 +1,471 @@
#include "PopupMenuOverlayValidationScene.h"
#include <XCEngine/Input/InputTypes.h>
#include <algorithm>
#include <string>
#include <utility>
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<UIInputEvent>& 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<std::int32_t>(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

View File

@@ -0,0 +1,69 @@
#pragma once
#include <XCEngine/UI/DrawData.h>
#include <XCEngine/UI/Widgets/UIPopupOverlayModel.h>
#include <cstdint>
#include <string>
#include <vector>
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

View File

@@ -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

View File

@@ -0,0 +1,200 @@
#include <gtest/gtest.h>
#include <XCEngine/UI/Widgets/UIPopupOverlayModel.h>
#include <vector>
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<std::string>& actual,
std::initializer_list<const char*> 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);
}