diff --git a/new_editor/CMakeLists.txt b/new_editor/CMakeLists.txt index d9661639..a0a299c2 100644 --- a/new_editor/CMakeLists.txt +++ b/new_editor/CMakeLists.txt @@ -16,6 +16,7 @@ add_library(XCUIEditorLib STATIC src/Core/UIEditorCommandDispatcher.cpp src/Core/UIEditorCommandRegistry.cpp src/Core/UIEditorMenuModel.cpp + src/Core/UIEditorMenuSession.cpp src/Core/UIEditorPanelRegistry.cpp src/Core/UIEditorShortcutManager.cpp src/Core/UIEditorWorkspaceLayoutPersistence.cpp @@ -23,6 +24,8 @@ add_library(XCUIEditorLib STATIC src/Core/UIEditorWorkspaceModel.cpp src/Core/UIEditorWorkspaceSession.cpp src/Widgets/UIEditorCollectionPrimitives.cpp + src/Widgets/UIEditorPanelFrame.cpp + src/Widgets/UIEditorTabStrip.cpp ) target_include_directories(XCUIEditorLib diff --git a/new_editor/include/XCEditor/Core/UIEditorMenuSession.h b/new_editor/include/XCEditor/Core/UIEditorMenuSession.h new file mode 100644 index 00000000..1f0fa204 --- /dev/null +++ b/new_editor/include/XCEditor/Core/UIEditorMenuSession.h @@ -0,0 +1,102 @@ +#pragma once + +#include +#include + +#include +#include +#include + +namespace XCEngine::UI::Editor { + +struct UIEditorMenuPopupState { + std::string popupId = {}; + std::string menuId = {}; + std::string itemId = {}; + + [[nodiscard]] bool IsRootPopup() const { + return itemId.empty(); + } +}; + +struct UIEditorMenuSessionMutationResult { + bool changed = false; + std::string openRootMenuId = {}; + std::string openedPopupId = {}; + std::vector closedPopupIds = {}; + Widgets::UIPopupDismissReason dismissReason = + Widgets::UIPopupDismissReason::None; + + [[nodiscard]] bool HasOpenMenu() const { + return !openRootMenuId.empty(); + } +}; + +class UIEditorMenuSession { +public: + const Widgets::UIPopupOverlayModel& GetPopupOverlayModel() const { + return m_popupOverlayModel; + } + + const std::vector& GetPopupStates() const { + return m_popupStates; + } + + const std::vector& GetOpenSubmenuItemIds() const { + return m_openSubmenuItemIds; + } + + std::string_view GetOpenRootMenuId() const { + return m_openRootMenuId; + } + + [[nodiscard]] bool HasOpenMenu() const { + return !m_openRootMenuId.empty(); + } + + [[nodiscard]] bool IsMenuOpen(std::string_view menuId) const { + return !m_openRootMenuId.empty() && m_openRootMenuId == menuId; + } + + [[nodiscard]] bool IsPopupOpen(std::string_view popupId) const; + + const UIEditorMenuPopupState* FindPopupState(std::string_view popupId) const; + + void Reset(); + + UIEditorMenuSessionMutationResult OpenMenuBarRoot( + std::string_view menuId, + Widgets::UIPopupOverlayEntry entry); + + UIEditorMenuSessionMutationResult HoverMenuBarRoot( + std::string_view menuId, + Widgets::UIPopupOverlayEntry entry); + + UIEditorMenuSessionMutationResult HoverSubmenu( + std::string_view itemId, + Widgets::UIPopupOverlayEntry entry); + + UIEditorMenuSessionMutationResult CloseAll( + Widgets::UIPopupDismissReason dismissReason = + Widgets::UIPopupDismissReason::Programmatic); + + UIEditorMenuSessionMutationResult DismissFromEscape(); + UIEditorMenuSessionMutationResult DismissFromPointerDown( + const UIInputPath& hitPath); + UIEditorMenuSessionMutationResult DismissFromFocusLoss( + const UIInputPath& focusedPath); + +private: + UIEditorMenuSessionMutationResult BuildResult( + const Widgets::UIPopupOverlayMutationResult& mutation) const; + + void RemoveClosedPopupStates(const std::vector& closedPopupIds); + void RebuildDerivedState(); + + std::string m_openRootMenuId = {}; + Widgets::UIPopupOverlayModel m_popupOverlayModel = {}; + std::vector m_popupStates = {}; + std::vector m_openSubmenuItemIds = {}; +}; + +} // namespace XCEngine::UI::Editor diff --git a/new_editor/include/XCEditor/Widgets/UIEditorPanelFrame.h b/new_editor/include/XCEditor/Widgets/UIEditorPanelFrame.h new file mode 100644 index 00000000..355adf90 --- /dev/null +++ b/new_editor/include/XCEditor/Widgets/UIEditorPanelFrame.h @@ -0,0 +1,160 @@ +#pragma once + +#include + +#include +#include + +namespace XCEngine::UI::Editor::Widgets { + +enum class UIEditorPanelFrameAction : std::uint8_t { + None = 0, + Pin, + Close +}; + +enum class UIEditorPanelFrameHitTarget : std::uint8_t { + None = 0, + Header, + Body, + Footer, + PinButton, + CloseButton +}; + +struct UIEditorPanelFrameState { + bool active = false; + bool hovered = false; + bool focused = false; + bool pinned = false; + bool closable = true; + bool pinnable = true; + bool showFooter = false; + bool pinHovered = false; + bool closeHovered = false; +}; + +struct UIEditorPanelFrameText { + std::string_view title = {}; + std::string_view subtitle = {}; + std::string_view footer = {}; +}; + +struct UIEditorPanelFrameMetrics { + float cornerRounding = 10.0f; + float headerHeight = 36.0f; + float footerHeight = 24.0f; + float contentPadding = 12.0f; + float titleInsetX = 14.0f; + float titleInsetY = 9.0f; + float subtitleInsetY = 22.0f; + float footerInsetX = 14.0f; + float footerInsetY = 6.0f; + float actionButtonExtent = 18.0f; + float actionInsetX = 12.0f; + float actionGap = 6.0f; + float baseBorderThickness = 1.0f; + float hoveredBorderThickness = 1.25f; + float activeBorderThickness = 1.5f; + float focusedBorderThickness = 2.0f; +}; + +struct UIEditorPanelFramePalette { + ::XCEngine::UI::UIColor surfaceColor = + ::XCEngine::UI::UIColor(0.16f, 0.16f, 0.16f, 1.0f); + ::XCEngine::UI::UIColor headerColor = + ::XCEngine::UI::UIColor(0.20f, 0.20f, 0.20f, 1.0f); + ::XCEngine::UI::UIColor footerColor = + ::XCEngine::UI::UIColor(0.19f, 0.19f, 0.19f, 1.0f); + ::XCEngine::UI::UIColor borderColor = + ::XCEngine::UI::UIColor(0.30f, 0.30f, 0.30f, 1.0f); + ::XCEngine::UI::UIColor hoveredBorderColor = + ::XCEngine::UI::UIColor(0.42f, 0.42f, 0.42f, 1.0f); + ::XCEngine::UI::UIColor activeBorderColor = + ::XCEngine::UI::UIColor(0.58f, 0.58f, 0.58f, 1.0f); + ::XCEngine::UI::UIColor focusedBorderColor = + ::XCEngine::UI::UIColor(0.84f, 0.84f, 0.84f, 1.0f); + ::XCEngine::UI::UIColor textPrimary = + ::XCEngine::UI::UIColor(0.94f, 0.94f, 0.94f, 1.0f); + ::XCEngine::UI::UIColor textSecondary = + ::XCEngine::UI::UIColor(0.71f, 0.71f, 0.71f, 1.0f); + ::XCEngine::UI::UIColor textMuted = + ::XCEngine::UI::UIColor(0.60f, 0.60f, 0.60f, 1.0f); + ::XCEngine::UI::UIColor actionButtonColor = + ::XCEngine::UI::UIColor(0.24f, 0.24f, 0.24f, 1.0f); + ::XCEngine::UI::UIColor actionButtonHoveredColor = + ::XCEngine::UI::UIColor(0.34f, 0.34f, 0.34f, 1.0f); + ::XCEngine::UI::UIColor actionButtonSelectedColor = + ::XCEngine::UI::UIColor(0.48f, 0.48f, 0.48f, 1.0f); + ::XCEngine::UI::UIColor actionButtonBorderColor = + ::XCEngine::UI::UIColor(0.52f, 0.52f, 0.52f, 1.0f); + ::XCEngine::UI::UIColor actionGlyphColor = + ::XCEngine::UI::UIColor(0.92f, 0.92f, 0.92f, 1.0f); +}; + +struct UIEditorPanelFrameLayout { + ::XCEngine::UI::UIRect frameRect = {}; + ::XCEngine::UI::UIRect headerRect = {}; + ::XCEngine::UI::UIRect bodyRect = {}; + ::XCEngine::UI::UIRect footerRect = {}; + ::XCEngine::UI::UIRect pinButtonRect = {}; + ::XCEngine::UI::UIRect closeButtonRect = {}; + bool hasFooter = false; + bool showPinButton = false; + bool showCloseButton = false; +}; + +bool IsUIEditorPanelFramePointInside( + const ::XCEngine::UI::UIRect& rect, + const ::XCEngine::UI::UIPoint& point); + +bool IsUIEditorPanelFramePinButtonVisible(const UIEditorPanelFrameState& state); +bool IsUIEditorPanelFrameCloseButtonVisible(const UIEditorPanelFrameState& state); + +UIEditorPanelFrameLayout BuildUIEditorPanelFrameLayout( + const ::XCEngine::UI::UIRect& frameRect, + const UIEditorPanelFrameState& state, + const UIEditorPanelFrameMetrics& metrics = {}); + +::XCEngine::UI::UIColor ResolveUIEditorPanelFrameBorderColor( + const UIEditorPanelFrameState& state, + const UIEditorPanelFramePalette& palette = {}); + +float ResolveUIEditorPanelFrameBorderThickness( + const UIEditorPanelFrameState& state, + const UIEditorPanelFrameMetrics& metrics = {}); + +UIEditorPanelFrameAction HitTestUIEditorPanelFrameAction( + const UIEditorPanelFrameLayout& layout, + const UIEditorPanelFrameState& state, + const ::XCEngine::UI::UIPoint& point); + +UIEditorPanelFrameHitTarget HitTestUIEditorPanelFrame( + const UIEditorPanelFrameLayout& layout, + const UIEditorPanelFrameState& state, + const ::XCEngine::UI::UIPoint& point); + +void AppendUIEditorPanelFrameBackground( + ::XCEngine::UI::UIDrawList& drawList, + const UIEditorPanelFrameLayout& layout, + const UIEditorPanelFrameState& state, + const UIEditorPanelFramePalette& palette = {}, + const UIEditorPanelFrameMetrics& metrics = {}); + +void AppendUIEditorPanelFrameForeground( + ::XCEngine::UI::UIDrawList& drawList, + const UIEditorPanelFrameLayout& layout, + const UIEditorPanelFrameState& state, + const UIEditorPanelFrameText& text, + const UIEditorPanelFramePalette& palette = {}, + const UIEditorPanelFrameMetrics& metrics = {}); + +void AppendUIEditorPanelFrame( + ::XCEngine::UI::UIDrawList& drawList, + const ::XCEngine::UI::UIRect& frameRect, + const UIEditorPanelFrameState& state, + const UIEditorPanelFrameText& text, + const UIEditorPanelFramePalette& palette = {}, + const UIEditorPanelFrameMetrics& metrics = {}); + +} // namespace XCEngine::UI::Editor::Widgets diff --git a/new_editor/include/XCEditor/Widgets/UIEditorTabStrip.h b/new_editor/include/XCEditor/Widgets/UIEditorTabStrip.h new file mode 100644 index 00000000..0316d364 --- /dev/null +++ b/new_editor/include/XCEditor/Widgets/UIEditorTabStrip.h @@ -0,0 +1,157 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace XCEngine::UI::Editor::Widgets { + +inline constexpr std::size_t UIEditorTabStripInvalidIndex = + ::XCEngine::UI::Widgets::UITabStripModel::InvalidIndex; + +struct UIEditorTabStripItem { + std::string tabId = {}; + std::string title = {}; + bool closable = true; + float desiredHeaderLabelWidth = 0.0f; +}; + +struct UIEditorTabStripState { + std::size_t selectedIndex = UIEditorTabStripInvalidIndex; + std::size_t hoveredIndex = UIEditorTabStripInvalidIndex; + std::size_t closeHoveredIndex = UIEditorTabStripInvalidIndex; + bool focused = false; +}; + +struct UIEditorTabStripMetrics { + ::XCEngine::UI::Layout::UITabStripMetrics layoutMetrics = + ::XCEngine::UI::Layout::UITabStripMetrics{ 32.0f, 88.0f, 12.0f, 1.0f }; + float estimatedGlyphWidth = 7.0f; + float closeButtonExtent = 14.0f; + float closeButtonGap = 8.0f; + float closeInsetRight = 12.0f; + float closeInsetY = 0.0f; + float labelInsetX = 12.0f; + float labelInsetY = -1.0f; + float baseBorderThickness = 1.0f; + float selectedBorderThickness = 1.5f; + float focusedBorderThickness = 2.0f; +}; + +struct UIEditorTabStripPalette { + ::XCEngine::UI::UIColor stripBackgroundColor = + ::XCEngine::UI::UIColor(0.17f, 0.17f, 0.17f, 1.0f); + ::XCEngine::UI::UIColor headerBackgroundColor = + ::XCEngine::UI::UIColor(0.20f, 0.20f, 0.20f, 1.0f); + ::XCEngine::UI::UIColor contentBackgroundColor = + ::XCEngine::UI::UIColor(0.21f, 0.21f, 0.21f, 1.0f); + ::XCEngine::UI::UIColor stripBorderColor = + ::XCEngine::UI::UIColor(0.30f, 0.30f, 0.30f, 1.0f); + ::XCEngine::UI::UIColor focusedBorderColor = + ::XCEngine::UI::UIColor(0.86f, 0.86f, 0.86f, 1.0f); + ::XCEngine::UI::UIColor tabColor = + ::XCEngine::UI::UIColor(0.23f, 0.23f, 0.23f, 1.0f); + ::XCEngine::UI::UIColor tabHoveredColor = + ::XCEngine::UI::UIColor(0.27f, 0.27f, 0.27f, 1.0f); + ::XCEngine::UI::UIColor tabSelectedColor = + ::XCEngine::UI::UIColor(0.29f, 0.29f, 0.29f, 1.0f); + ::XCEngine::UI::UIColor tabBorderColor = + ::XCEngine::UI::UIColor(0.32f, 0.32f, 0.32f, 1.0f); + ::XCEngine::UI::UIColor tabHoveredBorderColor = + ::XCEngine::UI::UIColor(0.46f, 0.46f, 0.46f, 1.0f); + ::XCEngine::UI::UIColor tabSelectedBorderColor = + ::XCEngine::UI::UIColor(0.72f, 0.72f, 0.72f, 1.0f); + ::XCEngine::UI::UIColor textPrimary = + ::XCEngine::UI::UIColor(0.94f, 0.94f, 0.94f, 1.0f); + ::XCEngine::UI::UIColor textSecondary = + ::XCEngine::UI::UIColor(0.76f, 0.76f, 0.76f, 1.0f); + ::XCEngine::UI::UIColor textMuted = + ::XCEngine::UI::UIColor(0.60f, 0.60f, 0.60f, 1.0f); + ::XCEngine::UI::UIColor closeButtonColor = + ::XCEngine::UI::UIColor(0.25f, 0.25f, 0.25f, 1.0f); + ::XCEngine::UI::UIColor closeButtonHoveredColor = + ::XCEngine::UI::UIColor(0.36f, 0.36f, 0.36f, 1.0f); + ::XCEngine::UI::UIColor closeButtonBorderColor = + ::XCEngine::UI::UIColor(0.44f, 0.44f, 0.44f, 1.0f); + ::XCEngine::UI::UIColor closeGlyphColor = + ::XCEngine::UI::UIColor(0.92f, 0.92f, 0.92f, 1.0f); +}; + +struct UIEditorTabStripLayout { + ::XCEngine::UI::UIRect bounds = {}; + ::XCEngine::UI::UIRect headerRect = {}; + ::XCEngine::UI::UIRect contentRect = {}; + std::vector<::XCEngine::UI::UIRect> tabHeaderRects = {}; + std::vector<::XCEngine::UI::UIRect> closeButtonRects = {}; + std::vector showCloseButtons = {}; + std::size_t selectedIndex = UIEditorTabStripInvalidIndex; +}; + +enum class UIEditorTabStripHitTargetKind : std::uint8_t { + None = 0, + HeaderBackground, + Tab, + CloseButton, + Content +}; + +struct UIEditorTabStripHitTarget { + UIEditorTabStripHitTargetKind kind = UIEditorTabStripHitTargetKind::None; + std::size_t index = UIEditorTabStripInvalidIndex; +}; + +float ResolveUIEditorTabStripDesiredHeaderLabelWidth( + const UIEditorTabStripItem& item, + const UIEditorTabStripMetrics& metrics = {}); + +std::size_t ResolveUIEditorTabStripSelectedIndex( + const std::vector& items, + std::string_view selectedTabId, + std::size_t fallbackIndex = UIEditorTabStripInvalidIndex); + +std::size_t ResolveUIEditorTabStripSelectedIndexAfterClose( + std::size_t selectedIndex, + std::size_t closedIndex, + std::size_t itemCountBeforeClose); + +UIEditorTabStripLayout BuildUIEditorTabStripLayout( + const ::XCEngine::UI::UIRect& bounds, + const std::vector& items, + const UIEditorTabStripState& state, + const UIEditorTabStripMetrics& metrics = {}); + +UIEditorTabStripHitTarget HitTestUIEditorTabStrip( + const UIEditorTabStripLayout& layout, + const UIEditorTabStripState& state, + const ::XCEngine::UI::UIPoint& point); + +void AppendUIEditorTabStripBackground( + ::XCEngine::UI::UIDrawList& drawList, + const UIEditorTabStripLayout& layout, + const UIEditorTabStripState& state, + const UIEditorTabStripPalette& palette = {}, + const UIEditorTabStripMetrics& metrics = {}); + +void AppendUIEditorTabStripForeground( + ::XCEngine::UI::UIDrawList& drawList, + const UIEditorTabStripLayout& layout, + const std::vector& items, + const UIEditorTabStripState& state, + const UIEditorTabStripPalette& palette = {}, + const UIEditorTabStripMetrics& metrics = {}); + +void AppendUIEditorTabStrip( + ::XCEngine::UI::UIDrawList& drawList, + const ::XCEngine::UI::UIRect& bounds, + const std::vector& items, + const UIEditorTabStripState& state, + const UIEditorTabStripPalette& palette = {}, + const UIEditorTabStripMetrics& metrics = {}); + +} // namespace XCEngine::UI::Editor::Widgets diff --git a/new_editor/src/Core/UIEditorMenuSession.cpp b/new_editor/src/Core/UIEditorMenuSession.cpp new file mode 100644 index 00000000..627165b2 --- /dev/null +++ b/new_editor/src/Core/UIEditorMenuSession.cpp @@ -0,0 +1,194 @@ +#include + +#include +#include + +namespace XCEngine::UI::Editor { + +bool UIEditorMenuSession::IsPopupOpen(std::string_view popupId) const { + return m_popupOverlayModel.FindPopup(popupId) != nullptr; +} + +const UIEditorMenuPopupState* UIEditorMenuSession::FindPopupState( + std::string_view popupId) const { + for (const UIEditorMenuPopupState& state : m_popupStates) { + if (state.popupId == popupId) { + return &state; + } + } + + return nullptr; +} + +void UIEditorMenuSession::Reset() { + m_openRootMenuId.clear(); + m_popupOverlayModel = {}; + m_popupStates.clear(); + m_openSubmenuItemIds.clear(); +} + +UIEditorMenuSessionMutationResult UIEditorMenuSession::OpenMenuBarRoot( + std::string_view menuId, + Widgets::UIPopupOverlayEntry entry) { + if (menuId.empty() || entry.popupId.empty()) { + return BuildResult({}); + } + + const Widgets::UIPopupOverlayEntry* rootPopup = m_popupOverlayModel.GetRootPopup(); + if (rootPopup != nullptr && + m_openRootMenuId == menuId && + rootPopup->popupId == entry.popupId) { + return BuildResult({}); + } + + entry.parentPopupId.clear(); + const Widgets::UIPopupOverlayMutationResult mutation = + m_popupOverlayModel.OpenPopup(std::move(entry)); + if (mutation.changed) { + m_popupStates.clear(); + + UIEditorMenuPopupState rootState = {}; + rootState.popupId = mutation.openedPopupId; + rootState.menuId = std::string(menuId); + m_popupStates.push_back(std::move(rootState)); + RebuildDerivedState(); + } + + return BuildResult(mutation); +} + +UIEditorMenuSessionMutationResult UIEditorMenuSession::HoverMenuBarRoot( + std::string_view menuId, + Widgets::UIPopupOverlayEntry entry) { + if (!HasOpenMenu() || IsMenuOpen(menuId)) { + return BuildResult({}); + } + + return OpenMenuBarRoot(menuId, std::move(entry)); +} + +UIEditorMenuSessionMutationResult UIEditorMenuSession::HoverSubmenu( + std::string_view itemId, + Widgets::UIPopupOverlayEntry entry) { + if (!HasOpenMenu() || + itemId.empty() || + entry.popupId.empty() || + entry.parentPopupId.empty() || + m_popupOverlayModel.FindPopup(entry.parentPopupId) == nullptr) { + return BuildResult({}); + } + + const Widgets::UIPopupOverlayEntry* existingPopup = + m_popupOverlayModel.FindPopup(entry.popupId); + if (existingPopup != nullptr && + existingPopup->parentPopupId == entry.parentPopupId) { + return BuildResult({}); + } + + const Widgets::UIPopupOverlayMutationResult mutation = + m_popupOverlayModel.OpenPopup(std::move(entry)); + if (mutation.changed) { + RemoveClosedPopupStates(mutation.closedPopupIds); + + UIEditorMenuPopupState popupState = {}; + popupState.popupId = mutation.openedPopupId; + popupState.menuId = m_openRootMenuId; + popupState.itemId = std::string(itemId); + m_popupStates.push_back(std::move(popupState)); + RebuildDerivedState(); + } + + return BuildResult(mutation); +} + +UIEditorMenuSessionMutationResult UIEditorMenuSession::CloseAll( + Widgets::UIPopupDismissReason dismissReason) { + const Widgets::UIPopupOverlayMutationResult mutation = + m_popupOverlayModel.CloseAll(dismissReason); + if (mutation.changed) { + RemoveClosedPopupStates(mutation.closedPopupIds); + RebuildDerivedState(); + } + + return BuildResult(mutation); +} + +UIEditorMenuSessionMutationResult UIEditorMenuSession::DismissFromEscape() { + const Widgets::UIPopupOverlayMutationResult mutation = + m_popupOverlayModel.DismissFromEscape(); + if (mutation.changed) { + RemoveClosedPopupStates(mutation.closedPopupIds); + RebuildDerivedState(); + } + + return BuildResult(mutation); +} + +UIEditorMenuSessionMutationResult UIEditorMenuSession::DismissFromPointerDown( + const UIInputPath& hitPath) { + const Widgets::UIPopupOverlayMutationResult mutation = + m_popupOverlayModel.DismissFromPointerDown(hitPath); + if (mutation.changed) { + RemoveClosedPopupStates(mutation.closedPopupIds); + RebuildDerivedState(); + } + + return BuildResult(mutation); +} + +UIEditorMenuSessionMutationResult UIEditorMenuSession::DismissFromFocusLoss( + const UIInputPath& focusedPath) { + const Widgets::UIPopupOverlayMutationResult mutation = + m_popupOverlayModel.DismissFromFocusLoss(focusedPath); + if (mutation.changed) { + RemoveClosedPopupStates(mutation.closedPopupIds); + RebuildDerivedState(); + } + + return BuildResult(mutation); +} + +UIEditorMenuSessionMutationResult UIEditorMenuSession::BuildResult( + const Widgets::UIPopupOverlayMutationResult& mutation) const { + UIEditorMenuSessionMutationResult result = {}; + result.changed = mutation.changed; + result.openRootMenuId = m_openRootMenuId; + result.openedPopupId = mutation.openedPopupId; + result.closedPopupIds = mutation.closedPopupIds; + result.dismissReason = mutation.dismissReason; + return result; +} + +void UIEditorMenuSession::RemoveClosedPopupStates( + const std::vector& closedPopupIds) { + if (closedPopupIds.empty()) { + return; + } + + std::erase_if( + m_popupStates, + [&closedPopupIds](const UIEditorMenuPopupState& state) { + return std::find( + closedPopupIds.begin(), + closedPopupIds.end(), + state.popupId) != closedPopupIds.end(); + }); +} + +void UIEditorMenuSession::RebuildDerivedState() { + m_openSubmenuItemIds.clear(); + + if (m_popupStates.empty() || !m_popupOverlayModel.HasOpenPopups()) { + m_openRootMenuId.clear(); + return; + } + + m_openRootMenuId = m_popupStates.front().menuId; + for (std::size_t index = 1u; index < m_popupStates.size(); ++index) { + if (!m_popupStates[index].itemId.empty()) { + m_openSubmenuItemIds.push_back(m_popupStates[index].itemId); + } + } +} + +} // namespace XCEngine::UI::Editor diff --git a/new_editor/src/Widgets/UIEditorPanelFrame.cpp b/new_editor/src/Widgets/UIEditorPanelFrame.cpp new file mode 100644 index 00000000..9757cf6f --- /dev/null +++ b/new_editor/src/Widgets/UIEditorPanelFrame.cpp @@ -0,0 +1,341 @@ +#include + +#include +#include + +namespace XCEngine::UI::Editor::Widgets { + +namespace { + +using ::XCEngine::UI::UIColor; +using ::XCEngine::UI::UIDrawList; +using ::XCEngine::UI::UIPoint; +using ::XCEngine::UI::UIRect; + +float ClampNonNegative(float value) { + return (std::max)(value, 0.0f); +} + +float ResolveActionButtonExtent( + const UIEditorPanelFrameMetrics& metrics, + float headerHeight) { + const float inset = (std::max)(metrics.actionInsetX, 0.0f); + return (std::min)( + (std::max)(metrics.actionButtonExtent, 0.0f), + (std::max)(headerHeight - inset * 2.0f, 0.0f)); +} + +UIColor ResolveActionFillColor( + bool selected, + bool hovered, + const UIEditorPanelFramePalette& palette) { + if (selected) { + return palette.actionButtonSelectedColor; + } + + if (hovered) { + return palette.actionButtonHoveredColor; + } + + return palette.actionButtonColor; +} + +void AppendActionButton( + UIDrawList& drawList, + const UIRect& rect, + std::string_view glyph, + bool selected, + bool hovered, + const UIEditorPanelFramePalette& palette) { + if (rect.width <= 0.0f || rect.height <= 0.0f) { + return; + } + + drawList.AddFilledRect( + rect, + ResolveActionFillColor(selected, hovered, palette), + 5.0f); + drawList.AddRectOutline( + rect, + palette.actionButtonBorderColor, + 1.0f, + 5.0f); + drawList.AddText( + UIPoint(rect.x + 5.0f, rect.y + 2.0f), + std::string(glyph), + palette.actionGlyphColor, + 12.0f); +} + +} // namespace + +bool IsUIEditorPanelFramePointInside( + const UIRect& rect, + const UIPoint& point) { + return point.x >= rect.x && + point.x <= rect.x + rect.width && + point.y >= rect.y && + point.y <= rect.y + rect.height; +} + +bool IsUIEditorPanelFramePinButtonVisible(const UIEditorPanelFrameState& state) { + return state.pinnable; +} + +bool IsUIEditorPanelFrameCloseButtonVisible(const UIEditorPanelFrameState& state) { + return state.closable; +} + +UIEditorPanelFrameLayout BuildUIEditorPanelFrameLayout( + const UIRect& frameRect, + const UIEditorPanelFrameState& state, + const UIEditorPanelFrameMetrics& metrics) { + UIEditorPanelFrameLayout layout = {}; + layout.frameRect = UIRect( + frameRect.x, + frameRect.y, + ClampNonNegative(frameRect.width), + ClampNonNegative(frameRect.height)); + + const float headerHeight = + (std::min)(ClampNonNegative(metrics.headerHeight), layout.frameRect.height); + const float footerHeight = + state.showFooter + ? (std::min)( + ClampNonNegative(metrics.footerHeight), + (std::max)(layout.frameRect.height - headerHeight, 0.0f)) + : 0.0f; + + layout.hasFooter = footerHeight > 0.0f; + layout.showPinButton = IsUIEditorPanelFramePinButtonVisible(state); + layout.showCloseButton = IsUIEditorPanelFrameCloseButtonVisible(state); + + layout.headerRect = UIRect( + layout.frameRect.x, + layout.frameRect.y, + layout.frameRect.width, + headerHeight); + + if (layout.hasFooter) { + layout.footerRect = UIRect( + layout.frameRect.x, + layout.frameRect.y + layout.frameRect.height - footerHeight, + layout.frameRect.width, + footerHeight); + } + + const float bodyBandTop = layout.headerRect.y + layout.headerRect.height; + const float bodyBandBottom = layout.hasFooter + ? layout.footerRect.y + : layout.frameRect.y + layout.frameRect.height; + const float contentPadding = ClampNonNegative(metrics.contentPadding); + + layout.bodyRect = UIRect( + layout.frameRect.x + contentPadding, + bodyBandTop + contentPadding, + (std::max)(layout.frameRect.width - contentPadding * 2.0f, 0.0f), + (std::max)(bodyBandBottom - bodyBandTop - contentPadding * 2.0f, 0.0f)); + + if (layout.headerRect.height <= 0.0f) { + return layout; + } + + const float buttonExtent = ResolveActionButtonExtent(metrics, layout.headerRect.height); + if (buttonExtent <= 0.0f) { + return layout; + } + + const float buttonY = layout.headerRect.y + (layout.headerRect.height - buttonExtent) * 0.5f; + float buttonRight = layout.headerRect.x + layout.headerRect.width - ClampNonNegative(metrics.actionInsetX); + + if (layout.showCloseButton) { + layout.closeButtonRect = UIRect( + buttonRight - buttonExtent, + buttonY, + buttonExtent, + buttonExtent); + buttonRight = layout.closeButtonRect.x - ClampNonNegative(metrics.actionGap); + } + + if (layout.showPinButton) { + layout.pinButtonRect = UIRect( + buttonRight - buttonExtent, + buttonY, + buttonExtent, + buttonExtent); + } + + return layout; +} + +UIColor ResolveUIEditorPanelFrameBorderColor( + const UIEditorPanelFrameState& state, + const UIEditorPanelFramePalette& palette) { + if (state.focused) { + return palette.focusedBorderColor; + } + + if (state.active) { + return palette.activeBorderColor; + } + + if (state.hovered) { + return palette.hoveredBorderColor; + } + + return palette.borderColor; +} + +float ResolveUIEditorPanelFrameBorderThickness( + const UIEditorPanelFrameState& state, + const UIEditorPanelFrameMetrics& metrics) { + if (state.focused) { + return metrics.focusedBorderThickness; + } + + if (state.active) { + return metrics.activeBorderThickness; + } + + if (state.hovered) { + return metrics.hoveredBorderThickness; + } + + return metrics.baseBorderThickness; +} + +UIEditorPanelFrameAction HitTestUIEditorPanelFrameAction( + const UIEditorPanelFrameLayout& layout, + const UIEditorPanelFrameState& state, + const UIPoint& point) { + if (layout.showPinButton && + IsUIEditorPanelFramePinButtonVisible(state) && + IsUIEditorPanelFramePointInside(layout.pinButtonRect, point)) { + return UIEditorPanelFrameAction::Pin; + } + + if (layout.showCloseButton && + IsUIEditorPanelFrameCloseButtonVisible(state) && + IsUIEditorPanelFramePointInside(layout.closeButtonRect, point)) { + return UIEditorPanelFrameAction::Close; + } + + return UIEditorPanelFrameAction::None; +} + +UIEditorPanelFrameHitTarget HitTestUIEditorPanelFrame( + const UIEditorPanelFrameLayout& layout, + const UIEditorPanelFrameState& state, + const UIPoint& point) { + if (!IsUIEditorPanelFramePointInside(layout.frameRect, point)) { + return UIEditorPanelFrameHitTarget::None; + } + + switch (HitTestUIEditorPanelFrameAction(layout, state, point)) { + case UIEditorPanelFrameAction::Pin: + return UIEditorPanelFrameHitTarget::PinButton; + case UIEditorPanelFrameAction::Close: + return UIEditorPanelFrameHitTarget::CloseButton; + default: + break; + } + + if (IsUIEditorPanelFramePointInside(layout.headerRect, point)) { + return UIEditorPanelFrameHitTarget::Header; + } + + if (layout.hasFooter && IsUIEditorPanelFramePointInside(layout.footerRect, point)) { + return UIEditorPanelFrameHitTarget::Footer; + } + + if (IsUIEditorPanelFramePointInside(layout.bodyRect, point)) { + return UIEditorPanelFrameHitTarget::Body; + } + + return UIEditorPanelFrameHitTarget::Body; +} + +void AppendUIEditorPanelFrameBackground( + UIDrawList& drawList, + const UIEditorPanelFrameLayout& layout, + const UIEditorPanelFrameState& state, + const UIEditorPanelFramePalette& palette, + const UIEditorPanelFrameMetrics& metrics) { + drawList.AddFilledRect(layout.frameRect, palette.surfaceColor, metrics.cornerRounding); + drawList.AddRectOutline( + layout.frameRect, + ResolveUIEditorPanelFrameBorderColor(state, palette), + ResolveUIEditorPanelFrameBorderThickness(state, metrics), + metrics.cornerRounding); + drawList.AddFilledRect(layout.headerRect, palette.headerColor, metrics.cornerRounding); + if (layout.hasFooter) { + drawList.AddFilledRect(layout.footerRect, palette.footerColor, metrics.cornerRounding); + } +} + +void AppendUIEditorPanelFrameForeground( + UIDrawList& drawList, + const UIEditorPanelFrameLayout& layout, + const UIEditorPanelFrameState& state, + const UIEditorPanelFrameText& text, + const UIEditorPanelFramePalette& palette, + const UIEditorPanelFrameMetrics& metrics) { + if (!text.title.empty()) { + drawList.AddText( + UIPoint(layout.frameRect.x + metrics.titleInsetX, layout.headerRect.y + metrics.titleInsetY), + std::string(text.title), + palette.textPrimary, + 16.0f); + } + + if (!text.subtitle.empty()) { + drawList.AddText( + UIPoint(layout.frameRect.x + metrics.titleInsetX, layout.headerRect.y + metrics.subtitleInsetY), + std::string(text.subtitle), + palette.textSecondary, + 12.0f); + } + + if (layout.hasFooter && !text.footer.empty()) { + drawList.AddText( + UIPoint(layout.footerRect.x + metrics.footerInsetX, layout.footerRect.y + metrics.footerInsetY), + std::string(text.footer), + palette.textMuted, + 11.0f); + } + + if (layout.showPinButton) { + AppendActionButton( + drawList, + layout.pinButtonRect, + "P", + state.pinned, + state.pinHovered, + palette); + } + + if (layout.showCloseButton) { + AppendActionButton( + drawList, + layout.closeButtonRect, + "X", + false, + state.closeHovered, + palette); + } +} + +void AppendUIEditorPanelFrame( + UIDrawList& drawList, + const UIRect& frameRect, + const UIEditorPanelFrameState& state, + const UIEditorPanelFrameText& text, + const UIEditorPanelFramePalette& palette, + const UIEditorPanelFrameMetrics& metrics) { + const UIEditorPanelFrameLayout layout = + BuildUIEditorPanelFrameLayout(frameRect, state, metrics); + AppendUIEditorPanelFrameBackground(drawList, layout, state, palette, metrics); + AppendUIEditorPanelFrameForeground(drawList, layout, state, text, palette, metrics); +} + +} // namespace XCEngine::UI::Editor::Widgets diff --git a/new_editor/src/Widgets/UIEditorTabStrip.cpp b/new_editor/src/Widgets/UIEditorTabStrip.cpp new file mode 100644 index 00000000..d560d6c8 --- /dev/null +++ b/new_editor/src/Widgets/UIEditorTabStrip.cpp @@ -0,0 +1,396 @@ +#include + +#include +#include +#include +#include + +namespace XCEngine::UI::Editor::Widgets { + +namespace { + +using ::XCEngine::UI::UIColor; +using ::XCEngine::UI::UIDrawList; +using ::XCEngine::UI::UIPoint; +using ::XCEngine::UI::UIRect; +using ::XCEngine::UI::Layout::ArrangeUITabStrip; +using ::XCEngine::UI::Layout::MeasureUITabStripHeaderWidth; + +constexpr float kTabRounding = 7.0f; +constexpr float kStripRounding = 8.0f; +constexpr float kHeaderFontSize = 13.0f; +constexpr float kCloseFontSize = 11.0f; + +float ClampNonNegative(float value) { + return (std::max)(value, 0.0f); +} + +bool IsPointInsideRect( + const UIRect& rect, + const UIPoint& point) { + return point.x >= rect.x && + point.x <= rect.x + rect.width && + point.y >= rect.y && + point.y <= rect.y + rect.height; +} + +std::size_t ResolveSelectedIndex( + std::size_t itemCount, + std::size_t selectedIndex) { + if (itemCount == 0u) { + return UIEditorTabStripInvalidIndex; + } + + if (selectedIndex == UIEditorTabStripInvalidIndex || selectedIndex >= itemCount) { + return 0u; + } + + return selectedIndex; +} + +float ResolveEstimatedLabelWidth( + const UIEditorTabStripItem& item, + const UIEditorTabStripMetrics& metrics) { + if (item.desiredHeaderLabelWidth > 0.0f) { + return item.desiredHeaderLabelWidth; + } + + return static_cast(item.title.size()) * ClampNonNegative(metrics.estimatedGlyphWidth); +} + +float ResolveTabTextTop( + const UIRect& rect, + const UIEditorTabStripMetrics& metrics) { + return rect.y + (std::max)(0.0f, (rect.height - kHeaderFontSize) * 0.5f) + metrics.labelInsetY; +} + +float ResolveCloseTextTop(const UIRect& rect) { + return rect.y + (std::max)(0.0f, (rect.height - kCloseFontSize) * 0.5f) - 0.5f; +} + +UIColor ResolveStripBorderColor( + const UIEditorTabStripState& state, + const UIEditorTabStripPalette& palette) { + return state.focused ? palette.focusedBorderColor : palette.stripBorderColor; +} + +float ResolveStripBorderThickness( + const UIEditorTabStripState& state, + const UIEditorTabStripMetrics& metrics) { + return state.focused ? metrics.focusedBorderThickness : metrics.baseBorderThickness; +} + +UIColor ResolveTabFillColor( + bool selected, + bool hovered, + const UIEditorTabStripPalette& palette) { + if (selected) { + return palette.tabSelectedColor; + } + + if (hovered) { + return palette.tabHoveredColor; + } + + return palette.tabColor; +} + +UIColor ResolveTabBorderColor( + bool selected, + bool hovered, + bool focused, + const UIEditorTabStripPalette& palette) { + if (selected) { + return focused ? palette.focusedBorderColor : palette.tabSelectedBorderColor; + } + + if (hovered) { + return palette.tabHoveredBorderColor; + } + + return palette.tabBorderColor; +} + +float ResolveTabBorderThickness( + bool selected, + bool focused, + const UIEditorTabStripMetrics& metrics) { + if (selected) { + return focused ? metrics.focusedBorderThickness : metrics.selectedBorderThickness; + } + + return metrics.baseBorderThickness; +} + +UIRect BuildCloseButtonRect( + const UIRect& headerRect, + const UIEditorTabStripMetrics& metrics) { + const float insetY = ClampNonNegative(metrics.closeInsetY); + const float extent = (std::min)( + ClampNonNegative(metrics.closeButtonExtent), + (std::max)(headerRect.height - insetY * 2.0f, 0.0f)); + if (extent <= 0.0f) { + return {}; + } + + return UIRect( + headerRect.x + headerRect.width - ClampNonNegative(metrics.closeInsetRight) - extent, + headerRect.y + insetY + (std::max)(0.0f, headerRect.height - insetY * 2.0f - extent) * 0.5f, + extent, + extent); +} + +} // namespace + +float ResolveUIEditorTabStripDesiredHeaderLabelWidth( + const UIEditorTabStripItem& item, + const UIEditorTabStripMetrics& metrics) { + const float labelWidth = ResolveEstimatedLabelWidth(item, metrics); + const float horizontalPadding = ClampNonNegative(metrics.layoutMetrics.tabHorizontalPadding); + const float extraLeftInset = (std::max)(ClampNonNegative(metrics.labelInsetX) - horizontalPadding, 0.0f); + const float extraRightInset = (std::max)(ClampNonNegative(metrics.closeInsetRight) - horizontalPadding, 0.0f); + const float closeBudget = item.closable + ? ClampNonNegative(metrics.closeButtonExtent) + + ClampNonNegative(metrics.closeButtonGap) + + extraRightInset + : 0.0f; + return labelWidth + extraLeftInset + closeBudget; +} + +std::size_t ResolveUIEditorTabStripSelectedIndex( + const std::vector& items, + std::string_view selectedTabId, + std::size_t fallbackIndex) { + for (std::size_t index = 0; index < items.size(); ++index) { + if (items[index].tabId == selectedTabId) { + return index; + } + } + + if (items.empty()) { + return UIEditorTabStripInvalidIndex; + } + + return ResolveSelectedIndex(items.size(), fallbackIndex); +} + +std::size_t ResolveUIEditorTabStripSelectedIndexAfterClose( + std::size_t selectedIndex, + std::size_t closedIndex, + std::size_t itemCountBeforeClose) { + if (itemCountBeforeClose == 0u || closedIndex >= itemCountBeforeClose) { + return UIEditorTabStripInvalidIndex; + } + + const std::size_t itemCountAfterClose = itemCountBeforeClose - 1u; + if (itemCountAfterClose == 0u) { + return UIEditorTabStripInvalidIndex; + } + + if (selectedIndex == UIEditorTabStripInvalidIndex || selectedIndex >= itemCountBeforeClose) { + return (std::min)(closedIndex, itemCountAfterClose - 1u); + } + + if (closedIndex < selectedIndex) { + return selectedIndex - 1u; + } + + if (closedIndex > selectedIndex) { + return selectedIndex; + } + + return (std::min)(selectedIndex, itemCountAfterClose - 1u); +} + +UIEditorTabStripLayout BuildUIEditorTabStripLayout( + const UIRect& bounds, + const std::vector& items, + const UIEditorTabStripState& state, + const UIEditorTabStripMetrics& metrics) { + UIEditorTabStripLayout layout = {}; + layout.bounds = UIRect( + bounds.x, + bounds.y, + ClampNonNegative(bounds.width), + ClampNonNegative(bounds.height)); + layout.selectedIndex = ResolveSelectedIndex(items.size(), state.selectedIndex); + + std::vector desiredHeaderWidths = {}; + desiredHeaderWidths.reserve(items.size()); + for (const UIEditorTabStripItem& item : items) { + desiredHeaderWidths.push_back( + MeasureUITabStripHeaderWidth( + ResolveUIEditorTabStripDesiredHeaderLabelWidth(item, metrics), + metrics.layoutMetrics)); + } + + const ::XCEngine::UI::Layout::UITabStripLayoutResult arranged = + ArrangeUITabStrip(layout.bounds, desiredHeaderWidths, metrics.layoutMetrics); + layout.headerRect = arranged.headerRect; + layout.contentRect = arranged.contentRect; + layout.tabHeaderRects = arranged.tabHeaderRects; + layout.closeButtonRects.resize(items.size()); + layout.showCloseButtons.resize(items.size(), false); + + for (std::size_t index = 0; index < items.size(); ++index) { + if (!items[index].closable) { + continue; + } + + layout.closeButtonRects[index] = BuildCloseButtonRect(layout.tabHeaderRects[index], metrics); + layout.showCloseButtons[index] = + layout.closeButtonRects[index].width > 0.0f && layout.closeButtonRects[index].height > 0.0f; + } + + return layout; +} + +UIEditorTabStripHitTarget HitTestUIEditorTabStrip( + const UIEditorTabStripLayout& layout, + const UIEditorTabStripState&, + const UIPoint& point) { + UIEditorTabStripHitTarget target = {}; + + if (!IsPointInsideRect(layout.bounds, point)) { + return target; + } + + for (std::size_t index = 0; index < layout.closeButtonRects.size(); ++index) { + if (layout.showCloseButtons[index] && + IsPointInsideRect(layout.closeButtonRects[index], point)) { + target.kind = UIEditorTabStripHitTargetKind::CloseButton; + target.index = index; + return target; + } + } + + for (std::size_t index = 0; index < layout.tabHeaderRects.size(); ++index) { + if (IsPointInsideRect(layout.tabHeaderRects[index], point)) { + target.kind = UIEditorTabStripHitTargetKind::Tab; + target.index = index; + return target; + } + } + + if (IsPointInsideRect(layout.headerRect, point)) { + target.kind = UIEditorTabStripHitTargetKind::HeaderBackground; + return target; + } + + if (IsPointInsideRect(layout.contentRect, point)) { + target.kind = UIEditorTabStripHitTargetKind::Content; + return target; + } + + return target; +} + +void AppendUIEditorTabStripBackground( + UIDrawList& drawList, + const UIEditorTabStripLayout& layout, + const UIEditorTabStripState& state, + const UIEditorTabStripPalette& palette, + const UIEditorTabStripMetrics& metrics) { + drawList.AddFilledRect(layout.bounds, palette.stripBackgroundColor, kStripRounding); + if (layout.contentRect.height > 0.0f) { + drawList.AddFilledRect(layout.contentRect, palette.contentBackgroundColor, kStripRounding); + } + if (layout.headerRect.height > 0.0f) { + drawList.AddFilledRect(layout.headerRect, palette.headerBackgroundColor, kStripRounding); + } + drawList.AddRectOutline( + layout.bounds, + ResolveStripBorderColor(state, palette), + ResolveStripBorderThickness(state, metrics), + kStripRounding); + + for (std::size_t index = 0; index < layout.tabHeaderRects.size(); ++index) { + const bool selected = layout.selectedIndex == index; + const bool hovered = state.hoveredIndex == index || state.closeHoveredIndex == index; + drawList.AddFilledRect( + layout.tabHeaderRects[index], + ResolveTabFillColor(selected, hovered, palette), + kTabRounding); + drawList.AddRectOutline( + layout.tabHeaderRects[index], + ResolveTabBorderColor(selected, hovered, state.focused, palette), + ResolveTabBorderThickness(selected, state.focused, metrics), + kTabRounding); + } +} + +void AppendUIEditorTabStripForeground( + UIDrawList& drawList, + const UIEditorTabStripLayout& layout, + const std::vector& items, + const UIEditorTabStripState& state, + const UIEditorTabStripPalette& palette, + const UIEditorTabStripMetrics& metrics) { + const float leftInset = (std::max)( + ClampNonNegative(metrics.layoutMetrics.tabHorizontalPadding), + ClampNonNegative(metrics.labelInsetX)); + + for (std::size_t index = 0; index < items.size() && index < layout.tabHeaderRects.size(); ++index) { + const UIRect& tabRect = layout.tabHeaderRects[index]; + const bool selected = layout.selectedIndex == index; + const bool hovered = state.hoveredIndex == index || state.closeHoveredIndex == index; + const float textLeft = tabRect.x + leftInset; + float textRight = tabRect.x + tabRect.width - ClampNonNegative(metrics.layoutMetrics.tabHorizontalPadding); + if (layout.showCloseButtons[index]) { + textRight = layout.closeButtonRects[index].x - ClampNonNegative(metrics.closeButtonGap); + } + + if (textRight > textLeft) { + const UIRect clipRect( + textLeft, + tabRect.y, + textRight - textLeft, + tabRect.height); + drawList.PushClipRect(clipRect, true); + drawList.AddText( + UIPoint(textLeft, ResolveTabTextTop(tabRect, metrics)), + items[index].title, + selected || hovered ? palette.textPrimary : palette.textSecondary, + kHeaderFontSize); + drawList.PopClipRect(); + } + + if (!layout.showCloseButtons[index]) { + continue; + } + + const bool closeHovered = state.closeHoveredIndex == index; + const UIRect& closeRect = layout.closeButtonRects[index]; + drawList.AddFilledRect( + closeRect, + closeHovered ? palette.closeButtonHoveredColor : palette.closeButtonColor, + 4.0f); + drawList.AddRectOutline( + closeRect, + palette.closeButtonBorderColor, + 1.0f, + 4.0f); + drawList.AddText( + UIPoint( + closeRect.x + (std::max)(0.0f, (closeRect.width - 7.0f) * 0.5f), + ResolveCloseTextTop(closeRect)), + "X", + palette.closeGlyphColor, + kCloseFontSize); + } +} + +void AppendUIEditorTabStrip( + UIDrawList& drawList, + const UIRect& bounds, + const std::vector& items, + const UIEditorTabStripState& state, + const UIEditorTabStripPalette& palette, + const UIEditorTabStripMetrics& metrics) { + const UIEditorTabStripLayout layout = + BuildUIEditorTabStripLayout(bounds, items, state, metrics); + AppendUIEditorTabStripBackground(drawList, layout, state, palette, metrics); + AppendUIEditorTabStripForeground(drawList, layout, items, state, palette, metrics); +} + +} // namespace XCEngine::UI::Editor::Widgets diff --git a/tests/UI/Editor/integration/CMakeLists.txt b/tests/UI/Editor/integration/CMakeLists.txt index a0ed9c94..724e0ed6 100644 --- a/tests/UI/Editor/integration/CMakeLists.txt +++ b/tests/UI/Editor/integration/CMakeLists.txt @@ -8,6 +8,8 @@ add_custom_target(editor_ui_integration_tests DEPENDS editor_ui_workspace_shell_compose_validation editor_ui_menu_bar_basic_validation + editor_ui_panel_frame_basic_validation + editor_ui_tab_strip_basic_validation editor_ui_panel_session_flow_validation editor_ui_layout_persistence_validation editor_ui_shortcut_dispatch_validation diff --git a/tests/UI/Editor/integration/README.md b/tests/UI/Editor/integration/README.md index 493525ae..c3f5f275 100644 --- a/tests/UI/Editor/integration/README.md +++ b/tests/UI/Editor/integration/README.md @@ -14,6 +14,8 @@ Layout: - `shared/`: shared host wrapper, scenario registry, shared theme - `shell/workspace_shell_compose/`: split/tab/panel shell compose only - `shell/menu_bar_basic/`: menu bar open/close/hover/dispatch only +- `shell/panel_frame_basic/`: panel frame layout/state/hit-test only +- `shell/tab_strip_basic/`: tab strip layout/state/hit-test/close/navigation only - `state/panel_session_flow/`: panel session state flow only - `state/layout_persistence/`: layout save/load/reset only - `state/shortcut_dispatch/`: shortcut match/suppression/dispatch only @@ -30,6 +32,16 @@ Scenarios: Executable: `XCUIEditorMenuBarBasicValidation.exe` Scope: menu bar open/close, hover, dismiss, menu command dispatch only +- `editor.shell.panel_frame_basic` + Build target: `editor_ui_panel_frame_basic_validation` + Executable: `XCUIEditorPanelFrameBasicValidation.exe` + Scope: panel frame header/body/footer layout, focus/active/hover chrome, pin/close hit target only + +- `editor.shell.tab_strip_basic` + Build target: `editor_ui_tab_strip_basic_validation` + Executable: `XCUIEditorTabStripBasicValidation.exe` + Scope: tab header layout, selected/hover/focus, close hit target, close fallback, Left/Right/Home/End navigation only + - `editor.state.panel_session_flow` Build target: `editor_ui_panel_session_flow_validation` Executable: `XCUIEditorPanelSessionFlowValidation.exe` @@ -59,6 +71,12 @@ Selected controls: - `shell/menu_bar_basic/` Click `File / Window / Layout`, move the mouse across menu items, click outside the menu or press `Esc`, press `F12`. +- `shell/panel_frame_basic/` + Move the mouse over the preview panel, click `Body / Pin / Close`, toggle `Active / Focus / Closable / Footer`, press `F12`. + +- `shell/tab_strip_basic/` + Click `Document A / B / C`, click `X` on closable tabs, click content to focus, press `Left / Right / Home / End`, press `Reset`, press `F12`. + - `state/panel_session_flow/` Click `Hide Active / Show Doc A / Close Doc B / Open Doc B / Activate Details / Reset`, press `F12`. diff --git a/tests/UI/Editor/integration/shell/CMakeLists.txt b/tests/UI/Editor/integration/shell/CMakeLists.txt index 76512b4f..1a5c2e8b 100644 --- a/tests/UI/Editor/integration/shell/CMakeLists.txt +++ b/tests/UI/Editor/integration/shell/CMakeLists.txt @@ -1,4 +1,6 @@ add_subdirectory(workspace_shell_compose) +add_subdirectory(panel_frame_basic) +add_subdirectory(tab_strip_basic) if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/menu_bar_basic/CMakeLists.txt") add_subdirectory(menu_bar_basic) endif() diff --git a/tests/UI/Editor/integration/shell/menu_bar_basic/main.cpp b/tests/UI/Editor/integration/shell/menu_bar_basic/main.cpp index 14cd78ac..aa2726c5 100644 --- a/tests/UI/Editor/integration/shell/menu_bar_basic/main.cpp +++ b/tests/UI/Editor/integration/shell/menu_bar_basic/main.cpp @@ -4,17 +4,20 @@ #include #include +#include #include #include "Host/AutoScreenshot.h" #include "Host/NativeRenderer.h" #include #include +#include #include #include #include +#include #include #include #include @@ -37,7 +40,6 @@ using XCEngine::UI::Editor::BuildUIEditorWorkspaceTabStack; using XCEngine::UI::Editor::CollectUIEditorWorkspaceVisiblePanels; using XCEngine::UI::Editor::FindUIEditorPanelSessionState; using XCEngine::UI::Editor::GetUIEditorCommandDispatchStatusName; -using XCEngine::UI::Editor::GetUIEditorMenuItemKindName; using XCEngine::UI::Editor::GetUIEditorWorkspaceCommandStatusName; using XCEngine::UI::Editor::UIEditorCommandDispatchResult; using XCEngine::UI::Editor::UIEditorCommandDispatcher; @@ -48,13 +50,15 @@ using XCEngine::UI::Editor::UIEditorMenuDescriptor; using XCEngine::UI::Editor::UIEditorMenuItemDescriptor; using XCEngine::UI::Editor::UIEditorMenuItemKind; using XCEngine::UI::Editor::UIEditorMenuModel; +using XCEngine::UI::Editor::UIEditorMenuPopupState; +using XCEngine::UI::Editor::UIEditorMenuSession; +using XCEngine::UI::Editor::UIEditorMenuSessionMutationResult; using XCEngine::UI::Editor::UIEditorPanelRegistry; using XCEngine::UI::Editor::UIEditorResolvedMenuDescriptor; using XCEngine::UI::Editor::UIEditorResolvedMenuItem; using XCEngine::UI::Editor::UIEditorResolvedMenuModel; using XCEngine::UI::Editor::UIEditorShortcutManager; using XCEngine::UI::Editor::UIEditorWorkspaceCommandKind; -using XCEngine::UI::Editor::UIEditorWorkspaceCommandStatus; using XCEngine::UI::Editor::UIEditorWorkspaceController; using XCEngine::UI::Editor::UIEditorWorkspaceModel; using XCEngine::UI::Editor::UIEditorWorkspaceSession; @@ -63,11 +67,16 @@ using XCEngine::UI::Editor::ValidateUIEditorMenuModel; using XCEngine::UI::UIColor; using XCEngine::UI::UIDrawData; using XCEngine::UI::UIDrawList; +using XCEngine::UI::UIElementId; using XCEngine::UI::UIInputEventType; +using XCEngine::UI::UIInputPath; using XCEngine::UI::UIPoint; using XCEngine::UI::UIRect; using XCEngine::UI::UIShortcutBinding; using XCEngine::UI::UIShortcutScope; +using XCEngine::UI::Widgets::ResolvePopupPlacementRect; +using XCEngine::UI::Widgets::UIPopupOverlayEntry; +using XCEngine::UI::Widgets::UIPopupPlacement; using XCEngine::UI::Editor::Host::AutoScreenshotController; using XCEngine::UI::Editor::Host::NativeRenderer; @@ -92,23 +101,40 @@ constexpr UIColor kMenuDropBg(0.17f, 0.17f, 0.17f, 1.0f); constexpr UIColor kMenuItemHover(0.28f, 0.28f, 0.28f, 1.0f); constexpr UIColor kMenuDivider(0.32f, 0.32f, 0.32f, 1.0f); constexpr UIColor kIndicatorBg(0.23f, 0.23f, 0.23f, 1.0f); +constexpr float kMenuPopupWidth = 320.0f; +constexpr UIElementId kMenuBarPathRoot = 1000u; +constexpr UIElementId kMenuPopupPathRoot = 2000u; struct MenuButtonLayout { std::string menuId = {}; std::string label = {}; + std::string popupId = {}; UIRect rect = {}; + UIInputPath path = {}; +}; + +struct MenuPopupLayout { + std::string popupId = {}; + std::string menuId = {}; + std::string sourceItemId = {}; + UIRect rect = {}; + UIInputPath surfacePath = {}; }; struct MenuItemLayout { + std::string popupId = {}; std::string menuId = {}; std::string itemId = {}; UIEditorMenuItemKind kind = UIEditorMenuItemKind::Command; std::string label = {}; std::string commandId = {}; std::string shortcutText = {}; + std::string childPopupId = {}; UIRect rect = {}; + UIInputPath path = {}; bool enabled = false; bool checked = false; + bool hasSubmenu = false; }; std::filesystem::path ResolveRepoRootPath(); @@ -122,6 +148,19 @@ bool ContainsPoint(const UIRect& rect, float x, float y); std::string JoinVisiblePanelIds(const UIEditorWorkspaceModel& workspace, const UIEditorWorkspaceSession& session); void DrawCard(UIDrawList& drawList, const UIRect& rect, std::string_view title, std::string_view subtitle = {}); UIColor ResolveResultColor(std::string_view statusLabel); +std::string BuildRootPopupId(std::string_view menuId); +std::string BuildSubmenuPopupId(std::string_view itemId); +UIInputPath BuildMenuButtonPath(std::string_view menuId); +UIInputPath BuildPopupSurfacePath(std::string_view popupId); +UIInputPath BuildMenuItemPath(std::string_view popupId, std::string_view itemId); +std::string JoinPopupChainIds(const UIEditorMenuSession& menuSession); +std::string JoinSubmenuPathIds(const UIEditorMenuSession& menuSession); +std::string JoinClosedPopupIds(const UIEditorMenuSessionMutationResult& result); +const UIEditorResolvedMenuDescriptor* FindResolvedMenu(const UIEditorResolvedMenuModel& model, std::string_view menuId); +const UIEditorResolvedMenuItem* FindResolvedMenuItemRecursive(const std::vector& items, std::string_view itemId); +const std::vector* ResolvePopupItems(const UIEditorResolvedMenuModel& model, const UIEditorMenuPopupState& popupState); +std::uint64_t HashText(std::string_view text); +float MeasureMenuPopupHeight(const std::vector& items); class ScenarioApp { public: @@ -141,8 +180,22 @@ private: void SetDispatchResult(std::string actionName, const UIEditorCommandDispatchResult& result); void SetCustomResult(std::string actionName, std::string statusLabel, std::string message); void DrawMenuBar(UIDrawList& drawList, const UIRect& rect, const UIEditorResolvedMenuModel& resolvedModel); - void DrawOpenMenu(UIDrawList& drawList, const UIEditorResolvedMenuDescriptor& menu, const UIRect& anchorRect); + void DrawOpenPopups(UIDrawList& drawList, const UIEditorResolvedMenuModel& resolvedModel, const UIRect& viewportRect); + void DrawPopup( + UIDrawList& drawList, + std::string_view menuId, + std::string_view popupId, + const std::vector& items, + const UIPopupOverlayEntry& popupEntry, + const UIRect& viewportRect); void BuildDrawData(UIDrawData& drawData, float width, float height); + const MenuButtonLayout* FindMenuButtonLayout(std::string_view menuId) const; + const MenuButtonLayout* HitTestMenuButton(float x, float y) const; + const MenuPopupLayout* HitTestMenuPopup(float x, float y) const; + const MenuItemLayout* HitTestMenuItem(float x, float y) const; + UIPopupOverlayEntry BuildRootPopupEntry(const MenuButtonLayout& button) const; + UIPopupOverlayEntry BuildSubmenuPopupEntry(const MenuItemLayout& item) const; + void ClearHoverWhenMenuClosed(); HWND m_hwnd = nullptr; HINSTANCE m_hInstance = nullptr; @@ -153,10 +206,12 @@ private: UIEditorCommandDispatcher m_commandDispatcher = {}; UIEditorShortcutManager m_shortcutManager = {}; UIEditorMenuModel m_menuModel = {}; + UIEditorMenuSession m_menuSession = {}; std::vector m_menuButtons = {}; + std::vector m_menuPopups = {}; std::vector m_menuItems = {}; - std::string m_openMenuId = {}; std::string m_hoveredMenuId = {}; + std::string m_hoveredPopupId = {}; std::string m_hoveredItemId = {}; std::string m_lastActionName = {}; std::string m_lastStatusLabel = {}; @@ -164,6 +219,15 @@ private: UIColor m_lastStatusColor = kTextMuted; }; +std::uint64_t HashText(std::string_view text) { + std::uint64_t hash = 1469598103934665603ull; + for (const char value : text) { + hash ^= static_cast(value); + hash *= 1099511628211ull; + } + return hash; +} + std::filesystem::path ResolveRepoRootPath() { std::string root = XCENGINE_EDITOR_UI_TESTS_REPO_ROOT; if (root.size() >= 2u && root.front() == '"' && root.back() == '"') { @@ -258,12 +322,45 @@ UIEditorMenuModel BuildMenuModel() { "file", "File", { + UIEditorMenuItemDescriptor{ + UIEditorMenuItemKind::Submenu, + "file-workspace-tools", + "Workspace Tools", + {}, + {}, + { + UIEditorMenuItemDescriptor{ + UIEditorMenuItemKind::Command, + "file-workspace-activate-details", + {}, + "workspace.activate_details", + { UIEditorMenuCheckedStateSource::PanelActive, "details" }, + {} + }, + UIEditorMenuItemDescriptor{ + UIEditorMenuItemKind::Command, + "file-workspace-reset", + {}, + "workspace.reset", + {}, + {} + } + } + }, + UIEditorMenuItemDescriptor{ + UIEditorMenuItemKind::Separator, + "file-separator-1", + {}, + {}, + {}, + {} + }, UIEditorMenuItemDescriptor{ UIEditorMenuItemKind::Command, - "file-reset", - {}, - "workspace.reset", + "file-show-details", {}, + "workspace.show_details", + { UIEditorMenuCheckedStateSource::PanelVisible, "details" }, {} } } @@ -355,7 +452,7 @@ void DrawCard( } UIColor ResolveResultColor(std::string_view statusLabel) { - if (statusLabel == "Changed" || statusLabel == "Dispatched") { + if (statusLabel == "Changed" || statusLabel == "Dispatched" || statusLabel == "Applied") { return kSuccess; } if (statusLabel == "NoOp" || statusLabel == "Dismissed" || statusLabel == "Disabled") { @@ -367,6 +464,153 @@ UIColor ResolveResultColor(std::string_view statusLabel) { return kTextMuted; } +std::string BuildRootPopupId(std::string_view menuId) { + return "menu.root." + std::string(menuId); +} + +std::string BuildSubmenuPopupId(std::string_view itemId) { + return "menu.submenu." + std::string(itemId); +} + +UIInputPath BuildMenuButtonPath(std::string_view menuId) { + return UIInputPath { + kMenuBarPathRoot, + HashText("menu_button:" + std::string(menuId)) + }; +} + +UIInputPath BuildPopupSurfacePath(std::string_view popupId) { + return UIInputPath { + kMenuPopupPathRoot, + HashText("popup_surface:" + std::string(popupId)) + }; +} + +UIInputPath BuildMenuItemPath(std::string_view popupId, std::string_view itemId) { + UIInputPath path = BuildPopupSurfacePath(popupId); + path.elements.push_back( + HashText( + "popup_item:" + std::string(popupId) + ":" + std::string(itemId))); + return path; +} + +std::string JoinPopupChainIds(const UIEditorMenuSession& menuSession) { + const auto& popupStates = menuSession.GetPopupStates(); + if (popupStates.empty()) { + return "(none)"; + } + + std::ostringstream stream = {}; + for (std::size_t index = 0; index < popupStates.size(); ++index) { + if (index > 0u) { + stream << " -> "; + } + + if (popupStates[index].IsRootPopup()) { + stream << popupStates[index].menuId; + } else { + stream << popupStates[index].itemId; + } + } + + return stream.str(); +} + +std::string JoinSubmenuPathIds(const UIEditorMenuSession& menuSession) { + const auto& submenuItemIds = menuSession.GetOpenSubmenuItemIds(); + if (submenuItemIds.empty()) { + return "(none)"; + } + + std::ostringstream stream = {}; + for (std::size_t index = 0; index < submenuItemIds.size(); ++index) { + if (index > 0u) { + stream << " -> "; + } + stream << submenuItemIds[index]; + } + + return stream.str(); +} + +std::string JoinClosedPopupIds(const UIEditorMenuSessionMutationResult& result) { + if (result.closedPopupIds.empty()) { + return "(none)"; + } + + std::ostringstream stream = {}; + for (std::size_t index = 0; index < result.closedPopupIds.size(); ++index) { + if (index > 0u) { + stream << ", "; + } + stream << result.closedPopupIds[index]; + } + + return stream.str(); +} + +const UIEditorResolvedMenuDescriptor* FindResolvedMenu( + const UIEditorResolvedMenuModel& model, + std::string_view menuId) { + for (const UIEditorResolvedMenuDescriptor& menu : model.menus) { + if (menu.menuId == menuId) { + return &menu; + } + } + + return nullptr; +} + +const UIEditorResolvedMenuItem* FindResolvedMenuItemRecursive( + const std::vector& items, + std::string_view itemId) { + for (const UIEditorResolvedMenuItem& item : items) { + if (item.itemId == itemId) { + return &item; + } + + if (!item.children.empty()) { + if (const UIEditorResolvedMenuItem* found = + FindResolvedMenuItemRecursive(item.children, itemId)) { + return found; + } + } + } + + return nullptr; +} + +const std::vector* ResolvePopupItems( + const UIEditorResolvedMenuModel& model, + const UIEditorMenuPopupState& popupState) { + const UIEditorResolvedMenuDescriptor* menu = + FindResolvedMenu(model, popupState.menuId); + if (menu == nullptr) { + return nullptr; + } + + if (popupState.IsRootPopup()) { + return &menu->items; + } + + const UIEditorResolvedMenuItem* item = + FindResolvedMenuItemRecursive(menu->items, popupState.itemId); + if (item == nullptr || item->kind != UIEditorMenuItemKind::Submenu) { + return nullptr; + } + + return &item->children; +} + +float MeasureMenuPopupHeight(const std::vector& items) { + float contentHeight = 10.0f; + for (const UIEditorResolvedMenuItem& item : items) { + contentHeight += item.kind == UIEditorMenuItemKind::Separator ? 12.0f : 34.0f; + } + + return contentHeight + 8.0f; +} + int ScenarioApp::Run(HINSTANCE hInstance, int nCmdShow) { if (!Initialize(hInstance, nCmdShow)) { Shutdown(); @@ -522,20 +766,43 @@ void ScenarioApp::Shutdown() { } void ScenarioApp::ResetScenario() { +#if 1 m_controller = BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace()); m_commandDispatcher = UIEditorCommandDispatcher(BuildCommandRegistry()); m_shortcutManager = BuildShortcutManager(); m_menuModel = BuildMenuModel(); - m_openMenuId.clear(); + m_menuSession.Reset(); m_hoveredMenuId.clear(); + m_hoveredPopupId.clear(); m_hoveredItemId.clear(); m_menuButtons.clear(); + m_menuPopups.clear(); + m_menuItems.clear(); + SetCustomResult( + "等待操作", + "Pending", + "这个测试验证 Editor 菜单会话状态层:顶层菜单切换、child popup hover、Esc / outside click dismiss、命令派发。"); + return; +#endif +#if 0 + m_controller = + BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace()); + m_commandDispatcher = UIEditorCommandDispatcher(BuildCommandRegistry()); + m_shortcutManager = BuildShortcutManager(); + m_menuModel = BuildMenuModel(); + m_menuSession.Reset(); + m_hoveredMenuId.clear(); + m_hoveredPopupId.clear(); + m_hoveredItemId.clear(); + m_menuButtons.clear(); + m_menuPopups.clear(); m_menuItems.clear(); SetCustomResult( "等待操作", "Pending", "先点 File / Window / Layout,确认一次只会打开一个菜单;再点菜单外区域或按 Escape 关闭。"); +#endif } void ScenarioApp::OnResize(UINT width, UINT height) { @@ -568,32 +835,267 @@ void ScenarioApp::RenderFrame() { framePresented); } -void ScenarioApp::HandleMouseMove(float x, float y) { - std::string hoveredMenuId = {}; - std::string hoveredItemId = {}; - +const MenuButtonLayout* ScenarioApp::FindMenuButtonLayout(std::string_view menuId) const { for (const MenuButtonLayout& button : m_menuButtons) { - if (ContainsPoint(button.rect, x, y)) { - hoveredMenuId = button.menuId; - break; + if (button.menuId == menuId) { + return &button; } } - for (const MenuItemLayout& item : m_menuItems) { - if (ContainsPoint(item.rect, x, y)) { - hoveredItemId = item.itemId; - break; + return nullptr; +} + +const MenuButtonLayout* ScenarioApp::HitTestMenuButton(float x, float y) const { + for (auto it = m_menuButtons.rbegin(); it != m_menuButtons.rend(); ++it) { + if (ContainsPoint(it->rect, x, y)) { + return &(*it); } } - if (hoveredMenuId != m_hoveredMenuId || hoveredItemId != m_hoveredItemId) { - m_hoveredMenuId = std::move(hoveredMenuId); - m_hoveredItemId = std::move(hoveredItemId); + return nullptr; +} + +const MenuPopupLayout* ScenarioApp::HitTestMenuPopup(float x, float y) const { + for (auto it = m_menuPopups.rbegin(); it != m_menuPopups.rend(); ++it) { + if (ContainsPoint(it->rect, x, y)) { + return &(*it); + } + } + + return nullptr; +} + +const MenuItemLayout* ScenarioApp::HitTestMenuItem(float x, float y) const { + for (auto it = m_menuItems.rbegin(); it != m_menuItems.rend(); ++it) { + if (ContainsPoint(it->rect, x, y)) { + return &(*it); + } + } + + return nullptr; +} + +UIPopupOverlayEntry ScenarioApp::BuildRootPopupEntry(const MenuButtonLayout& button) const { + UIPopupOverlayEntry entry = {}; + entry.popupId = button.popupId; + entry.anchorRect = button.rect; + entry.anchorPath = button.path; + entry.surfacePath = BuildPopupSurfacePath(button.popupId); + entry.placement = UIPopupPlacement::BottomStart; + return entry; +} + +UIPopupOverlayEntry ScenarioApp::BuildSubmenuPopupEntry(const MenuItemLayout& item) const { + UIPopupOverlayEntry entry = {}; + entry.popupId = item.childPopupId; + entry.parentPopupId = item.popupId; + entry.anchorRect = item.rect; + entry.anchorPath = item.path; + entry.surfacePath = BuildPopupSurfacePath(item.childPopupId); + entry.placement = UIPopupPlacement::RightStart; + return entry; +} + +void ScenarioApp::ClearHoverWhenMenuClosed() { + if (!m_menuSession.HasOpenMenu()) { + m_hoveredPopupId.clear(); + m_hoveredItemId.clear(); + } +} + +void ScenarioApp::HandleMouseMove(float x, float y) { + const MenuButtonLayout* hoveredButton = HitTestMenuButton(x, y); + const MenuItemLayout* hoveredItem = HitTestMenuItem(x, y); + const MenuPopupLayout* hoveredPopup = HitTestMenuPopup(x, y); + + const std::string hoveredMenuId = + hoveredButton != nullptr ? hoveredButton->menuId : std::string(); + const std::string hoveredItemId = + hoveredItem != nullptr ? hoveredItem->itemId : std::string(); + const std::string hoveredPopupId = + hoveredPopup != nullptr ? hoveredPopup->popupId : std::string(); + + bool dirty = false; + if (hoveredMenuId != m_hoveredMenuId || + hoveredItemId != m_hoveredItemId || + hoveredPopupId != m_hoveredPopupId) { + m_hoveredMenuId = hoveredMenuId; + m_hoveredItemId = hoveredItemId; + m_hoveredPopupId = hoveredPopupId; + dirty = true; + } + + if (m_menuSession.HasOpenMenu()) { + if (hoveredButton != nullptr) { + if (!m_menuSession.IsMenuOpen(hoveredButton->menuId)) { + const auto mutation = + m_menuSession.HoverMenuBarRoot( + hoveredButton->menuId, + BuildRootPopupEntry(*hoveredButton)); + if (mutation.changed) { + SetCustomResult( + "Hover 切换顶层菜单", + "Changed", + "已切换到顶层菜单 `" + hoveredButton->label + "`。重点检查:旧 root popup 被替换,新 root popup 立即出现。"); + dirty = true; + } + } else { + const auto mutation = + m_menuSession.DismissFromFocusLoss(hoveredButton->path); + if (mutation.changed) { + SetCustomResult( + "Hover 收起子菜单", + "Dismissed", + "鼠标回到顶层按钮后,旧的 child popup 已收起。closed: " + + JoinClosedPopupIds(mutation)); + dirty = true; + } + } + } else if (hoveredItem != nullptr) { + if (hoveredItem->hasSubmenu) { + const auto mutation = + m_menuSession.HoverSubmenu( + hoveredItem->itemId, + BuildSubmenuPopupEntry(*hoveredItem)); + if (mutation.changed) { + SetCustomResult( + "Hover 打开子菜单", + "Changed", + "已展开 `" + hoveredItem->label + + "` 的 child popup。重点检查:右侧子菜单是否立即出现。"); + dirty = true; + } + } else { + const auto mutation = + m_menuSession.DismissFromFocusLoss(hoveredItem->path); + if (mutation.changed) { + SetCustomResult( + "Hover 收起子菜单", + "Dismissed", + "鼠标移到普通菜单项后,旧的 child popup 已收起。closed: " + + JoinClosedPopupIds(mutation)); + dirty = true; + } + } + } else if (hoveredPopup != nullptr) { + const auto mutation = + m_menuSession.DismissFromFocusLoss(hoveredPopup->surfacePath); + if (mutation.changed) { + SetCustomResult( + "Hover popup 空白区", + "Dismissed", + "鼠标停留在 popup 空白区后,子菜单链已回收到当前 popup。closed: " + + JoinClosedPopupIds(mutation)); + dirty = true; + } + } + } + + ClearHoverWhenMenuClosed(); + if (dirty) { InvalidateRect(m_hwnd, nullptr, FALSE); } } void ScenarioApp::HandleClick(float x, float y) { +#if 1 + const MenuButtonLayout* hoveredButton = HitTestMenuButton(x, y); + if (hoveredButton != nullptr) { + if (m_menuSession.IsMenuOpen(hoveredButton->menuId)) { + const auto mutation = m_menuSession.CloseAll(); + SetCustomResult( + "点击关闭顶层菜单", + mutation.changed ? "Dismissed" : "NoOp", + mutation.changed + ? "再次点击当前顶层按钮后,整条菜单链已关闭。closed: " + + JoinClosedPopupIds(mutation) + : "当前顶层菜单没有发生变化。"); + } else { + const auto mutation = + m_menuSession.OpenMenuBarRoot( + hoveredButton->menuId, + BuildRootPopupEntry(*hoveredButton)); + SetCustomResult( + "点击打开顶层菜单", + mutation.changed ? "Changed" : "NoOp", + mutation.changed + ? "已打开 `" + hoveredButton->label + + "`。重点检查:同一时刻只保留一个 root popup。" + : "顶层菜单状态没有发生变化。"); + } + ClearHoverWhenMenuClosed(); + InvalidateRect(m_hwnd, nullptr, FALSE); + return; + } + + const MenuItemLayout* hoveredItem = HitTestMenuItem(x, y); + if (hoveredItem != nullptr) { + if (hoveredItem->hasSubmenu) { + const auto mutation = + m_menuSession.HoverSubmenu( + hoveredItem->itemId, + BuildSubmenuPopupEntry(*hoveredItem)); + SetCustomResult( + "点击展开子菜单", + mutation.changed ? "Changed" : "NoOp", + mutation.changed + ? "已展开 `" + hoveredItem->label + + "` 子菜单。这个场景的正常行为是 hover 也会直接展开。" + : "子菜单已经处于打开状态。"); + InvalidateRect(m_hwnd, nullptr, FALSE); + return; + } + + if (!hoveredItem->enabled) { + SetCustomResult( + "菜单项不可执行", + "Disabled", + "当前工作区状态下 `" + hoveredItem->label + "` 不可执行。"); + InvalidateRect(m_hwnd, nullptr, FALSE); + return; + } + + const UIEditorCommandDispatchResult result = + m_commandDispatcher.Dispatch(hoveredItem->commandId, m_controller); + m_menuSession.CloseAll(); + ClearHoverWhenMenuClosed(); + SetDispatchResult(hoveredItem->label, result); + InvalidateRect(m_hwnd, nullptr, FALSE); + return; + } + + const MenuPopupLayout* hoveredPopup = HitTestMenuPopup(x, y); + if (hoveredPopup != nullptr) { + const auto mutation = + m_menuSession.DismissFromPointerDown(hoveredPopup->surfacePath); + if (mutation.changed) { + SetCustomResult( + "点击 popup 空白区", + "Dismissed", + "点击当前 popup 空白区后,仅更深层子菜单被关闭。closed: " + + JoinClosedPopupIds(mutation)); + ClearHoverWhenMenuClosed(); + InvalidateRect(m_hwnd, nullptr, FALSE); + } + return; + } + + if (m_menuSession.HasOpenMenu()) { + const auto mutation = + m_menuSession.DismissFromPointerDown(UIInputPath { 999999u }); + SetCustomResult( + "点击菜单外区域", + mutation.changed ? "Dismissed" : "NoOp", + mutation.changed + ? "点击外部区域后,整条菜单链已关闭。closed: " + + JoinClosedPopupIds(mutation) + : "菜单链没有变化。"); + ClearHoverWhenMenuClosed(); + InvalidateRect(m_hwnd, nullptr, FALSE); + } + return; +#endif +#if 0 for (const MenuButtonLayout& button : m_menuButtons) { if (!ContainsPoint(button.rect, x, y)) { continue; @@ -649,9 +1151,37 @@ void ScenarioApp::HandleClick(float x, float y) { SetCustomResult("菜单失焦", "Dismissed", "点击菜单外区域,菜单已关闭。"); InvalidateRect(m_hwnd, nullptr, FALSE); } +#endif } void ScenarioApp::HandleKeyDown(UINT keyCode) { +#if 1 + switch (keyCode) { + case VK_ESCAPE: { + const auto mutation = m_menuSession.DismissFromEscape(); + if (mutation.changed) { + SetCustomResult( + "Esc 关闭 popup", + "Dismissed", + "Escape 会从 topmost popup 开始逐层关闭。当前关闭: " + + JoinClosedPopupIds(mutation)); + ClearHoverWhenMenuClosed(); + InvalidateRect(m_hwnd, nullptr, FALSE); + } + break; + } + case 'R': + SetDispatchResult( + "键盘 Reset Workspace", + m_commandDispatcher.Dispatch("workspace.reset", m_controller)); + InvalidateRect(m_hwnd, nullptr, FALSE); + break; + default: + break; + } + return; +#endif +#if 0 switch (keyCode) { case VK_ESCAPE: if (!m_openMenuId.empty()) { @@ -670,6 +1200,7 @@ void ScenarioApp::HandleKeyDown(UINT keyCode) { default: break; } +#endif } void ScenarioApp::SetDispatchResult( @@ -699,6 +1230,252 @@ void ScenarioApp::SetCustomResult( m_lastStatusColor = ResolveResultColor(m_lastStatusLabel); } +void ScenarioApp::DrawMenuBar( + UIDrawList& drawList, + const UIRect& rect, + const UIEditorResolvedMenuModel& resolvedModel) { + drawList.AddFilledRect(rect, kMenuBarBg, 8.0f); + drawList.AddRectOutline(rect, kCardBorder, 1.0f, 8.0f); + + float buttonX = rect.x + 12.0f; + for (const UIEditorResolvedMenuDescriptor& menu : resolvedModel.menus) { + const bool open = m_menuSession.IsMenuOpen(menu.menuId); + const bool hovered = m_hoveredMenuId == menu.menuId; + const float buttonWidth = 104.0f; + const UIRect buttonRect(buttonX, rect.y + 6.0f, buttonWidth, rect.height - 12.0f); + + drawList.AddFilledRect( + buttonRect, + open ? kMenuButtonOpen : (hovered ? kMenuButtonHover : kMenuButtonBg), + 6.0f); + drawList.AddRectOutline(buttonRect, kCardBorder, 1.0f, 6.0f); + drawList.AddText( + UIPoint(buttonRect.x + 14.0f, buttonRect.y + 10.0f), + menu.label, + kTextPrimary, + 14.0f); + + m_menuButtons.push_back( + { menu.menuId, menu.label, BuildRootPopupId(menu.menuId), buttonRect, BuildMenuButtonPath(menu.menuId) }); + + buttonX += buttonWidth + 10.0f; + } +} + +void ScenarioApp::DrawPopup( + UIDrawList& drawList, + std::string_view menuId, + std::string_view popupId, + const std::vector& items, + const UIPopupOverlayEntry& popupEntry, + const UIRect& viewportRect) { + const auto placementResult = + ResolvePopupPlacementRect( + popupEntry.anchorRect, + XCEngine::UI::UISize(kMenuPopupWidth, MeasureMenuPopupHeight(items)), + viewportRect, + popupEntry.placement); + const UIRect popupRect = placementResult.rect; + + drawList.AddFilledRect(popupRect, kMenuDropBg, 8.0f); + drawList.AddRectOutline(popupRect, kCardBorder, 1.0f, 8.0f); + + m_menuPopups.push_back( + { std::string(popupId), std::string(menuId), popupEntry.parentPopupId, popupRect, popupEntry.surfacePath }); + + float itemY = popupRect.y + 8.0f; + for (const UIEditorResolvedMenuItem& item : items) { + if (item.kind == UIEditorMenuItemKind::Separator) { + drawList.AddFilledRect( + UIRect(popupRect.x + 12.0f, itemY + 4.0f, popupRect.width - 24.0f, 1.0f), + kMenuDivider); + itemY += 12.0f; + continue; + } + + const bool hasSubmenu = + item.kind == UIEditorMenuItemKind::Submenu && !item.children.empty(); + const std::string childPopupId = + hasSubmenu ? BuildSubmenuPopupId(item.itemId) : std::string(); + const bool submenuOpen = + hasSubmenu && m_menuSession.IsPopupOpen(childPopupId); + const bool hovered = m_hoveredItemId == item.itemId; + + const UIRect itemRect(popupRect.x + 8.0f, itemY, popupRect.width - 16.0f, 30.0f); + if (hovered || submenuOpen) { + drawList.AddFilledRect(itemRect, kMenuItemHover, 6.0f); + } + + drawList.AddRectOutline( + UIRect(itemRect.x + 10.0f, itemRect.y + 8.0f, 10.0f, 10.0f), + item.checked ? kAccent : kMenuDivider, + item.checked ? 2.0f : 1.0f, + 3.0f); + if (item.checked) { + drawList.AddFilledRect( + UIRect(itemRect.x + 12.0f, itemRect.y + 10.0f, 6.0f, 6.0f), + kAccent, + 2.0f); + } + + drawList.AddText( + UIPoint(itemRect.x + 30.0f, itemRect.y + 7.0f), + item.label, + item.enabled ? kTextPrimary : kTextDisabled, + 13.0f); + if (!item.shortcutText.empty()) { + drawList.AddText( + UIPoint(itemRect.x + itemRect.width - 92.0f, itemRect.y + 7.0f), + item.shortcutText, + item.enabled ? kTextMuted : kTextDisabled, + 12.0f); + } + if (hasSubmenu) { + drawList.AddText( + UIPoint(itemRect.x + itemRect.width - 24.0f, itemRect.y + 7.0f), + ">", + kTextMuted, + 13.0f); + } + + m_menuItems.push_back( + { + std::string(popupId), + std::string(menuId), + item.itemId, + item.kind, + item.label, + item.commandId, + item.shortcutText, + childPopupId, + itemRect, + BuildMenuItemPath(popupId, item.itemId), + item.enabled, + item.checked, + hasSubmenu + }); + itemY += 34.0f; + } +} + +void ScenarioApp::DrawOpenPopups( + UIDrawList& drawList, + const UIEditorResolvedMenuModel& resolvedModel, + const UIRect& viewportRect) { + for (const UIEditorMenuPopupState& popupState : m_menuSession.GetPopupStates()) { + const auto* popupEntry = + m_menuSession.GetPopupOverlayModel().FindPopup(popupState.popupId); + const auto* popupItems = ResolvePopupItems(resolvedModel, popupState); + if (popupEntry == nullptr || popupItems == nullptr) { + continue; + } + + DrawPopup( + drawList, + popupState.menuId, + popupState.popupId, + *popupItems, + *popupEntry, + viewportRect); + } +} + +void ScenarioApp::BuildDrawData(UIDrawData& drawData, float width, float height) { + const auto menuValidation = + ValidateUIEditorMenuModel(m_menuModel, m_commandDispatcher.GetCommandRegistry()); + const auto resolvedModel = + BuildUIEditorResolvedMenuModel( + m_menuModel, + m_commandDispatcher, + m_controller, + &m_shortcutManager); + + const UIEditorWorkspaceModel& workspace = m_controller.GetWorkspace(); + const UIEditorWorkspaceSession& session = m_controller.GetSession(); + const auto* detailsState = FindUIEditorPanelSessionState(session, "details"); + + m_menuButtons.clear(); + m_menuPopups.clear(); + m_menuItems.clear(); + + UIDrawList& drawList = drawData.EmplaceDrawList("Editor Menu Bar Basic"); + drawList.AddFilledRect(UIRect(0.0f, 0.0f, width, height), kWindowBg); + + const float margin = 20.0f; + const UIRect viewportRect(0.0f, 0.0f, width, height); + const UIRect headerRect(margin, margin, width - margin * 2.0f, 176.0f); + const UIRect shellRect(margin, headerRect.y + headerRect.height + 16.0f, width * 0.58f, height - 312.0f); + const UIRect stateRect( + shellRect.x + shellRect.width + 16.0f, + shellRect.y, + width - shellRect.width - margin * 2.0f - 16.0f, + height - 312.0f); + const UIRect footerRect(margin, height - 100.0f, width - margin * 2.0f, 80.0f); + + DrawCard( + drawList, + headerRect, + "测试内容:Editor Menu 会话状态层", + "本场景只验证顶层菜单切换、child popup hover、Esc / outside dismiss、命令派发;不验证业务面板。"); + drawList.AddText(UIPoint(headerRect.x + 18.0f, headerRect.y + 70.0f), "1. 点击 File 打开 root popup,再 hover `Workspace Tools`,右侧 child popup 应立即弹出。", kTextPrimary, 13.0f); + drawList.AddText(UIPoint(headerRect.x + 18.0f, headerRect.y + 92.0f), "2. root popup 打开时,把鼠标移到 Window / Layout 顶层按钮,旧 popup 应直接被新的 root popup 替换。", kTextPrimary, 13.0f); + drawList.AddText(UIPoint(headerRect.x + 18.0f, headerRect.y + 114.0f), "3. 点击 Window -> Activate Details,再点 Window -> Hide Active,检查右侧 details visible / active 是否按状态变化。", kTextPrimary, 13.0f); + drawList.AddText(UIPoint(headerRect.x + 18.0f, headerRect.y + 136.0f), "4. 如果 child popup 已打开,按一次 Esc 只关 topmost;再按一次 Esc 关闭整条菜单链。点击外部空白区也必须整条关闭。", kTextPrimary, 13.0f); + drawList.AddText(UIPoint(headerRect.x + 18.0f, headerRect.y + 158.0f), "5. F12 保存截图;R 可直接触发 Reset Workspace,方便确认命令派发仍正常。", kTextPrimary, 13.0f); + + DrawCard(drawList, shellRect, "操作区", "这里只保留 MenuBar 和当前 popup overlay。"); + DrawCard(drawList, stateRect, "状态摘要", "重点看 menu session 当前链路和 Details 状态,不放无关杂项。"); + DrawCard(drawList, footerRect, "最近结果", "显示最近一次交互、命令状态和截图输出。"); + + const UIRect menuBarRect(shellRect.x + 18.0f, shellRect.y + 74.0f, shellRect.width - 36.0f, 46.0f); + DrawMenuBar(drawList, menuBarRect, resolvedModel); + + const UIRect shellInfoRect(shellRect.x + 18.0f, shellRect.y + 144.0f, shellRect.width - 36.0f, 190.0f); + drawList.AddFilledRect(shellInfoRect, kIndicatorBg, 8.0f); + drawList.AddRectOutline(shellInfoRect, kCardBorder, 1.0f, 8.0f); + drawList.AddText(UIPoint(shellInfoRect.x + 14.0f, shellInfoRect.y + 14.0f), "Open root menu", kTextMuted, 12.0f); + drawList.AddText(UIPoint(shellInfoRect.x + 14.0f, shellInfoRect.y + 34.0f), m_menuSession.HasOpenMenu() ? std::string(m_menuSession.GetOpenRootMenuId()) : "(none)", kTextPrimary, 14.0f); + drawList.AddText(UIPoint(shellInfoRect.x + 14.0f, shellInfoRect.y + 62.0f), "Popup chain", kTextMuted, 12.0f); + drawList.AddText(UIPoint(shellInfoRect.x + 14.0f, shellInfoRect.y + 82.0f), JoinPopupChainIds(m_menuSession), kTextPrimary, 14.0f); + drawList.AddText(UIPoint(shellInfoRect.x + 14.0f, shellInfoRect.y + 110.0f), "Submenu path", kTextMuted, 12.0f); + drawList.AddText(UIPoint(shellInfoRect.x + 14.0f, shellInfoRect.y + 130.0f), JoinSubmenuPathIds(m_menuSession), kTextPrimary, 14.0f); + drawList.AddText(UIPoint(shellInfoRect.x + 14.0f, shellInfoRect.y + 158.0f), "提示:popup overlay 必须压在右侧状态面板上方,这一轮会专门检查这个层级关系。", kTextMuted, 12.0f); + + drawList.AddText(UIPoint(stateRect.x + 18.0f, stateRect.y + 72.0f), "Hover summary", kAccent, 15.0f); + drawList.AddText(UIPoint(stateRect.x + 18.0f, stateRect.y + 100.0f), "hover menu", kTextMuted, 12.0f); + drawList.AddText(UIPoint(stateRect.x + 18.0f, stateRect.y + 120.0f), m_hoveredMenuId.empty() ? "(none)" : m_hoveredMenuId, kTextPrimary, 14.0f); + drawList.AddText(UIPoint(stateRect.x + 18.0f, stateRect.y + 150.0f), "hover popup", kTextMuted, 12.0f); + drawList.AddText(UIPoint(stateRect.x + 18.0f, stateRect.y + 170.0f), m_hoveredPopupId.empty() ? "(none)" : m_hoveredPopupId, kTextPrimary, 14.0f); + drawList.AddText(UIPoint(stateRect.x + 18.0f, stateRect.y + 200.0f), "hover item", kTextMuted, 12.0f); + drawList.AddText(UIPoint(stateRect.x + 18.0f, stateRect.y + 220.0f), m_hoveredItemId.empty() ? "(none)" : m_hoveredItemId, kTextPrimary, 14.0f); + + drawList.AddText(UIPoint(stateRect.x + 18.0f, stateRect.y + 262.0f), "Workspace summary", kAccent, 15.0f); + drawList.AddText(UIPoint(stateRect.x + 18.0f, stateRect.y + 290.0f), "active panel", kTextMuted, 12.0f); + drawList.AddText(UIPoint(stateRect.x + 18.0f, stateRect.y + 310.0f), workspace.activePanelId.empty() ? "(none)" : workspace.activePanelId, kTextPrimary, 14.0f); + drawList.AddText(UIPoint(stateRect.x + 18.0f, stateRect.y + 338.0f), "visible panels", kTextMuted, 12.0f); + drawList.AddText(UIPoint(stateRect.x + 18.0f, stateRect.y + 358.0f), JoinVisiblePanelIds(workspace, session), kTextPrimary, 14.0f); + drawList.AddText(UIPoint(stateRect.x + 18.0f, stateRect.y + 386.0f), "details visible", kTextMuted, 12.0f); + drawList.AddText(UIPoint(stateRect.x + 18.0f, stateRect.y + 406.0f), detailsState != nullptr && detailsState->visible ? "true" : "false", detailsState != nullptr && detailsState->visible ? kSuccess : kWarning, 14.0f); + drawList.AddText(UIPoint(stateRect.x + 18.0f, stateRect.y + 434.0f), "details active", kTextMuted, 12.0f); + drawList.AddText(UIPoint(stateRect.x + 18.0f, stateRect.y + 454.0f), workspace.activePanelId == "details" ? "true" : "false", workspace.activePanelId == "details" ? kSuccess : kTextMuted, 14.0f); + drawList.AddText(UIPoint(stateRect.x + 18.0f, stateRect.y + 482.0f), "menu model validation", kTextMuted, 12.0f); + drawList.AddText(UIPoint(stateRect.x + 18.0f, stateRect.y + 502.0f), menuValidation.IsValid() ? "OK" : menuValidation.message, menuValidation.IsValid() ? kSuccess : kDanger, 12.0f); + + drawList.AddText(UIPoint(footerRect.x + 18.0f, footerRect.y + 28.0f), "Last interaction: " + m_lastActionName + " | Result: " + m_lastStatusLabel, m_lastStatusColor, 13.0f); + drawList.AddText(UIPoint(footerRect.x + 18.0f, footerRect.y + 48.0f), m_lastMessage, kTextPrimary, 12.0f); + + const std::string captureSummary = + m_autoScreenshot.HasPendingCapture() + ? "截图排队中..." + : (m_autoScreenshot.GetLastCaptureSummary().empty() + ? std::string("F12 -> tests/UI/Editor/integration/shell/menu_bar_basic/captures/") + : m_autoScreenshot.GetLastCaptureSummary()); + drawList.AddText(UIPoint(footerRect.x + 18.0f, footerRect.y + 66.0f), captureSummary, kTextMuted, 12.0f); + + DrawOpenPopups(drawList, resolvedModel, viewportRect); +} + +#if 0 void ScenarioApp::DrawMenuBar( UIDrawList& drawList, const UIRect& rect, @@ -927,6 +1704,8 @@ void ScenarioApp::BuildDrawData(UIDrawData& drawData, float width, float height) 12.0f); } +#endif + } // namespace int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) { diff --git a/tests/UI/Editor/integration/shell/panel_frame_basic/CMakeLists.txt b/tests/UI/Editor/integration/shell/panel_frame_basic/CMakeLists.txt new file mode 100644 index 00000000..c1f08fff --- /dev/null +++ b/tests/UI/Editor/integration/shell/panel_frame_basic/CMakeLists.txt @@ -0,0 +1,30 @@ +add_executable(editor_ui_panel_frame_basic_validation WIN32 + main.cpp +) + +target_include_directories(editor_ui_panel_frame_basic_validation PRIVATE + ${CMAKE_SOURCE_DIR}/new_editor/include + ${CMAKE_SOURCE_DIR}/new_editor/app + ${CMAKE_SOURCE_DIR}/engine/include +) + +target_compile_definitions(editor_ui_panel_frame_basic_validation PRIVATE + UNICODE + _UNICODE + XCENGINE_EDITOR_UI_TESTS_REPO_ROOT="${XCENGINE_EDITOR_UI_TESTS_REPO_ROOT_PATH}" +) + +if(MSVC) + target_compile_options(editor_ui_panel_frame_basic_validation PRIVATE /utf-8 /FS) + set_property(TARGET editor_ui_panel_frame_basic_validation PROPERTY + MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>DLL") +endif() + +target_link_libraries(editor_ui_panel_frame_basic_validation PRIVATE + XCUIEditorLib + XCUIEditorHost +) + +set_target_properties(editor_ui_panel_frame_basic_validation PROPERTIES + OUTPUT_NAME "XCUIEditorPanelFrameBasicValidation" +) diff --git a/tests/UI/Editor/integration/shell/panel_frame_basic/captures/.gitkeep b/tests/UI/Editor/integration/shell/panel_frame_basic/captures/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/tests/UI/Editor/integration/shell/panel_frame_basic/captures/.gitkeep @@ -0,0 +1 @@ + diff --git a/tests/UI/Editor/integration/shell/panel_frame_basic/main.cpp b/tests/UI/Editor/integration/shell/panel_frame_basic/main.cpp new file mode 100644 index 00000000..693a4e18 --- /dev/null +++ b/tests/UI/Editor/integration/shell/panel_frame_basic/main.cpp @@ -0,0 +1,520 @@ +#ifndef NOMINMAX +#define NOMINMAX +#endif + +#include +#include "Host/AutoScreenshot.h" +#include "Host/NativeRenderer.h" + +#include + +#include +#include + +#include +#include +#include +#include +#include + +#ifndef XCENGINE_EDITOR_UI_TESTS_REPO_ROOT +#define XCENGINE_EDITOR_UI_TESTS_REPO_ROOT "." +#endif + +namespace { + +using XCEngine::UI::UIColor; +using XCEngine::UI::UIDrawData; +using XCEngine::UI::UIDrawList; +using XCEngine::UI::UIPoint; +using XCEngine::UI::UIRect; +using XCEngine::UI::Editor::Host::AutoScreenshotController; +using XCEngine::UI::Editor::Host::NativeRenderer; +using XCEngine::UI::Editor::Widgets::AppendUIEditorPanelFrameBackground; +using XCEngine::UI::Editor::Widgets::AppendUIEditorPanelFrameForeground; +using XCEngine::UI::Editor::Widgets::BuildUIEditorPanelFrameLayout; +using XCEngine::UI::Editor::Widgets::HitTestUIEditorPanelFrame; +using XCEngine::UI::Editor::Widgets::UIEditorPanelFrameHitTarget; +using XCEngine::UI::Editor::Widgets::UIEditorPanelFrameLayout; +using XCEngine::UI::Editor::Widgets::UIEditorPanelFrameState; +using XCEngine::UI::Editor::Widgets::UIEditorPanelFrameText; + +constexpr const wchar_t* kWindowClassName = L"XCUIEditorPanelFrameBasicValidation"; +constexpr const wchar_t* kWindowTitle = L"XCUI Editor | PanelFrame Basic"; + +constexpr UIColor kWindowBg(0.13f, 0.13f, 0.13f, 1.0f); +constexpr UIColor kCardBg(0.18f, 0.18f, 0.18f, 1.0f); +constexpr UIColor kCardBorder(0.30f, 0.30f, 0.30f, 1.0f); +constexpr UIColor kCardAccent(0.82f, 0.82f, 0.82f, 1.0f); +constexpr UIColor kTextPrimary(0.94f, 0.94f, 0.94f, 1.0f); +constexpr UIColor kTextMuted(0.72f, 0.72f, 0.72f, 1.0f); +constexpr UIColor kTextWeak(0.56f, 0.56f, 0.56f, 1.0f); +constexpr UIColor kButtonBg(0.26f, 0.26f, 0.26f, 1.0f); +constexpr UIColor kButtonOnBg(0.42f, 0.42f, 0.42f, 1.0f); +constexpr UIColor kButtonBorder(0.48f, 0.48f, 0.48f, 1.0f); + +enum class ActionId : unsigned char { + ToggleFooter = 0, + TogglePin, + ToggleClose, + Reset +}; + +struct ButtonState { + ActionId action = ActionId::ToggleFooter; + std::string label = {}; + UIRect rect = {}; + bool selected = false; +}; + +std::filesystem::path ResolveRepoRootPath() { + std::string root = XCENGINE_EDITOR_UI_TESTS_REPO_ROOT; + if (root.size() >= 2u && root.front() == '"' && root.back() == '"') { + root = root.substr(1u, root.size() - 2u); + } + + return std::filesystem::path(root).lexically_normal(); +} + +bool ContainsPoint(const UIRect& rect, float x, float y) { + return x >= rect.x && + x <= rect.x + rect.width && + y >= rect.y && + y <= rect.y + rect.height; +} + +std::string DescribePart(UIEditorPanelFrameHitTarget part) { + switch (part) { + case UIEditorPanelFrameHitTarget::Header: return "Header"; + case UIEditorPanelFrameHitTarget::Body: return "Body"; + case UIEditorPanelFrameHitTarget::Footer: return "Footer"; + case UIEditorPanelFrameHitTarget::PinButton: return "Pin"; + case UIEditorPanelFrameHitTarget::CloseButton: return "Close"; + default: return "None"; + } +} + +void DrawCard( + UIDrawList& drawList, + const UIRect& rect, + std::string_view title, + std::string_view subtitle = {}) { + drawList.AddFilledRect(rect, kCardBg, 10.0f); + drawList.AddRectOutline(rect, kCardBorder, 1.0f, 10.0f); + drawList.AddText(UIPoint(rect.x + 16.0f, rect.y + 14.0f), std::string(title), kTextPrimary, 17.0f); + if (!subtitle.empty()) { + drawList.AddText(UIPoint(rect.x + 16.0f, rect.y + 38.0f), std::string(subtitle), kTextMuted, 12.0f); + } +} + +void DrawButton( + UIDrawList& drawList, + const ButtonState& button) { + drawList.AddFilledRect(button.rect, button.selected ? kButtonOnBg : kButtonBg, 8.0f); + drawList.AddRectOutline(button.rect, button.selected ? kCardAccent : kButtonBorder, 1.0f, 8.0f); + drawList.AddText( + UIPoint(button.rect.x + 12.0f, button.rect.y + 10.0f), + button.label, + kTextPrimary, + 12.0f); +} + +class ScenarioApp { +public: + int Run(HINSTANCE hInstance, int nCmdShow) { + if (!Initialize(hInstance, nCmdShow)) { + Shutdown(); + return 1; + } + + MSG message = {}; + while (message.message != WM_QUIT) { + if (PeekMessageW(&message, nullptr, 0U, 0U, PM_REMOVE)) { + TranslateMessage(&message); + DispatchMessageW(&message); + continue; + } + + RenderFrame(); + Sleep(8); + } + + Shutdown(); + return static_cast(message.wParam); + } + +private: + static LRESULT CALLBACK WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) { + if (message == WM_NCCREATE) { + const auto* createStruct = reinterpret_cast(lParam); + auto* app = reinterpret_cast(createStruct->lpCreateParams); + SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast(app)); + return TRUE; + } + + auto* app = reinterpret_cast(GetWindowLongPtrW(hwnd, GWLP_USERDATA)); + switch (message) { + case WM_SIZE: + if (app != nullptr && wParam != SIZE_MINIMIZED) { + app->OnResize(static_cast(LOWORD(lParam)), static_cast(HIWORD(lParam))); + } + return 0; + case WM_MOUSEMOVE: + if (app != nullptr) { + app->HandleMouseMove( + static_cast(GET_X_LPARAM(lParam)), + static_cast(GET_Y_LPARAM(lParam))); + return 0; + } + break; + case WM_MOUSELEAVE: + if (app != nullptr) { + app->HandleMouseLeave(); + return 0; + } + break; + case WM_LBUTTONUP: + if (app != nullptr) { + app->HandleClick( + static_cast(GET_X_LPARAM(lParam)), + static_cast(GET_Y_LPARAM(lParam))); + return 0; + } + break; + case WM_KEYDOWN: + case WM_SYSKEYDOWN: + if (app != nullptr && wParam == VK_F12) { + app->m_autoScreenshot.RequestCapture("manual_f12"); + return 0; + } + break; + case WM_PAINT: + if (app != nullptr) { + PAINTSTRUCT paintStruct = {}; + BeginPaint(hwnd, &paintStruct); + app->RenderFrame(); + EndPaint(hwnd, &paintStruct); + return 0; + } + break; + case WM_DESTROY: + PostQuitMessage(0); + return 0; + default: + break; + } + + return DefWindowProcW(hwnd, message, wParam, lParam); + } + + bool Initialize(HINSTANCE hInstance, int nCmdShow) { + m_captureRoot = + ResolveRepoRootPath() / "tests/UI/Editor/integration/shell/panel_frame_basic/captures"; + m_autoScreenshot.Initialize(m_captureRoot); + + WNDCLASSEXW windowClass = {}; + windowClass.cbSize = sizeof(windowClass); + windowClass.style = CS_HREDRAW | CS_VREDRAW; + windowClass.lpfnWndProc = &ScenarioApp::WndProc; + windowClass.hInstance = hInstance; + windowClass.hCursor = LoadCursorW(nullptr, IDC_ARROW); + windowClass.lpszClassName = kWindowClassName; + + m_windowClassAtom = RegisterClassExW(&windowClass); + if (m_windowClassAtom == 0) { + return false; + } + + m_hwnd = CreateWindowExW( + 0, + kWindowClassName, + kWindowTitle, + WS_OVERLAPPEDWINDOW | WS_VISIBLE, + CW_USEDEFAULT, + CW_USEDEFAULT, + 1440, + 920, + nullptr, + nullptr, + hInstance, + this); + if (m_hwnd == nullptr) { + return false; + } + + ShowWindow(m_hwnd, nCmdShow); + UpdateWindow(m_hwnd); + + if (!m_renderer.Initialize(m_hwnd)) { + return false; + } + + ResetState(); + return true; + } + + void Shutdown() { + m_autoScreenshot.Shutdown(); + m_renderer.Shutdown(); + + if (m_hwnd != nullptr && IsWindow(m_hwnd)) { + DestroyWindow(m_hwnd); + } + m_hwnd = nullptr; + + if (m_windowClassAtom != 0) { + UnregisterClassW(kWindowClassName, GetModuleHandleW(nullptr)); + m_windowClassAtom = 0; + } + } + + void OnResize(UINT width, UINT height) { + m_renderer.Resize(width, height); + } + + void HandleMouseMove(float x, float y) { + m_mousePosition = UIPoint(x, y); + TRACKMOUSEEVENT event = {}; + event.cbSize = sizeof(event); + event.dwFlags = TME_LEAVE; + event.hwndTrack = m_hwnd; + TrackMouseEvent(&event); + UpdateHoveredPart(); + } + + void HandleMouseLeave() { + m_mousePosition = UIPoint(-1000.0f, -1000.0f); + m_hoveredPart = UIEditorPanelFrameHitTarget::None; + } + + void HandleClick(float x, float y) { + for (const ButtonState& button : m_buttons) { + if (ContainsPoint(button.rect, x, y)) { + ExecuteAction(button.action); + return; + } + } + + const UIEditorPanelFrameHitTarget part = + HitTestUIEditorPanelFrame(m_layout, m_state, UIPoint(x, y)); + switch (part) { + case UIEditorPanelFrameHitTarget::Header: + m_state.active = !m_state.active; + m_lastResult = m_state.active ? "Header click -> Active = On" : "Header click -> Active = Off"; + break; + case UIEditorPanelFrameHitTarget::Body: + case UIEditorPanelFrameHitTarget::Footer: + m_state.focused = true; + m_lastResult = "Body/Footer click -> Focus = On"; + break; + case UIEditorPanelFrameHitTarget::PinButton: + if (m_state.pinnable) { + m_state.pinned = !m_state.pinned; + m_lastResult = m_state.pinned ? "Pin click -> Pinned = On" : "Pin click -> Pinned = Off"; + } + break; + case UIEditorPanelFrameHitTarget::CloseButton: + if (m_state.closable) { + ++m_closeDispatchCount; + m_lastResult = "Close click -> CloseRequested #" + std::to_string(m_closeDispatchCount); + } + break; + case UIEditorPanelFrameHitTarget::None: + default: + m_state.focused = false; + m_lastResult = "Outside click -> Focus = Off"; + break; + } + + UpdateHoveredPart(); + } + + void ExecuteAction(ActionId action) { + switch (action) { + case ActionId::ToggleFooter: + m_state.showFooter = !m_state.showFooter; + m_lastResult = m_state.showFooter ? "Footer = On" : "Footer = Off"; + break; + case ActionId::TogglePin: + m_state.pinnable = !m_state.pinnable; + if (!m_state.pinnable) { + m_state.pinned = false; + } + m_lastResult = m_state.pinnable ? "Pin button = On" : "Pin button = Off"; + break; + case ActionId::ToggleClose: + m_state.closable = !m_state.closable; + m_lastResult = m_state.closable ? "Close button = On" : "Close button = Off"; + break; + case ActionId::Reset: + ResetState(); + m_lastResult = "State reset"; + break; + } + } + + void ResetState() { + m_state = {}; + m_state.active = true; + m_state.showFooter = true; + m_state.pinnable = true; + m_state.closable = true; + m_hoveredPart = UIEditorPanelFrameHitTarget::None; + m_lastResult = "Ready"; + m_closeDispatchCount = 0; + } + + void UpdateHoveredPart() { + m_hoveredPart = HitTestUIEditorPanelFrame(m_layout, m_state, m_mousePosition); + } + + void BuildButtons(float left, float top, float width) { + const float buttonHeight = 34.0f; + const float gap = 10.0f; + m_buttons = { + { ActionId::ToggleFooter, "Footer", UIRect(left, top, width, buttonHeight), m_state.showFooter }, + { ActionId::TogglePin, "Pin Button", UIRect(left, top + (buttonHeight + gap), width, buttonHeight), m_state.pinnable }, + { ActionId::ToggleClose, "Close Button", UIRect(left, top + (buttonHeight + gap) * 2.0f, width, buttonHeight), m_state.closable }, + { ActionId::Reset, "Reset", UIRect(left, top + (buttonHeight + gap) * 3.0f, width, buttonHeight), false } + }; + } + + void RenderFrame() { + RECT clientRect = {}; + GetClientRect(m_hwnd, &clientRect); + const float width = static_cast((std::max)(1L, clientRect.right - clientRect.left)); + const float height = static_cast((std::max)(1L, clientRect.bottom - clientRect.top)); + + const float leftColumnWidth = 340.0f; + const float outerPadding = 20.0f; + const UIRect introRect(outerPadding, outerPadding, leftColumnWidth, 148.0f); + const UIRect controlsRect(outerPadding, 188.0f, leftColumnWidth, 180.0f); + const UIRect stateRect(outerPadding, 388.0f, leftColumnWidth, 220.0f); + const UIRect panelRect( + leftColumnWidth + outerPadding * 2.0f, + outerPadding, + width - leftColumnWidth - outerPadding * 3.0f, + height - outerPadding * 2.0f); + + BuildButtons(controlsRect.x + 16.0f, controlsRect.y + 54.0f, controlsRect.width - 32.0f); + + UIDrawData drawData = {}; + UIDrawList& drawList = drawData.EmplaceDrawList("PanelFrameBasic"); + drawList.AddFilledRect(UIRect(0.0f, 0.0f, width, height), kWindowBg); + + DrawCard( + drawList, + introRect, + "测试功能:PanelFrame 基础壳层", + "重点检查:Header / Body / Footer 布局,Active / Focus 边框,Pin / Close 命中。"); + drawList.AddText( + UIPoint(introRect.x + 16.0f, introRect.y + 66.0f), + "操作:移动鼠标观察 hover;点 Header 切 Active;点 Body/Footer 取 Focus;点 Pin/Close 看 Result;按 F12 截图。", + kTextMuted, + 12.0f); + drawList.AddText( + UIPoint(introRect.x + 16.0f, introRect.y + 90.0f), + "这个场景只验证 Editor 基础层 PanelFrame,不包含任何业务面板。", + kTextWeak, + 12.0f); + + DrawCard(drawList, controlsRect, "开关", "这里只保留基础状态开关,避免试验面板过杂。"); + for (const ButtonState& button : m_buttons) { + DrawButton(drawList, button); + } + + DrawCard(drawList, stateRect, "状态", "右侧面板的命中结果和状态变化会同步显示在这里。"); + drawList.AddText( + UIPoint(stateRect.x + 16.0f, stateRect.y + 66.0f), + "Hover: " + DescribePart(m_hoveredPart), + kTextPrimary, + 13.0f); + drawList.AddText( + UIPoint(stateRect.x + 16.0f, stateRect.y + 92.0f), + std::string("Active: ") + (m_state.active ? "On" : "Off"), + kTextPrimary, + 13.0f); + drawList.AddText( + UIPoint(stateRect.x + 16.0f, stateRect.y + 118.0f), + std::string("Focused: ") + (m_state.focused ? "On" : "Off"), + kTextPrimary, + 13.0f); + drawList.AddText( + UIPoint(stateRect.x + 16.0f, stateRect.y + 144.0f), + std::string("Pinned: ") + (m_state.pinned ? "On" : "Off"), + kTextPrimary, + 13.0f); + drawList.AddText( + UIPoint(stateRect.x + 16.0f, stateRect.y + 170.0f), + "Result: " + m_lastResult, + kTextMuted, + 12.0f); + + UIEditorPanelFrameState frameState = m_state; + frameState.hovered = m_hoveredPart != UIEditorPanelFrameHitTarget::None; + frameState.pinHovered = m_hoveredPart == UIEditorPanelFrameHitTarget::PinButton; + frameState.closeHovered = m_hoveredPart == UIEditorPanelFrameHitTarget::CloseButton; + m_layout = BuildUIEditorPanelFrameLayout(panelRect, frameState); + + AppendUIEditorPanelFrameBackground(drawList, m_layout, frameState); + AppendUIEditorPanelFrameForeground( + drawList, + m_layout, + frameState, + UIEditorPanelFrameText{ + "Inspector Placeholder", + "PanelFrame foundation validation", + m_state.showFooter + ? "Footer visible | Active=" + std::string(m_state.active ? "On" : "Off") + : "" + }); + + drawList.AddText( + UIPoint(m_layout.bodyRect.x, m_layout.bodyRect.y), + "Body content placeholder", + kTextPrimary, + 15.0f); + drawList.AddText( + UIPoint(m_layout.bodyRect.x, m_layout.bodyRect.y + 28.0f), + "Hover current region: " + DescribePart(m_hoveredPart), + kTextMuted, + 12.0f); + drawList.AddText( + UIPoint(m_layout.bodyRect.x, m_layout.bodyRect.y + 50.0f), + "Pin button and Close button use PanelFrame hit rects.", + kTextMuted, + 12.0f); + drawList.AddText( + UIPoint(m_layout.bodyRect.x, m_layout.bodyRect.y + 72.0f), + "Close does not destroy the panel here. It only verifies dispatch intent.", + kTextWeak, + 12.0f); + + const bool framePresented = m_renderer.Render(drawData); + m_autoScreenshot.CaptureIfRequested( + m_renderer, + drawData, + static_cast(width), + static_cast(height), + framePresented); + } + + HWND m_hwnd = nullptr; + ATOM m_windowClassAtom = 0; + NativeRenderer m_renderer = {}; + AutoScreenshotController m_autoScreenshot = {}; + std::filesystem::path m_captureRoot = {}; + std::vector m_buttons = {}; + UIPoint m_mousePosition = UIPoint(-1000.0f, -1000.0f); + UIEditorPanelFrameState m_state = {}; + UIEditorPanelFrameLayout m_layout = {}; + UIEditorPanelFrameHitTarget m_hoveredPart = UIEditorPanelFrameHitTarget::None; + std::string m_lastResult = {}; + int m_closeDispatchCount = 0; +}; + +} // namespace + +int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) { + return ScenarioApp().Run(hInstance, nCmdShow); +} diff --git a/tests/UI/Editor/integration/shell/tab_strip_basic/CMakeLists.txt b/tests/UI/Editor/integration/shell/tab_strip_basic/CMakeLists.txt new file mode 100644 index 00000000..bf9a361f --- /dev/null +++ b/tests/UI/Editor/integration/shell/tab_strip_basic/CMakeLists.txt @@ -0,0 +1,30 @@ +add_executable(editor_ui_tab_strip_basic_validation WIN32 + main.cpp +) + +target_include_directories(editor_ui_tab_strip_basic_validation PRIVATE + ${CMAKE_SOURCE_DIR}/new_editor/include + ${CMAKE_SOURCE_DIR}/new_editor/app + ${CMAKE_SOURCE_DIR}/engine/include +) + +target_compile_definitions(editor_ui_tab_strip_basic_validation PRIVATE + UNICODE + _UNICODE + XCENGINE_EDITOR_UI_TESTS_REPO_ROOT="${XCENGINE_EDITOR_UI_TESTS_REPO_ROOT_PATH}" +) + +if(MSVC) + target_compile_options(editor_ui_tab_strip_basic_validation PRIVATE /utf-8 /FS) + set_property(TARGET editor_ui_tab_strip_basic_validation PROPERTY + MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>DLL") +endif() + +target_link_libraries(editor_ui_tab_strip_basic_validation PRIVATE + XCUIEditorLib + XCUIEditorHost +) + +set_target_properties(editor_ui_tab_strip_basic_validation PROPERTIES + OUTPUT_NAME "XCUIEditorTabStripBasicValidation" +) diff --git a/tests/UI/Editor/integration/shell/tab_strip_basic/main.cpp b/tests/UI/Editor/integration/shell/tab_strip_basic/main.cpp new file mode 100644 index 00000000..5dd24304 --- /dev/null +++ b/tests/UI/Editor/integration/shell/tab_strip_basic/main.cpp @@ -0,0 +1,740 @@ +#ifndef NOMINMAX +#define NOMINMAX +#endif + +#include +#include +#include +#include "Host/AutoScreenshot.h" +#include "Host/NativeRenderer.h" + +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +#ifndef XCENGINE_EDITOR_UI_TESTS_REPO_ROOT +#define XCENGINE_EDITOR_UI_TESTS_REPO_ROOT "." +#endif + +namespace { + +using XCEngine::UI::UIColor; +using XCEngine::UI::UIDrawData; +using XCEngine::UI::UIDrawList; +using XCEngine::UI::UIPoint; +using XCEngine::UI::UIRect; +using XCEngine::UI::Widgets::UITabStripModel; +using XCEngine::UI::Editor::BuildDefaultUIEditorWorkspaceController; +using XCEngine::UI::Editor::BuildUIEditorWorkspacePanel; +using XCEngine::UI::Editor::BuildUIEditorWorkspaceTabStack; +using XCEngine::UI::Editor::FindUIEditorPanelDescriptor; +using XCEngine::UI::Editor::FindUIEditorPanelSessionState; +using XCEngine::UI::Editor::GetUIEditorWorkspaceCommandStatusName; +using XCEngine::UI::Editor::Host::AutoScreenshotController; +using XCEngine::UI::Editor::Host::NativeRenderer; +using XCEngine::UI::Editor::UIEditorPanelDescriptor; +using XCEngine::UI::Editor::UIEditorPanelRegistry; +using XCEngine::UI::Editor::UIEditorWorkspaceCommand; +using XCEngine::UI::Editor::UIEditorWorkspaceCommandKind; +using XCEngine::UI::Editor::UIEditorWorkspaceCommandResult; +using XCEngine::UI::Editor::UIEditorWorkspaceController; +using XCEngine::UI::Editor::UIEditorWorkspaceModel; +using XCEngine::UI::Editor::UIEditorWorkspaceNode; +using XCEngine::UI::Editor::UIEditorWorkspaceNodeKind; +using XCEngine::UI::Editor::Widgets::AppendUIEditorTabStripBackground; +using XCEngine::UI::Editor::Widgets::AppendUIEditorTabStripForeground; +using XCEngine::UI::Editor::Widgets::BuildUIEditorTabStripLayout; +using XCEngine::UI::Editor::Widgets::HitTestUIEditorTabStrip; +using XCEngine::UI::Editor::Widgets::ResolveUIEditorTabStripSelectedIndex; +using XCEngine::UI::Editor::Widgets::UIEditorTabStripHitTarget; +using XCEngine::UI::Editor::Widgets::UIEditorTabStripHitTargetKind; +using XCEngine::UI::Editor::Widgets::UIEditorTabStripInvalidIndex; +using XCEngine::UI::Editor::Widgets::UIEditorTabStripItem; +using XCEngine::UI::Editor::Widgets::UIEditorTabStripLayout; +using XCEngine::UI::Editor::Widgets::UIEditorTabStripState; + +constexpr const wchar_t* kWindowClassName = L"XCUIEditorTabStripBasicValidation"; +constexpr const wchar_t* kWindowTitle = L"XCUI Editor | TabStrip Basic"; + +constexpr UIColor kWindowBg(0.15f, 0.15f, 0.15f, 1.0f); +constexpr UIColor kCardBg(0.19f, 0.19f, 0.19f, 1.0f); +constexpr UIColor kCardBorder(0.30f, 0.30f, 0.30f, 1.0f); +constexpr UIColor kTextPrimary(0.94f, 0.94f, 0.94f, 1.0f); +constexpr UIColor kTextMuted(0.73f, 0.73f, 0.73f, 1.0f); +constexpr UIColor kTextWeak(0.58f, 0.58f, 0.58f, 1.0f); +constexpr UIColor kSuccess(0.62f, 0.74f, 0.62f, 1.0f); +constexpr UIColor kWarning(0.78f, 0.70f, 0.46f, 1.0f); +constexpr UIColor kDanger(0.82f, 0.48f, 0.48f, 1.0f); +constexpr UIColor kButtonBg(0.25f, 0.25f, 0.25f, 1.0f); +constexpr UIColor kButtonHoveredBg(0.31f, 0.31f, 0.31f, 1.0f); + +std::filesystem::path ResolveRepoRootPath() { + std::string root = XCENGINE_EDITOR_UI_TESTS_REPO_ROOT; + if (root.size() >= 2u && root.front() == '"' && root.back() == '"') { + root = root.substr(1u, root.size() - 2u); + } + + return std::filesystem::path(root).lexically_normal(); +} + +bool ContainsPoint(const UIRect& rect, float x, float y) { + return x >= rect.x && + x <= rect.x + rect.width && + y >= rect.y && + y <= rect.y + rect.height; +} + +void DrawCard( + UIDrawList& drawList, + const UIRect& rect, + std::string_view title, + std::string_view subtitle = {}) { + drawList.AddFilledRect(rect, kCardBg, 10.0f); + drawList.AddRectOutline(rect, kCardBorder, 1.0f, 10.0f); + drawList.AddText(UIPoint(rect.x + 16.0f, rect.y + 14.0f), std::string(title), kTextPrimary, 17.0f); + if (!subtitle.empty()) { + drawList.AddText(UIPoint(rect.x + 16.0f, rect.y + 40.0f), std::string(subtitle), kTextMuted, 12.0f); + } +} + +void DrawButton( + UIDrawList& drawList, + const UIRect& rect, + std::string_view label, + bool hovered) { + drawList.AddFilledRect(rect, hovered ? kButtonHoveredBg : kButtonBg, 8.0f); + drawList.AddRectOutline(rect, kCardBorder, 1.0f, 8.0f); + drawList.AddText(UIPoint(rect.x + 14.0f, rect.y + 11.0f), std::string(label), kTextPrimary, 13.0f); +} + +UIEditorPanelRegistry BuildPanelRegistry() { + UIEditorPanelRegistry registry = {}; + registry.panels = { + { "doc-a", "Document A", {}, true, true, true }, + { "doc-b", "Document B", {}, true, true, true }, + { "doc-c", "Document C", {}, true, true, false } + }; + return registry; +} + +UIEditorWorkspaceModel BuildWorkspace() { + UIEditorWorkspaceModel workspace = {}; + workspace.root = BuildUIEditorWorkspaceTabStack( + "document-tabs", + { + BuildUIEditorWorkspacePanel("doc-a-node", "doc-a", "Document A", true), + BuildUIEditorWorkspacePanel("doc-b-node", "doc-b", "Document B", true), + BuildUIEditorWorkspacePanel("doc-c-node", "doc-c", "Document C", true) + }, + 0u); + workspace.activePanelId = "doc-a"; + return workspace; +} + +const UIEditorWorkspaceNode* GetRootTabStack(const UIEditorWorkspaceModel& workspace) { + return workspace.root.kind == UIEditorWorkspaceNodeKind::TabStack ? &workspace.root : nullptr; +} + +std::string DescribeHitTarget( + const UIEditorTabStripHitTarget& target, + const std::vector& items) { + switch (target.kind) { + case UIEditorTabStripHitTargetKind::HeaderBackground: + return "HeaderBackground"; + case UIEditorTabStripHitTargetKind::Content: + return "Content"; + case UIEditorTabStripHitTargetKind::Tab: + if (target.index < items.size()) { + return "Tab: " + items[target.index].title; + } + return "Tab"; + case UIEditorTabStripHitTargetKind::CloseButton: + if (target.index < items.size()) { + return "CloseButton: " + items[target.index].title; + } + return "CloseButton"; + case UIEditorTabStripHitTargetKind::None: + default: + return "None"; + } +} + +std::string JoinTabTitles(const std::vector& items) { + if (items.empty()) { + return "(none)"; + } + + std::ostringstream stream; + for (std::size_t index = 0; index < items.size(); ++index) { + if (index > 0u) { + stream << " | "; + } + stream << items[index].title; + if (!items[index].closable) { + stream << " (locked)"; + } + } + return stream.str(); +} + +class ScenarioApp { +public: + int Run(HINSTANCE hInstance, int nCmdShow) { + if (!Initialize(hInstance, nCmdShow)) { + Shutdown(); + return 1; + } + + MSG message = {}; + while (message.message != WM_QUIT) { + if (PeekMessageW(&message, nullptr, 0U, 0U, PM_REMOVE)) { + TranslateMessage(&message); + DispatchMessageW(&message); + continue; + } + + RenderFrame(); + Sleep(8); + } + + Shutdown(); + return static_cast(message.wParam); + } + +private: + static LRESULT CALLBACK WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) { + if (message == WM_NCCREATE) { + const auto* createStruct = reinterpret_cast(lParam); + auto* app = reinterpret_cast(createStruct->lpCreateParams); + SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast(app)); + return TRUE; + } + + auto* app = reinterpret_cast(GetWindowLongPtrW(hwnd, GWLP_USERDATA)); + switch (message) { + case WM_SIZE: + if (app != nullptr && wParam != SIZE_MINIMIZED) { + app->OnResize(static_cast(LOWORD(lParam)), static_cast(HIWORD(lParam))); + } + return 0; + case WM_MOUSEMOVE: + if (app != nullptr) { + app->HandleMouseMove( + static_cast(GET_X_LPARAM(lParam)), + static_cast(GET_Y_LPARAM(lParam))); + return 0; + } + break; + case WM_MOUSELEAVE: + if (app != nullptr) { + app->HandleMouseLeave(); + return 0; + } + break; + case WM_LBUTTONUP: + if (app != nullptr) { + app->HandleClick( + static_cast(GET_X_LPARAM(lParam)), + static_cast(GET_Y_LPARAM(lParam))); + return 0; + } + break; + case WM_KEYDOWN: + case WM_SYSKEYDOWN: + if (app != nullptr) { + if (wParam == VK_F12) { + app->m_autoScreenshot.RequestCapture("manual_f12"); + } else { + app->HandleKeyDown(static_cast(wParam)); + } + return 0; + } + break; + case WM_PAINT: + if (app != nullptr) { + PAINTSTRUCT paintStruct = {}; + BeginPaint(hwnd, &paintStruct); + app->RenderFrame(); + EndPaint(hwnd, &paintStruct); + return 0; + } + break; + case WM_ERASEBKGND: + return 1; + case WM_DESTROY: + if (app != nullptr) { + app->m_hwnd = nullptr; + } + PostQuitMessage(0); + return 0; + default: + break; + } + + return DefWindowProcW(hwnd, message, wParam, lParam); + } + + bool Initialize(HINSTANCE hInstance, int nCmdShow) { + m_captureRoot = + ResolveRepoRootPath() / "tests/UI/Editor/integration/shell/tab_strip_basic/captures"; + m_autoScreenshot.Initialize(m_captureRoot); + + WNDCLASSEXW windowClass = {}; + windowClass.cbSize = sizeof(windowClass); + windowClass.style = CS_HREDRAW | CS_VREDRAW; + windowClass.lpfnWndProc = &ScenarioApp::WndProc; + windowClass.hInstance = hInstance; + windowClass.hCursor = LoadCursorW(nullptr, IDC_ARROW); + windowClass.lpszClassName = kWindowClassName; + + m_windowClassAtom = RegisterClassExW(&windowClass); + if (m_windowClassAtom == 0) { + return false; + } + + m_hwnd = CreateWindowExW( + 0, + kWindowClassName, + kWindowTitle, + WS_OVERLAPPEDWINDOW | WS_VISIBLE, + CW_USEDEFAULT, + CW_USEDEFAULT, + 1440, + 920, + nullptr, + nullptr, + hInstance, + this); + if (m_hwnd == nullptr) { + return false; + } + + ShowWindow(m_hwnd, nCmdShow); + UpdateWindow(m_hwnd); + + if (!m_renderer.Initialize(m_hwnd)) { + return false; + } + + ResetScenario(); + return true; + } + + void Shutdown() { + m_autoScreenshot.Shutdown(); + m_renderer.Shutdown(); + + if (m_hwnd != nullptr && IsWindow(m_hwnd)) { + DestroyWindow(m_hwnd); + } + m_hwnd = nullptr; + + if (m_windowClassAtom != 0) { + UnregisterClassW(kWindowClassName, GetModuleHandleW(nullptr)); + m_windowClassAtom = 0; + } + } + + void ResetScenario() { + m_controller = + BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace()); + m_tabStripState = {}; + m_layout = {}; + m_tabItems.clear(); + m_hoverTarget = {}; + m_mousePosition = UIPoint(-1000.0f, -1000.0f); + m_lastResult = "等待操作"; + m_navigationModel = {}; + } + + void OnResize(UINT width, UINT height) { + if (width == 0u || height == 0u) { + return; + } + + m_renderer.Resize(width, height); + } + + void HandleMouseMove(float x, float y) { + m_mousePosition = UIPoint(x, y); + TRACKMOUSEEVENT event = {}; + event.cbSize = sizeof(event); + event.dwFlags = TME_LEAVE; + event.hwndTrack = m_hwnd; + TrackMouseEvent(&event); + + m_resetButtonHovered = ContainsPoint(m_resetButtonRect, x, y); + RefreshHoverTarget(); + InvalidateRect(m_hwnd, nullptr, FALSE); + } + + void HandleMouseLeave() { + m_mousePosition = UIPoint(-1000.0f, -1000.0f); + m_resetButtonHovered = false; + m_hoverTarget = {}; + m_tabStripState.hoveredIndex = UIEditorTabStripInvalidIndex; + m_tabStripState.closeHoveredIndex = UIEditorTabStripInvalidIndex; + InvalidateRect(m_hwnd, nullptr, FALSE); + } + + void HandleClick(float x, float y) { + if (ContainsPoint(m_resetButtonRect, x, y)) { + ResetScenario(); + InvalidateRect(m_hwnd, nullptr, FALSE); + return; + } + + const UIEditorTabStripHitTarget hit = + HitTestUIEditorTabStrip(m_layout, m_tabStripState, UIPoint(x, y)); + switch (hit.kind) { + case UIEditorTabStripHitTargetKind::CloseButton: + if (hit.index < m_tabItems.size()) { + DispatchCommand( + UIEditorWorkspaceCommandKind::ClosePanel, + m_tabItems[hit.index].tabId, + "Close " + m_tabItems[hit.index].title); + m_tabStripState.focused = true; + } + break; + case UIEditorTabStripHitTargetKind::Tab: + if (hit.index < m_tabItems.size()) { + DispatchCommand( + UIEditorWorkspaceCommandKind::ActivatePanel, + m_tabItems[hit.index].tabId, + "Activate " + m_tabItems[hit.index].title); + m_tabStripState.focused = true; + } + break; + case UIEditorTabStripHitTargetKind::HeaderBackground: + case UIEditorTabStripHitTargetKind::Content: + m_tabStripState.focused = true; + m_lastResult = "TabStrip 获得 focus"; + break; + case UIEditorTabStripHitTargetKind::None: + default: + m_tabStripState.focused = false; + m_lastResult = "Focus cleared"; + break; + } + + RefreshHoverTarget(); + InvalidateRect(m_hwnd, nullptr, FALSE); + } + + void HandleKeyDown(UINT keyCode) { + if (!m_tabStripState.focused) { + return; + } + + RefreshTabItems(); + bool handled = true; + bool changed = false; + std::string action = {}; + + switch (keyCode) { + case VK_LEFT: + action = "Keyboard Left"; + changed = m_navigationModel.SelectPrevious(); + break; + case VK_RIGHT: + action = "Keyboard Right"; + changed = m_navigationModel.SelectNext(); + break; + case VK_HOME: + action = "Keyboard Home"; + changed = m_navigationModel.SelectFirst(); + break; + case VK_END: + action = "Keyboard End"; + changed = m_navigationModel.SelectLast(); + break; + default: + handled = false; + break; + } + + if (!handled) { + return; + } + + if (!changed || !m_navigationModel.HasSelection()) { + m_lastResult = action + " -> NoOp"; + InvalidateRect(m_hwnd, nullptr, FALSE); + return; + } + + const std::size_t selectedIndex = m_navigationModel.GetSelectedIndex(); + if (selectedIndex < m_tabItems.size()) { + DispatchCommand( + UIEditorWorkspaceCommandKind::ActivatePanel, + m_tabItems[selectedIndex].tabId, + action + " -> " + m_tabItems[selectedIndex].title); + } + + InvalidateRect(m_hwnd, nullptr, FALSE); + } + + void DispatchCommand( + UIEditorWorkspaceCommandKind kind, + std::string_view panelId, + std::string label) { + UIEditorWorkspaceCommand command = {}; + command.kind = kind; + command.panelId = std::string(panelId); + + const UIEditorWorkspaceCommandResult result = m_controller.Dispatch(command); + m_lastResult = + std::move(label) + " -> " + + std::string(GetUIEditorWorkspaceCommandStatusName(result.status)) + + " | " + + result.message; + } + + void RefreshTabItems() { + const UIEditorWorkspaceModel& workspace = m_controller.GetWorkspace(); + const UIEditorPanelRegistry& registry = m_controller.GetPanelRegistry(); + const auto* tabStack = GetRootTabStack(workspace); + + m_tabItems.clear(); + if (tabStack != nullptr) { + for (const UIEditorWorkspaceNode& child : tabStack->children) { + if (child.kind != UIEditorWorkspaceNodeKind::Panel) { + continue; + } + + const auto* sessionState = + FindUIEditorPanelSessionState(m_controller.GetSession(), child.panel.panelId); + if (sessionState == nullptr || !sessionState->open || !sessionState->visible) { + continue; + } + + const UIEditorPanelDescriptor* descriptor = + FindUIEditorPanelDescriptor(registry, child.panel.panelId); + UIEditorTabStripItem item = {}; + item.tabId = child.panel.panelId; + item.title = child.panel.title; + item.closable = descriptor != nullptr ? descriptor->canClose : true; + m_tabItems.push_back(std::move(item)); + } + } + + m_tabStripState.selectedIndex = + ResolveUIEditorTabStripSelectedIndex(m_tabItems, workspace.activePanelId); + + m_navigationModel.SetItemCount(m_tabItems.size()); + if (m_tabStripState.selectedIndex != UIEditorTabStripInvalidIndex) { + m_navigationModel.SetSelectedIndex(m_tabStripState.selectedIndex); + } + } + + void RefreshHoverTarget() { + m_hoverTarget = HitTestUIEditorTabStrip(m_layout, m_tabStripState, m_mousePosition); + m_tabStripState.hoveredIndex = UIEditorTabStripInvalidIndex; + m_tabStripState.closeHoveredIndex = UIEditorTabStripInvalidIndex; + + if (m_hoverTarget.kind == UIEditorTabStripHitTargetKind::CloseButton && + m_hoverTarget.index < m_tabItems.size()) { + m_tabStripState.hoveredIndex = m_hoverTarget.index; + m_tabStripState.closeHoveredIndex = m_hoverTarget.index; + return; + } + + if (m_hoverTarget.kind == UIEditorTabStripHitTargetKind::Tab && + m_hoverTarget.index < m_tabItems.size()) { + m_tabStripState.hoveredIndex = m_hoverTarget.index; + } + } + + void RenderFrame() { + RECT clientRect = {}; + GetClientRect(m_hwnd, &clientRect); + const float width = static_cast((std::max)(1L, clientRect.right - clientRect.left)); + const float height = static_cast((std::max)(1L, clientRect.bottom - clientRect.top)); + + const float outerPadding = 20.0f; + const float leftColumnWidth = 360.0f; + const UIRect introRect(outerPadding, outerPadding, leftColumnWidth, 176.0f); + const UIRect stateRect(outerPadding, 216.0f, leftColumnWidth, height - 236.0f); + const UIRect previewCardRect( + leftColumnWidth + outerPadding * 2.0f, + outerPadding, + width - leftColumnWidth - outerPadding * 3.0f, + height - outerPadding * 2.0f); + const UIRect tabStripRect( + previewCardRect.x + 20.0f, + previewCardRect.y + 20.0f, + previewCardRect.width - 40.0f, + previewCardRect.height - 40.0f); + + RefreshTabItems(); + m_layout = BuildUIEditorTabStripLayout(tabStripRect, m_tabItems, m_tabStripState); + RefreshHoverTarget(); + + m_resetButtonRect = UIRect( + stateRect.x + 16.0f, + stateRect.y + 194.0f, + stateRect.width - 32.0f, + 38.0f); + + UIDrawData drawData = {}; + UIDrawList& drawList = drawData.EmplaceDrawList("EditorTabStripBasic"); + drawList.AddFilledRect(UIRect(0.0f, 0.0f, width, height), kWindowBg); + + DrawCard( + drawList, + introRect, + "测试功能:Editor TabStrip 基础层", + "只验证 TabStrip header / hit-test / selection / close / keyboard,不包含任何业务面板。"); + drawList.AddText( + UIPoint(introRect.x + 16.0f, introRect.y + 68.0f), + "重点检查:tab 布局是否整齐;selected / hover / focus 是否正确;Close 后 active panel 是否回退正确。", + kTextPrimary, + 12.0f); + drawList.AddText( + UIPoint(introRect.x + 16.0f, introRect.y + 92.0f), + "操作:点击 tab 切换;点击 X 关闭;Document C 没有 X;点击内容区后按 Left / Right / Home / End;Reset 恢复;F12 截图。", + kTextMuted, + 12.0f); + drawList.AddText( + UIPoint(introRect.x + 16.0f, introRect.y + 116.0f), + "预期:Close 命中优先于 tab;键盘导航只在 focused 时生效;Document C 始终保留。", + kTextWeak, + 12.0f); + + DrawCard( + drawList, + stateRect, + "状态回显", + "这里直接回显 hover / focus / active panel / result,方便人工检查。"); + DrawButton(drawList, m_resetButtonRect, "Reset", m_resetButtonHovered); + + const std::size_t selectedIndex = + ResolveUIEditorTabStripSelectedIndex(m_tabItems, m_controller.GetWorkspace().activePanelId); + const std::string selectedIndexText = + selectedIndex == UIEditorTabStripInvalidIndex ? "(none)" : std::to_string(selectedIndex); + const auto validation = m_controller.ValidateState(); + + drawList.AddText( + UIPoint(stateRect.x + 16.0f, stateRect.y + 70.0f), + "Hover: " + DescribeHitTarget(m_hoverTarget, m_tabItems), + kTextPrimary, + 13.0f); + drawList.AddText( + UIPoint(stateRect.x + 16.0f, stateRect.y + 96.0f), + std::string("Focused: ") + (m_tabStripState.focused ? "On" : "Off"), + kTextPrimary, + 13.0f); + drawList.AddText( + UIPoint(stateRect.x + 16.0f, stateRect.y + 122.0f), + "Active Panel: " + + (m_controller.GetWorkspace().activePanelId.empty() + ? std::string("(none)") + : m_controller.GetWorkspace().activePanelId), + kTextPrimary, + 13.0f); + drawList.AddText( + UIPoint(stateRect.x + 16.0f, stateRect.y + 148.0f), + "Selected Index: " + selectedIndexText, + kTextPrimary, + 13.0f); + drawList.AddText( + UIPoint(stateRect.x + 16.0f, stateRect.y + 174.0f), + "Tabs: " + JoinTabTitles(m_tabItems), + kTextMuted, + 12.0f); + drawList.AddText( + UIPoint(stateRect.x + 16.0f, stateRect.y + 250.0f), + "Result: " + m_lastResult, + kTextMuted, + 12.0f); + drawList.AddText( + UIPoint(stateRect.x + 16.0f, stateRect.y + 276.0f), + validation.IsValid() ? "Validation: OK" : "Validation: " + validation.message, + validation.IsValid() ? kSuccess : kDanger, + 12.0f); + + const std::string captureSummary = + m_autoScreenshot.HasPendingCapture() + ? "截图排队中..." + : (m_autoScreenshot.GetLastCaptureSummary().empty() + ? std::string("F12 -> tests/UI/Editor/integration/shell/tab_strip_basic/captures/") + : m_autoScreenshot.GetLastCaptureSummary()); + drawList.AddText( + UIPoint(stateRect.x + 16.0f, stateRect.y + 302.0f), + captureSummary, + kTextWeak, + 12.0f); + + DrawCard( + drawList, + previewCardRect, + "预览区", + "这里只放一个 TabStrip 和一个 content placeholder,避免试验面板过杂。"); + + AppendUIEditorTabStripBackground(drawList, m_layout, m_tabStripState); + AppendUIEditorTabStripForeground(drawList, m_layout, m_tabItems, m_tabStripState); + + drawList.AddText( + UIPoint(m_layout.contentRect.x + 20.0f, m_layout.contentRect.y + 22.0f), + "Content Placeholder", + kTextPrimary, + 18.0f); + drawList.AddText( + UIPoint(m_layout.contentRect.x + 20.0f, m_layout.contentRect.y + 52.0f), + "当前 active panel: " + + (m_controller.GetWorkspace().activePanelId.empty() + ? std::string("(none)") + : m_controller.GetWorkspace().activePanelId), + kTextMuted, + 13.0f); + drawList.AddText( + UIPoint(m_layout.contentRect.x + 20.0f, m_layout.contentRect.y + 76.0f), + "这个区域只用于验证 TabStrip content frame 与 focus,不承载业务内容。", + kTextWeak, + 12.0f); + drawList.AddText( + UIPoint(m_layout.contentRect.x + 20.0f, m_layout.contentRect.y + 100.0f), + "检查点:关闭 Document B 后,应自动回退到相邻 tab;Document C 无法关闭。", + kTextWeak, + 12.0f); + + const bool framePresented = m_renderer.Render(drawData); + m_autoScreenshot.CaptureIfRequested( + m_renderer, + drawData, + static_cast(width), + static_cast(height), + framePresented); + } + + HWND m_hwnd = nullptr; + ATOM m_windowClassAtom = 0; + NativeRenderer m_renderer = {}; + AutoScreenshotController m_autoScreenshot = {}; + std::filesystem::path m_captureRoot = {}; + UIPoint m_mousePosition = UIPoint(-1000.0f, -1000.0f); + UIEditorWorkspaceController m_controller = {}; + UIEditorTabStripState m_tabStripState = {}; + UIEditorTabStripLayout m_layout = {}; + std::vector m_tabItems = {}; + UITabStripModel m_navigationModel = {}; + UIEditorTabStripHitTarget m_hoverTarget = {}; + UIRect m_resetButtonRect = {}; + bool m_resetButtonHovered = false; + std::string m_lastResult = {}; +}; + +} // namespace + +int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) { + return ScenarioApp().Run(hInstance, nCmdShow); +} diff --git a/tests/UI/Editor/unit/CMakeLists.txt b/tests/UI/Editor/unit/CMakeLists.txt index e5a9298c..bee43566 100644 --- a/tests/UI/Editor/unit/CMakeLists.txt +++ b/tests/UI/Editor/unit/CMakeLists.txt @@ -5,9 +5,12 @@ set(EDITOR_UI_UNIT_TEST_SOURCES test_ui_editor_command_dispatcher.cpp test_ui_editor_command_registry.cpp test_ui_editor_menu_model.cpp + test_ui_editor_menu_session.cpp test_ui_editor_panel_registry.cpp test_ui_editor_collection_primitives.cpp test_ui_editor_panel_chrome.cpp + test_ui_editor_panel_frame.cpp + test_ui_editor_tab_strip.cpp test_ui_editor_shortcut_manager.cpp test_ui_editor_workspace_controller.cpp test_ui_editor_workspace_layout_persistence.cpp diff --git a/tests/UI/Editor/unit/test_ui_editor_menu_session.cpp b/tests/UI/Editor/unit/test_ui_editor_menu_session.cpp new file mode 100644 index 00000000..3d547dda --- /dev/null +++ b/tests/UI/Editor/unit/test_ui_editor_menu_session.cpp @@ -0,0 +1,274 @@ +#include + +#include + +#include + +namespace { + +using XCEngine::UI::UIInputPath; +using XCEngine::UI::Widgets::UIPopupDismissReason; +using XCEngine::UI::Widgets::UIPopupOverlayEntry; +using XCEngine::UI::Widgets::UIPopupPlacement; +using XCEngine::UI::Editor::UIEditorMenuSession; + +UIPopupOverlayEntry MakePopup( + const char* popupId, + const char* parentPopupId, + UIInputPath surfacePath, + UIInputPath anchorPath = {}, + UIPopupPlacement placement = UIPopupPlacement::BottomStart) { + UIPopupOverlayEntry entry = {}; + entry.popupId = popupId; + entry.parentPopupId = parentPopupId; + entry.surfacePath = std::move(surfacePath); + entry.anchorPath = std::move(anchorPath); + entry.placement = placement; + return entry; +} + +void ExpectClosedIds( + const std::vector& actual, + std::initializer_list expected) { + ASSERT_EQ(actual.size(), expected.size()); + + std::size_t index = 0u; + for (const char* popupId : expected) { + EXPECT_EQ(actual[index], popupId); + ++index; + } +} + +void ExpectOpenSubmenuIds( + const UIEditorMenuSession& session, + std::initializer_list expected) { + const auto& actual = session.GetOpenSubmenuItemIds(); + ASSERT_EQ(actual.size(), expected.size()); + + std::size_t index = 0u; + for (const char* itemId : expected) { + EXPECT_EQ(actual[index], itemId); + ++index; + } +} + +} // namespace + +TEST(UIEditorMenuSessionTest, OpenMenuBarRootTracksActiveMenuAndRootPopup) { + UIEditorMenuSession session = {}; + + const auto result = session.OpenMenuBarRoot( + "file", + MakePopup("menu.file.root", "", UIInputPath{100u, 110u}, UIInputPath{1u, 2u})); + + EXPECT_TRUE(result.changed); + EXPECT_EQ(result.openRootMenuId, "file"); + EXPECT_EQ(result.openedPopupId, "menu.file.root"); + EXPECT_TRUE(result.closedPopupIds.empty()); + EXPECT_TRUE(session.IsMenuOpen("file")); + EXPECT_TRUE(session.IsPopupOpen("menu.file.root")); + ASSERT_EQ(session.GetPopupOverlayModel().GetPopupCount(), 1u); + ASSERT_EQ(session.GetPopupStates().size(), 1u); + EXPECT_TRUE(session.GetPopupStates().front().IsRootPopup()); + EXPECT_TRUE(session.GetOpenSubmenuItemIds().empty()); +} + +TEST(UIEditorMenuSessionTest, HoverMenuBarRootReplacesOpenRootAndClearsSubmenuPath) { + UIEditorMenuSession session = {}; + ASSERT_TRUE(session.OpenMenuBarRoot( + "file", + MakePopup("menu.file.root", "", UIInputPath{100u, 110u}, UIInputPath{1u, 2u})) + .changed); + ASSERT_TRUE(session.HoverSubmenu( + "layout", + MakePopup( + "menu.file.layout", + "menu.file.root", + UIInputPath{200u, 210u}, + UIInputPath{100u, 110u, 120u}, + UIPopupPlacement::RightStart)) + .changed); + + const auto result = session.HoverMenuBarRoot( + "window", + MakePopup("menu.window.root", "", UIInputPath{300u, 310u}, UIInputPath{3u, 4u})); + + EXPECT_TRUE(result.changed); + EXPECT_EQ(result.openRootMenuId, "window"); + EXPECT_EQ(result.dismissReason, UIPopupDismissReason::Programmatic); + ExpectClosedIds(result.closedPopupIds, {"menu.file.root", "menu.file.layout"}); + EXPECT_TRUE(session.IsMenuOpen("window")); + EXPECT_FALSE(session.IsMenuOpen("file")); + EXPECT_TRUE(session.GetOpenSubmenuItemIds().empty()); + ASSERT_EQ(session.GetPopupOverlayModel().GetPopupCount(), 1u); + EXPECT_EQ(session.GetPopupOverlayModel().GetRootPopup()->popupId, "menu.window.root"); +} + +TEST(UIEditorMenuSessionTest, HoverSubmenuSwitchesSiblingBranchAndTruncatesDescendants) { + UIEditorMenuSession session = {}; + ASSERT_TRUE(session.OpenMenuBarRoot( + "file", + MakePopup("menu.file.root", "", UIInputPath{100u, 110u}, UIInputPath{1u, 2u})) + .changed); + ASSERT_TRUE(session.HoverSubmenu( + "layout", + MakePopup( + "menu.file.layout", + "menu.file.root", + UIInputPath{200u, 210u}, + UIInputPath{100u, 110u, 120u}, + UIPopupPlacement::RightStart)) + .changed); + ASSERT_TRUE(session.HoverSubmenu( + "advanced", + MakePopup( + "menu.file.advanced", + "menu.file.layout", + UIInputPath{300u, 310u}, + UIInputPath{200u, 210u, 220u}, + UIPopupPlacement::RightStart)) + .changed); + + const auto result = session.HoverSubmenu( + "export", + MakePopup( + "menu.file.export", + "menu.file.root", + UIInputPath{400u, 410u}, + UIInputPath{100u, 110u, 130u}, + UIPopupPlacement::RightStart)); + + EXPECT_TRUE(result.changed); + ExpectClosedIds(result.closedPopupIds, {"menu.file.layout", "menu.file.advanced"}); + EXPECT_EQ(result.openedPopupId, "menu.file.export"); + ASSERT_EQ(session.GetPopupOverlayModel().GetPopupCount(), 2u); + ExpectOpenSubmenuIds(session, {"export"}); + ASSERT_NE(session.FindPopupState("menu.file.export"), nullptr); + EXPECT_EQ(session.FindPopupState("menu.file.export")->itemId, "export"); +} + +TEST(UIEditorMenuSessionTest, HoveringAlreadyOpenSubmenuKeepsExistingDescendants) { + UIEditorMenuSession session = {}; + ASSERT_TRUE(session.OpenMenuBarRoot( + "file", + MakePopup("menu.file.root", "", UIInputPath{100u, 110u}, UIInputPath{1u, 2u})) + .changed); + ASSERT_TRUE(session.HoverSubmenu( + "layout", + MakePopup( + "menu.file.layout", + "menu.file.root", + UIInputPath{200u, 210u}, + UIInputPath{100u, 110u, 120u}, + UIPopupPlacement::RightStart)) + .changed); + ASSERT_TRUE(session.HoverSubmenu( + "advanced", + MakePopup( + "menu.file.advanced", + "menu.file.layout", + UIInputPath{300u, 310u}, + UIInputPath{200u, 210u, 220u}, + UIPopupPlacement::RightStart)) + .changed); + + const auto result = session.HoverSubmenu( + "layout", + MakePopup( + "menu.file.layout", + "menu.file.root", + UIInputPath{200u, 210u}, + UIInputPath{100u, 110u, 120u}, + UIPopupPlacement::RightStart)); + + EXPECT_FALSE(result.changed); + EXPECT_EQ(result.openRootMenuId, "file"); + ASSERT_EQ(session.GetPopupOverlayModel().GetPopupCount(), 3u); + ExpectOpenSubmenuIds(session, {"layout", "advanced"}); +} + +TEST(UIEditorMenuSessionTest, PointerDismissInsideRootClosesOnlyDescendantSubmenus) { + UIEditorMenuSession session = {}; + ASSERT_TRUE(session.OpenMenuBarRoot( + "file", + MakePopup("menu.file.root", "", UIInputPath{100u, 110u}, UIInputPath{1u, 2u})) + .changed); + ASSERT_TRUE(session.HoverSubmenu( + "layout", + MakePopup( + "menu.file.layout", + "menu.file.root", + UIInputPath{200u, 210u}, + UIInputPath{100u, 110u, 120u}, + UIPopupPlacement::RightStart)) + .changed); + + const auto result = session.DismissFromPointerDown(UIInputPath{100u, 110u, 130u}); + + EXPECT_TRUE(result.changed); + EXPECT_EQ(result.dismissReason, UIPopupDismissReason::PointerOutside); + ExpectClosedIds(result.closedPopupIds, {"menu.file.layout"}); + EXPECT_EQ(result.openRootMenuId, "file"); + EXPECT_TRUE(session.IsMenuOpen("file")); + EXPECT_TRUE(session.GetOpenSubmenuItemIds().empty()); + ASSERT_EQ(session.GetPopupOverlayModel().GetPopupCount(), 1u); +} + +TEST(UIEditorMenuSessionTest, PointerDismissOutsideAllClosesEntireMenuChain) { + UIEditorMenuSession session = {}; + ASSERT_TRUE(session.OpenMenuBarRoot( + "file", + MakePopup("menu.file.root", "", UIInputPath{100u, 110u}, UIInputPath{1u, 2u})) + .changed); + ASSERT_TRUE(session.HoverSubmenu( + "layout", + MakePopup( + "menu.file.layout", + "menu.file.root", + UIInputPath{200u, 210u}, + UIInputPath{100u, 110u, 120u}, + UIPopupPlacement::RightStart)) + .changed); + + const auto result = session.DismissFromPointerDown(UIInputPath{999u, 1000u}); + + EXPECT_TRUE(result.changed); + EXPECT_EQ(result.dismissReason, UIPopupDismissReason::PointerOutside); + ExpectClosedIds(result.closedPopupIds, {"menu.file.root", "menu.file.layout"}); + EXPECT_FALSE(result.HasOpenMenu()); + EXPECT_FALSE(session.HasOpenMenu()); + EXPECT_EQ(session.GetPopupOverlayModel().GetPopupCount(), 0u); +} + +TEST(UIEditorMenuSessionTest, EscapeDismissClosesTopmostPopupBeforeRoot) { + UIEditorMenuSession session = {}; + ASSERT_TRUE(session.OpenMenuBarRoot( + "file", + MakePopup("menu.file.root", "", UIInputPath{100u, 110u}, UIInputPath{1u, 2u})) + .changed); + ASSERT_TRUE(session.HoverSubmenu( + "layout", + MakePopup( + "menu.file.layout", + "menu.file.root", + UIInputPath{200u, 210u}, + UIInputPath{100u, 110u, 120u}, + UIPopupPlacement::RightStart)) + .changed); + + auto result = session.DismissFromEscape(); + + EXPECT_TRUE(result.changed); + EXPECT_EQ(result.dismissReason, UIPopupDismissReason::EscapeKey); + ExpectClosedIds(result.closedPopupIds, {"menu.file.layout"}); + EXPECT_EQ(result.openRootMenuId, "file"); + ASSERT_EQ(session.GetPopupOverlayModel().GetPopupCount(), 1u); + + result = session.DismissFromEscape(); + + EXPECT_TRUE(result.changed); + EXPECT_EQ(result.dismissReason, UIPopupDismissReason::EscapeKey); + ExpectClosedIds(result.closedPopupIds, {"menu.file.root"}); + EXPECT_FALSE(session.HasOpenMenu()); + EXPECT_EQ(session.GetPopupOverlayModel().GetPopupCount(), 0u); +} diff --git a/tests/UI/Editor/unit/test_ui_editor_panel_frame.cpp b/tests/UI/Editor/unit/test_ui_editor_panel_frame.cpp new file mode 100644 index 00000000..2ade3a8e --- /dev/null +++ b/tests/UI/Editor/unit/test_ui_editor_panel_frame.cpp @@ -0,0 +1,165 @@ +#include + +#include +#include + +namespace { + +using XCEngine::UI::UIColor; +using XCEngine::UI::UIDrawCommandType; +using XCEngine::UI::UIDrawList; +using XCEngine::UI::UIPoint; +using XCEngine::UI::UIRect; +using XCEngine::UI::Editor::Widgets::AppendUIEditorPanelFrameBackground; +using XCEngine::UI::Editor::Widgets::AppendUIEditorPanelFrameForeground; +using XCEngine::UI::Editor::Widgets::BuildUIEditorPanelFrameLayout; +using XCEngine::UI::Editor::Widgets::HitTestUIEditorPanelFrame; +using XCEngine::UI::Editor::Widgets::HitTestUIEditorPanelFrameAction; +using XCEngine::UI::Editor::Widgets::ResolveUIEditorPanelFrameBorderColor; +using XCEngine::UI::Editor::Widgets::ResolveUIEditorPanelFrameBorderThickness; +using XCEngine::UI::Editor::Widgets::UIEditorPanelFrameAction; +using XCEngine::UI::Editor::Widgets::UIEditorPanelFrameHitTarget; +using XCEngine::UI::Editor::Widgets::UIEditorPanelFrameLayout; +using XCEngine::UI::Editor::Widgets::UIEditorPanelFramePalette; +using XCEngine::UI::Editor::Widgets::UIEditorPanelFrameState; +using XCEngine::UI::Editor::Widgets::UIEditorPanelFrameText; + +void ExpectColorEq( + const UIColor& actual, + const UIColor& expected) { + EXPECT_FLOAT_EQ(actual.r, expected.r); + EXPECT_FLOAT_EQ(actual.g, expected.g); + EXPECT_FLOAT_EQ(actual.b, expected.b); + EXPECT_FLOAT_EQ(actual.a, expected.a); +} + +TEST(UIEditorPanelFrameTest, LayoutBuildsHeaderBodyFooterAndActionRectsFromState) { + UIEditorPanelFrameState state = {}; + state.active = true; + state.pinned = true; + state.closable = true; + state.pinnable = true; + state.showFooter = true; + + const UIEditorPanelFrameLayout layout = + BuildUIEditorPanelFrameLayout(UIRect(10.0f, 20.0f, 300.0f, 200.0f), state); + + EXPECT_FLOAT_EQ(layout.headerRect.x, 10.0f); + EXPECT_FLOAT_EQ(layout.headerRect.y, 20.0f); + EXPECT_FLOAT_EQ(layout.headerRect.width, 300.0f); + EXPECT_FLOAT_EQ(layout.headerRect.height, 36.0f); + + EXPECT_TRUE(layout.hasFooter); + EXPECT_FLOAT_EQ(layout.footerRect.y, 196.0f); + EXPECT_FLOAT_EQ(layout.footerRect.height, 24.0f); + + EXPECT_FLOAT_EQ(layout.bodyRect.x, 22.0f); + EXPECT_FLOAT_EQ(layout.bodyRect.y, 68.0f); + EXPECT_FLOAT_EQ(layout.bodyRect.width, 276.0f); + EXPECT_FLOAT_EQ(layout.bodyRect.height, 116.0f); + + EXPECT_TRUE(layout.showPinButton); + EXPECT_TRUE(layout.showCloseButton); + EXPECT_FLOAT_EQ(layout.closeButtonRect.x, 286.0f); + EXPECT_FLOAT_EQ(layout.closeButtonRect.y, 32.0f); + EXPECT_FLOAT_EQ(layout.closeButtonRect.width, 12.0f); + EXPECT_FLOAT_EQ(layout.pinButtonRect.x, 268.0f); + EXPECT_FLOAT_EQ(layout.pinButtonRect.y, 32.0f); +} + +TEST(UIEditorPanelFrameTest, BorderPolicyUsesFocusBeforeActiveBeforeHover) { + const UIEditorPanelFramePalette palette = {}; + + UIEditorPanelFrameState hoveredOnly = {}; + hoveredOnly.hovered = true; + UIEditorPanelFrameState activeOnly = hoveredOnly; + activeOnly.active = true; + UIEditorPanelFrameState focusedState = activeOnly; + focusedState.focused = true; + + ExpectColorEq( + ResolveUIEditorPanelFrameBorderColor(hoveredOnly, palette), + palette.hoveredBorderColor); + ExpectColorEq( + ResolveUIEditorPanelFrameBorderColor(activeOnly, palette), + palette.activeBorderColor); + ExpectColorEq( + ResolveUIEditorPanelFrameBorderColor(focusedState, palette), + palette.focusedBorderColor); + + EXPECT_FLOAT_EQ(ResolveUIEditorPanelFrameBorderThickness(UIEditorPanelFrameState{}), 1.0f); + EXPECT_FLOAT_EQ(ResolveUIEditorPanelFrameBorderThickness(hoveredOnly), 1.25f); + EXPECT_FLOAT_EQ(ResolveUIEditorPanelFrameBorderThickness(activeOnly), 1.5f); + EXPECT_FLOAT_EQ(ResolveUIEditorPanelFrameBorderThickness(focusedState), 2.0f); +} + +TEST(UIEditorPanelFrameTest, HitTestReturnsActionAndRegionTargetsFromLayout) { + UIEditorPanelFrameState state = {}; + state.closable = true; + state.pinnable = true; + state.showFooter = true; + + const UIEditorPanelFrameLayout layout = + BuildUIEditorPanelFrameLayout(UIRect(10.0f, 20.0f, 300.0f, 200.0f), state); + + EXPECT_EQ( + HitTestUIEditorPanelFrameAction(layout, state, UIPoint(269.0f, 35.0f)), + UIEditorPanelFrameAction::Pin); + EXPECT_EQ( + HitTestUIEditorPanelFrameAction(layout, state, UIPoint(287.0f, 35.0f)), + UIEditorPanelFrameAction::Close); + EXPECT_EQ( + HitTestUIEditorPanelFrame(layout, state, UIPoint(40.0f, 35.0f)), + UIEditorPanelFrameHitTarget::Header); + EXPECT_EQ( + HitTestUIEditorPanelFrame(layout, state, UIPoint(40.0f, 90.0f)), + UIEditorPanelFrameHitTarget::Body); + EXPECT_EQ( + HitTestUIEditorPanelFrame(layout, state, UIPoint(40.0f, 205.0f)), + UIEditorPanelFrameHitTarget::Footer); +} + +TEST(UIEditorPanelFrameTest, BackgroundAndForegroundEmitExpectedChromeAndActionCommands) { + UIEditorPanelFrameState state = {}; + state.active = true; + state.focused = true; + state.pinned = true; + state.closable = true; + state.pinnable = true; + state.showFooter = true; + state.pinHovered = true; + + const UIEditorPanelFramePalette palette = {}; + const UIEditorPanelFrameText text{ + "Inspector", + "PanelFrame chrome contract", + "focused | pinned | footer visible" + }; + const UIEditorPanelFrameLayout layout = + BuildUIEditorPanelFrameLayout(UIRect(20.0f, 30.0f, 320.0f, 220.0f), state); + + UIDrawList background("PanelFrameBackground"); + AppendUIEditorPanelFrameBackground(background, layout, state, palette); + + ASSERT_EQ(background.GetCommandCount(), 4u); + const auto& backgroundCommands = background.GetCommands(); + EXPECT_EQ(backgroundCommands[0].type, UIDrawCommandType::FilledRect); + EXPECT_EQ(backgroundCommands[1].type, UIDrawCommandType::RectOutline); + EXPECT_EQ(backgroundCommands[2].type, UIDrawCommandType::FilledRect); + EXPECT_EQ(backgroundCommands[3].type, UIDrawCommandType::FilledRect); + ExpectColorEq(backgroundCommands[1].color, palette.focusedBorderColor); + + UIDrawList foreground("PanelFrameForeground"); + AppendUIEditorPanelFrameForeground(foreground, layout, state, text, palette); + + ASSERT_EQ(foreground.GetCommandCount(), 9u); + const auto& foregroundCommands = foreground.GetCommands(); + EXPECT_EQ(foregroundCommands[0].type, UIDrawCommandType::Text); + EXPECT_EQ(foregroundCommands[0].text, "Inspector"); + EXPECT_EQ(foregroundCommands[1].text, "PanelFrame chrome contract"); + EXPECT_EQ(foregroundCommands[2].text, "focused | pinned | footer visible"); + EXPECT_EQ(foregroundCommands[5].text, "P"); + EXPECT_EQ(foregroundCommands[8].text, "X"); +} + +} // namespace diff --git a/tests/UI/Editor/unit/test_ui_editor_tab_strip.cpp b/tests/UI/Editor/unit/test_ui_editor_tab_strip.cpp new file mode 100644 index 00000000..b764561d --- /dev/null +++ b/tests/UI/Editor/unit/test_ui_editor_tab_strip.cpp @@ -0,0 +1,178 @@ +#include + +#include +#include + +namespace { + +using XCEngine::UI::UIDrawCommandType; +using XCEngine::UI::UIDrawList; +using XCEngine::UI::UIPoint; +using XCEngine::UI::UIRect; +using XCEngine::UI::Editor::Widgets::AppendUIEditorTabStripBackground; +using XCEngine::UI::Editor::Widgets::AppendUIEditorTabStripForeground; +using XCEngine::UI::Editor::Widgets::BuildUIEditorTabStripLayout; +using XCEngine::UI::Editor::Widgets::HitTestUIEditorTabStrip; +using XCEngine::UI::Editor::Widgets::ResolveUIEditorTabStripDesiredHeaderLabelWidth; +using XCEngine::UI::Editor::Widgets::ResolveUIEditorTabStripSelectedIndex; +using XCEngine::UI::Editor::Widgets::ResolveUIEditorTabStripSelectedIndexAfterClose; +using XCEngine::UI::Editor::Widgets::UIEditorTabStripHitTargetKind; +using XCEngine::UI::Editor::Widgets::UIEditorTabStripInvalidIndex; +using XCEngine::UI::Editor::Widgets::UIEditorTabStripItem; +using XCEngine::UI::Editor::Widgets::UIEditorTabStripLayout; +using XCEngine::UI::Editor::Widgets::UIEditorTabStripMetrics; +using XCEngine::UI::Editor::Widgets::UIEditorTabStripState; + +TEST(UIEditorTabStripTest, DesiredHeaderWidthReservesCloseButtonBudget) { + UIEditorTabStripMetrics metrics = {}; + metrics.layoutMetrics.tabHorizontalPadding = 10.0f; + metrics.estimatedGlyphWidth = 8.0f; + metrics.closeButtonExtent = 14.0f; + metrics.closeButtonGap = 6.0f; + metrics.closeInsetRight = 14.0f; + metrics.labelInsetX = 12.0f; + + const float closableWidth = ResolveUIEditorTabStripDesiredHeaderLabelWidth( + UIEditorTabStripItem{ "doc-a", "ABCD", true, 0.0f }, + metrics); + const float fixedWidth = ResolveUIEditorTabStripDesiredHeaderLabelWidth( + UIEditorTabStripItem{ "doc-b", "Ignored", false, 42.0f }, + metrics); + + EXPECT_FLOAT_EQ(closableWidth, 58.0f); + EXPECT_FLOAT_EQ(fixedWidth, 44.0f); +} + +TEST(UIEditorTabStripTest, SelectedIndexResolvesByTabIdAndFallsBackToValidRange) { + const std::vector items = { + { "doc-a", "Document A", true, 0.0f }, + { "doc-b", "Document B", true, 0.0f }, + { "doc-c", "Document C", false, 0.0f } + }; + + EXPECT_EQ(ResolveUIEditorTabStripSelectedIndex(items, "doc-b"), 1u); + EXPECT_EQ(ResolveUIEditorTabStripSelectedIndex(items, "missing", 2u), 2u); + EXPECT_EQ(ResolveUIEditorTabStripSelectedIndex(items, "missing"), 0u); + EXPECT_EQ( + ResolveUIEditorTabStripSelectedIndex({}, "missing"), + UIEditorTabStripInvalidIndex); +} + +TEST(UIEditorTabStripTest, ClosingTabsResolvesSelectionFallbackFromClosedIndex) { + EXPECT_EQ(ResolveUIEditorTabStripSelectedIndexAfterClose(1u, 1u, 3u), 1u); + EXPECT_EQ(ResolveUIEditorTabStripSelectedIndexAfterClose(2u, 2u, 3u), 1u); + EXPECT_EQ(ResolveUIEditorTabStripSelectedIndexAfterClose(2u, 0u, 3u), 1u); + EXPECT_EQ( + ResolveUIEditorTabStripSelectedIndexAfterClose(0u, 0u, 1u), + UIEditorTabStripInvalidIndex); +} + +TEST(UIEditorTabStripTest, LayoutUsesCoreTabArrangementAndBuildsCloseRects) { + UIEditorTabStripMetrics metrics = {}; + metrics.layoutMetrics.headerHeight = 30.0f; + metrics.layoutMetrics.tabMinWidth = 80.0f; + metrics.layoutMetrics.tabHorizontalPadding = 12.0f; + metrics.layoutMetrics.tabGap = 4.0f; + metrics.closeButtonExtent = 12.0f; + metrics.closeButtonGap = 6.0f; + metrics.closeInsetRight = 12.0f; + metrics.labelInsetX = 12.0f; + + const std::vector items = { + { "doc-a", "Document A", true, 48.0f }, + { "doc-b", "Document B", false, 40.0f } + }; + + UIEditorTabStripState state = {}; + state.selectedIndex = 0u; + + const UIEditorTabStripLayout layout = + BuildUIEditorTabStripLayout(UIRect(10.0f, 20.0f, 260.0f, 180.0f), items, state, metrics); + + EXPECT_FLOAT_EQ(layout.headerRect.x, 10.0f); + EXPECT_FLOAT_EQ(layout.headerRect.y, 20.0f); + EXPECT_FLOAT_EQ(layout.headerRect.width, 260.0f); + EXPECT_FLOAT_EQ(layout.headerRect.height, 30.0f); + + EXPECT_FLOAT_EQ(layout.contentRect.x, 10.0f); + EXPECT_FLOAT_EQ(layout.contentRect.y, 50.0f); + EXPECT_FLOAT_EQ(layout.contentRect.width, 260.0f); + EXPECT_FLOAT_EQ(layout.contentRect.height, 150.0f); + + ASSERT_EQ(layout.tabHeaderRects.size(), 2u); + EXPECT_FLOAT_EQ(layout.tabHeaderRects[0].x, 10.0f); + EXPECT_FLOAT_EQ(layout.tabHeaderRects[0].width, 90.0f); + EXPECT_FLOAT_EQ(layout.tabHeaderRects[1].x, 104.0f); + EXPECT_FLOAT_EQ(layout.tabHeaderRects[1].width, 80.0f); + + ASSERT_EQ(layout.closeButtonRects.size(), 2u); + EXPECT_TRUE(layout.showCloseButtons[0]); + EXPECT_FALSE(layout.showCloseButtons[1]); + EXPECT_FLOAT_EQ(layout.closeButtonRects[0].x, 76.0f); + EXPECT_FLOAT_EQ(layout.closeButtonRects[0].y, 29.0f); + EXPECT_FLOAT_EQ(layout.closeButtonRects[0].width, 12.0f); + EXPECT_FLOAT_EQ(layout.closeButtonRects[0].height, 12.0f); +} + +TEST(UIEditorTabStripTest, HitTestPrioritizesCloseButtonThenTabThenContent) { + const std::vector items = { + { "doc-a", "Document A", true, 48.0f }, + { "doc-b", "Document B", false, 40.0f } + }; + + UIEditorTabStripState state = {}; + state.selectedIndex = 0u; + const UIEditorTabStripLayout layout = + BuildUIEditorTabStripLayout(UIRect(10.0f, 20.0f, 260.0f, 180.0f), items, state); + + const auto closeHit = HitTestUIEditorTabStrip(layout, state, UIPoint(85.0f, 34.0f)); + EXPECT_EQ(closeHit.kind, UIEditorTabStripHitTargetKind::CloseButton); + EXPECT_EQ(closeHit.index, 0u); + + const auto tabHit = HitTestUIEditorTabStrip(layout, state, UIPoint(40.0f, 34.0f)); + EXPECT_EQ(tabHit.kind, UIEditorTabStripHitTargetKind::Tab); + EXPECT_EQ(tabHit.index, 0u); + + const auto contentHit = HitTestUIEditorTabStrip(layout, state, UIPoint(40.0f, 70.0f)); + EXPECT_EQ(contentHit.kind, UIEditorTabStripHitTargetKind::Content); + EXPECT_EQ(contentHit.index, UIEditorTabStripInvalidIndex); +} + +TEST(UIEditorTabStripTest, BackgroundAndForegroundEmitStableChromeCommands) { + const std::vector items = { + { "doc-a", "Document A", true, 48.0f }, + { "doc-b", "Document B", false, 40.0f } + }; + + UIEditorTabStripState state = {}; + state.selectedIndex = 0u; + state.hoveredIndex = 1u; + state.closeHoveredIndex = 0u; + state.focused = true; + + const UIEditorTabStripLayout layout = + BuildUIEditorTabStripLayout(UIRect(10.0f, 20.0f, 260.0f, 180.0f), items, state); + + UIDrawList background("TabStripBackground"); + AppendUIEditorTabStripBackground(background, layout, state); + ASSERT_EQ(background.GetCommandCount(), 8u); + const auto& backgroundCommands = background.GetCommands(); + EXPECT_EQ(backgroundCommands[0].type, UIDrawCommandType::FilledRect); + EXPECT_EQ(backgroundCommands[3].type, UIDrawCommandType::RectOutline); + EXPECT_EQ(backgroundCommands[4].type, UIDrawCommandType::FilledRect); + EXPECT_EQ(backgroundCommands[7].type, UIDrawCommandType::RectOutline); + + UIDrawList foreground("TabStripForeground"); + AppendUIEditorTabStripForeground(foreground, layout, items, state); + ASSERT_EQ(foreground.GetCommandCount(), 9u); + const auto& foregroundCommands = foreground.GetCommands(); + EXPECT_EQ(foregroundCommands[0].type, UIDrawCommandType::PushClipRect); + EXPECT_EQ(foregroundCommands[1].type, UIDrawCommandType::Text); + EXPECT_EQ(foregroundCommands[1].text, "Document A"); + EXPECT_EQ(foregroundCommands[5].type, UIDrawCommandType::Text); + EXPECT_EQ(foregroundCommands[5].text, "X"); + EXPECT_EQ(foregroundCommands[7].type, UIDrawCommandType::Text); + EXPECT_EQ(foregroundCommands[7].text, "Document B"); +} + +} // namespace