Add core popup overlay primitive
This commit is contained in:
@@ -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
|
||||
|
||||
113
engine/include/XCEngine/UI/Widgets/UIPopupOverlayModel.h
Normal file
113
engine/include/XCEngine/UI/Widgets/UIPopupOverlayModel.h
Normal 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
|
||||
303
engine/src/UI/Widgets/UIPopupOverlayModel.cpp
Normal file
303
engine/src/UI/Widgets/UIPopupOverlayModel.cpp
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
10
tests/UI/Core/integration/input/popup_menu_overlay/README.md
Normal file
10
tests/UI/Core/integration/input/popup_menu_overlay/README.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Core Input | Popup Menu Overlay
|
||||
|
||||
这个场景只验证 `Core popup / menu overlay primitive`:
|
||||
|
||||
- 根 popup 打开与关闭
|
||||
- submenu 展开与分支关闭
|
||||
- overlay 空白区点击 dismiss
|
||||
- popup 打开时阻断底层按钮命中
|
||||
|
||||
不验证 Editor 菜单栏,不验证业务命令。
|
||||
18
tests/UI/Core/integration/input/popup_menu_overlay/View.xcui
Normal file
18
tests/UI/Core/integration/input/popup_menu_overlay/View.xcui
Normal 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>
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
200
tests/UI/Core/unit/test_ui_popup_overlay_model.cpp
Normal file
200
tests/UI/Core/unit/test_ui_popup_overlay_model.cpp
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user