From 8eeb7af56efad44fd9416c034067cd3de121693e Mon Sep 17 00:00:00 2001 From: ssdfasd <2156608475@qq.com> Date: Tue, 7 Apr 2026 03:51:26 +0800 Subject: [PATCH] Build XCEditor menu and status shell widgets --- new_editor/CMakeLists.txt | 3 + new_editor/app/Host/AutoScreenshot.cpp | 31 +- new_editor/app/Host/NativeRenderer.cpp | 42 +- new_editor/app/Host/NativeRenderer.h | 2 + .../XCEditor/Core/UIEditorMenuSession.h | 4 + .../XCEditor/Widgets/UIEditorMenuBar.h | 120 ++ .../XCEditor/Widgets/UIEditorMenuPopup.h | 134 ++ .../XCEditor/Widgets/UIEditorStatusBar.h | 144 ++ new_editor/src/Core/UIEditorMenuSession.cpp | 31 +- new_editor/src/Widgets/UIEditorMenuBar.cpp | 215 +++ new_editor/src/Widgets/UIEditorMenuPopup.cpp | 289 ++++ new_editor/src/Widgets/UIEditorStatusBar.cpp | 279 ++++ tests/UI/Editor/integration/CMakeLists.txt | 28 +- tests/UI/Editor/integration/README.md | 23 + .../Editor/integration/shell/CMakeLists.txt | 6 + .../shell/context_menu_basic/CMakeLists.txt | 31 + .../shell/context_menu_basic/main.cpp | 1249 +++++++++++++++++ .../integration/shell/menu_bar_basic/main.cpp | 207 +-- .../shell/status_bar_basic/CMakeLists.txt | 30 + .../shell/status_bar_basic/main.cpp | 531 +++++++ tests/UI/Editor/unit/CMakeLists.txt | 3 + .../Editor/unit/test_ui_editor_menu_bar.cpp | 117 ++ .../Editor/unit/test_ui_editor_menu_popup.cpp | 136 ++ .../unit/test_ui_editor_menu_session.cpp | 39 + .../Editor/unit/test_ui_editor_status_bar.cpp | 120 ++ 25 files changed, 3708 insertions(+), 106 deletions(-) create mode 100644 new_editor/include/XCEditor/Widgets/UIEditorMenuBar.h create mode 100644 new_editor/include/XCEditor/Widgets/UIEditorMenuPopup.h create mode 100644 new_editor/include/XCEditor/Widgets/UIEditorStatusBar.h create mode 100644 new_editor/src/Widgets/UIEditorMenuBar.cpp create mode 100644 new_editor/src/Widgets/UIEditorMenuPopup.cpp create mode 100644 new_editor/src/Widgets/UIEditorStatusBar.cpp create mode 100644 tests/UI/Editor/integration/shell/context_menu_basic/CMakeLists.txt create mode 100644 tests/UI/Editor/integration/shell/context_menu_basic/main.cpp create mode 100644 tests/UI/Editor/integration/shell/status_bar_basic/CMakeLists.txt create mode 100644 tests/UI/Editor/integration/shell/status_bar_basic/main.cpp create mode 100644 tests/UI/Editor/unit/test_ui_editor_menu_bar.cpp create mode 100644 tests/UI/Editor/unit/test_ui_editor_menu_popup.cpp create mode 100644 tests/UI/Editor/unit/test_ui_editor_status_bar.cpp diff --git a/new_editor/CMakeLists.txt b/new_editor/CMakeLists.txt index 9b0b5c81..8467b3a6 100644 --- a/new_editor/CMakeLists.txt +++ b/new_editor/CMakeLists.txt @@ -25,7 +25,10 @@ add_library(XCUIEditorLib STATIC src/Core/UIEditorWorkspaceSession.cpp src/Widgets/UIEditorCollectionPrimitives.cpp src/Widgets/UIEditorDockHost.cpp + src/Widgets/UIEditorMenuBar.cpp + src/Widgets/UIEditorMenuPopup.cpp src/Widgets/UIEditorPanelFrame.cpp + src/Widgets/UIEditorStatusBar.cpp src/Widgets/UIEditorTabStrip.cpp ) diff --git a/new_editor/app/Host/AutoScreenshot.cpp b/new_editor/app/Host/AutoScreenshot.cpp index ef919f6f..bcf4cefa 100644 --- a/new_editor/app/Host/AutoScreenshot.cpp +++ b/new_editor/app/Host/AutoScreenshot.cpp @@ -4,12 +4,34 @@ #include #include +#include #include #include #include namespace XCEngine::UI::Editor::Host { +namespace { + +bool IsAutoCaptureOnStartupEnabled() { + const char* value = std::getenv("XCUI_AUTO_CAPTURE_ON_STARTUP"); + if (value == nullptr || value[0] == '\0') { + return false; + } + + std::string normalized = value; + for (char& character : normalized) { + character = static_cast(std::tolower(static_cast(character))); + } + + return normalized != "0" && + normalized != "false" && + normalized != "off" && + normalized != "no"; +} + +} // namespace + void AutoScreenshotController::Initialize(const std::filesystem::path& captureRoot) { m_captureRoot = captureRoot.lexically_normal(); m_historyRoot = (m_captureRoot / "history").lexically_normal(); @@ -19,6 +41,9 @@ void AutoScreenshotController::Initialize(const std::filesystem::path& captureRo m_pendingReason.clear(); m_lastCaptureSummary.clear(); m_lastCaptureError.clear(); + if (IsAutoCaptureOnStartupEnabled()) { + RequestCapture("startup"); + } } void AutoScreenshotController::Shutdown() { @@ -37,7 +62,11 @@ void AutoScreenshotController::CaptureIfRequested( unsigned int width, unsigned int height, bool framePresented) { - if (!m_capturePending || !framePresented || drawData.Empty() || width == 0u || height == 0u) { + if (!m_capturePending) { + return; + } + + if (!framePresented || drawData.Empty() || width == 0u || height == 0u) { return; } diff --git a/new_editor/app/Host/NativeRenderer.cpp b/new_editor/app/Host/NativeRenderer.cpp index 0a7f77a8..4f270d4a 100644 --- a/new_editor/app/Host/NativeRenderer.cpp +++ b/new_editor/app/Host/NativeRenderer.cpp @@ -24,23 +24,29 @@ bool NativeRenderer::Initialize(HWND hwnd) { Shutdown(); if (hwnd == nullptr) { + m_lastRenderError = "Initialize rejected a null hwnd."; return false; } m_hwnd = hwnd; - if (FAILED(D2D1CreateFactory(D2D1_FACTORY_TYPE_SINGLE_THREADED, m_d2dFactory.ReleaseAndGetAddressOf()))) { + HRESULT hr = D2D1CreateFactory(D2D1_FACTORY_TYPE_SINGLE_THREADED, m_d2dFactory.ReleaseAndGetAddressOf()); + if (FAILED(hr)) { + m_lastRenderError = HrToString("D2D1CreateFactory", hr); Shutdown(); return false; } - if (FAILED(DWriteCreateFactory( + hr = DWriteCreateFactory( DWRITE_FACTORY_TYPE_SHARED, __uuidof(IDWriteFactory), - reinterpret_cast(m_dwriteFactory.ReleaseAndGetAddressOf())))) { + reinterpret_cast(m_dwriteFactory.ReleaseAndGetAddressOf())); + if (FAILED(hr)) { + m_lastRenderError = HrToString("DWriteCreateFactory", hr); Shutdown(); return false; } + m_lastRenderError.clear(); return EnsureRenderTarget(); } @@ -71,17 +77,31 @@ void NativeRenderer::Resize(UINT width, UINT height) { bool NativeRenderer::Render(const ::XCEngine::UI::UIDrawData& drawData) { if (!EnsureRenderTarget()) { + if (m_lastRenderError.empty()) { + m_lastRenderError = "EnsureRenderTarget failed."; + } return false; } const bool rendered = RenderToTarget(*m_renderTarget.Get(), *m_solidBrush.Get(), drawData); const HRESULT hr = m_renderTarget->EndDraw(); if (hr == D2DERR_RECREATE_TARGET) { + m_lastRenderError = HrToString("ID2D1HwndRenderTarget::EndDraw", hr); DiscardRenderTarget(); return false; } - return rendered && SUCCEEDED(hr); + if (!rendered || FAILED(hr)) { + m_lastRenderError = HrToString("ID2D1HwndRenderTarget::EndDraw", hr); + return false; + } + + m_lastRenderError.clear(); + return true; +} + +const std::string& NativeRenderer::GetLastRenderError() const { + return m_lastRenderError; } bool NativeRenderer::CaptureToPng( @@ -231,6 +251,7 @@ bool NativeRenderer::CaptureToPng( bool NativeRenderer::EnsureRenderTarget() { if (!m_hwnd || !m_d2dFactory || !m_dwriteFactory) { + m_lastRenderError = "EnsureRenderTarget requires hwnd, D2D factory, and DWrite factory."; return false; } @@ -285,21 +306,26 @@ bool NativeRenderer::CreateDeviceResources() { m_hwnd, D2D1::SizeU(width, height)); - if (FAILED(m_d2dFactory->CreateHwndRenderTarget( + const HRESULT renderTargetHr = m_d2dFactory->CreateHwndRenderTarget( renderTargetProps, hwndProps, - m_renderTarget.ReleaseAndGetAddressOf()))) { + m_renderTarget.ReleaseAndGetAddressOf()); + if (FAILED(renderTargetHr)) { + m_lastRenderError = HrToString("ID2D1Factory::CreateHwndRenderTarget", renderTargetHr); return false; } - if (FAILED(m_renderTarget->CreateSolidColorBrush( + const HRESULT brushHr = m_renderTarget->CreateSolidColorBrush( D2D1::ColorF(1.0f, 1.0f, 1.0f, 1.0f), - m_solidBrush.ReleaseAndGetAddressOf()))) { + m_solidBrush.ReleaseAndGetAddressOf()); + if (FAILED(brushHr)) { + m_lastRenderError = HrToString("ID2D1HwndRenderTarget::CreateSolidColorBrush", brushHr); DiscardRenderTarget(); return false; } m_renderTarget->SetTextAntialiasMode(D2D1_TEXT_ANTIALIAS_MODE_GRAYSCALE); + m_lastRenderError.clear(); return true; } diff --git a/new_editor/app/Host/NativeRenderer.h b/new_editor/app/Host/NativeRenderer.h index 82f07332..5c89aec1 100644 --- a/new_editor/app/Host/NativeRenderer.h +++ b/new_editor/app/Host/NativeRenderer.h @@ -26,6 +26,7 @@ public: void Shutdown(); void Resize(UINT width, UINT height); bool Render(const ::XCEngine::UI::UIDrawData& drawData); + const std::string& GetLastRenderError() const; bool CaptureToPng( const ::XCEngine::UI::UIDrawData& drawData, UINT width, @@ -59,6 +60,7 @@ private: Microsoft::WRL::ComPtr m_renderTarget; Microsoft::WRL::ComPtr m_solidBrush; std::unordered_map> m_textFormats; + std::string m_lastRenderError = {}; bool m_wicComInitialized = false; }; diff --git a/new_editor/include/XCEditor/Core/UIEditorMenuSession.h b/new_editor/include/XCEditor/Core/UIEditorMenuSession.h index 1f0fa204..6ac2e8ab 100644 --- a/new_editor/include/XCEditor/Core/UIEditorMenuSession.h +++ b/new_editor/include/XCEditor/Core/UIEditorMenuSession.h @@ -64,6 +64,10 @@ public: void Reset(); + UIEditorMenuSessionMutationResult OpenRootMenu( + std::string_view menuId, + Widgets::UIPopupOverlayEntry entry); + UIEditorMenuSessionMutationResult OpenMenuBarRoot( std::string_view menuId, Widgets::UIPopupOverlayEntry entry); diff --git a/new_editor/include/XCEditor/Widgets/UIEditorMenuBar.h b/new_editor/include/XCEditor/Widgets/UIEditorMenuBar.h new file mode 100644 index 00000000..aa6ebb11 --- /dev/null +++ b/new_editor/include/XCEditor/Widgets/UIEditorMenuBar.h @@ -0,0 +1,120 @@ +#pragma once + +#include + +#include +#include +#include +#include + +namespace XCEngine::UI::Editor::Widgets { + +inline constexpr std::size_t UIEditorMenuBarInvalidIndex = + static_cast(-1); + +struct UIEditorMenuBarItem { + std::string menuId = {}; + std::string label = {}; + bool enabled = true; + float desiredLabelWidth = 0.0f; +}; + +struct UIEditorMenuBarState { + std::size_t openIndex = UIEditorMenuBarInvalidIndex; + std::size_t hoveredIndex = UIEditorMenuBarInvalidIndex; + bool focused = false; +}; + +struct UIEditorMenuBarMetrics { + float barHeight = 34.0f; + float horizontalInset = 10.0f; + float verticalInset = 4.0f; + float buttonGap = 6.0f; + float buttonPaddingX = 14.0f; + float estimatedGlyphWidth = 7.0f; + float labelInsetY = -1.0f; + float barCornerRounding = 8.0f; + float buttonCornerRounding = 7.0f; + float baseBorderThickness = 1.0f; + float focusedBorderThickness = 2.0f; + float openBorderThickness = 1.5f; +}; + +struct UIEditorMenuBarPalette { + ::XCEngine::UI::UIColor barColor = + ::XCEngine::UI::UIColor(0.16f, 0.16f, 0.16f, 1.0f); + ::XCEngine::UI::UIColor buttonColor = + ::XCEngine::UI::UIColor(0.23f, 0.23f, 0.23f, 1.0f); + ::XCEngine::UI::UIColor buttonHoveredColor = + ::XCEngine::UI::UIColor(0.29f, 0.29f, 0.29f, 1.0f); + ::XCEngine::UI::UIColor buttonOpenColor = + ::XCEngine::UI::UIColor(0.35f, 0.35f, 0.35f, 1.0f); + ::XCEngine::UI::UIColor borderColor = + ::XCEngine::UI::UIColor(0.30f, 0.30f, 0.30f, 1.0f); + ::XCEngine::UI::UIColor focusedBorderColor = + ::XCEngine::UI::UIColor(0.84f, 0.84f, 0.84f, 1.0f); + ::XCEngine::UI::UIColor openBorderColor = + ::XCEngine::UI::UIColor(0.68f, 0.68f, 0.68f, 1.0f); + ::XCEngine::UI::UIColor textPrimary = + ::XCEngine::UI::UIColor(0.94f, 0.94f, 0.94f, 1.0f); + ::XCEngine::UI::UIColor textMuted = + ::XCEngine::UI::UIColor(0.74f, 0.74f, 0.74f, 1.0f); + ::XCEngine::UI::UIColor textDisabled = + ::XCEngine::UI::UIColor(0.52f, 0.52f, 0.52f, 1.0f); +}; + +struct UIEditorMenuBarLayout { + ::XCEngine::UI::UIRect bounds = {}; + ::XCEngine::UI::UIRect contentRect = {}; + std::vector<::XCEngine::UI::UIRect> buttonRects = {}; +}; + +enum class UIEditorMenuBarHitTargetKind : std::uint8_t { + None = 0, + BarBackground, + Button +}; + +struct UIEditorMenuBarHitTarget { + UIEditorMenuBarHitTargetKind kind = UIEditorMenuBarHitTargetKind::None; + std::size_t index = UIEditorMenuBarInvalidIndex; +}; + +float ResolveUIEditorMenuBarDesiredButtonWidth( + const UIEditorMenuBarItem& item, + const UIEditorMenuBarMetrics& metrics = {}); + +UIEditorMenuBarLayout BuildUIEditorMenuBarLayout( + const ::XCEngine::UI::UIRect& bounds, + const std::vector& items, + const UIEditorMenuBarMetrics& metrics = {}); + +UIEditorMenuBarHitTarget HitTestUIEditorMenuBar( + const UIEditorMenuBarLayout& layout, + const ::XCEngine::UI::UIPoint& point); + +void AppendUIEditorMenuBarBackground( + ::XCEngine::UI::UIDrawList& drawList, + const UIEditorMenuBarLayout& layout, + const std::vector& items, + const UIEditorMenuBarState& state, + const UIEditorMenuBarPalette& palette = {}, + const UIEditorMenuBarMetrics& metrics = {}); + +void AppendUIEditorMenuBarForeground( + ::XCEngine::UI::UIDrawList& drawList, + const UIEditorMenuBarLayout& layout, + const std::vector& items, + const UIEditorMenuBarState& state, + const UIEditorMenuBarPalette& palette = {}, + const UIEditorMenuBarMetrics& metrics = {}); + +void AppendUIEditorMenuBar( + ::XCEngine::UI::UIDrawList& drawList, + const ::XCEngine::UI::UIRect& bounds, + const std::vector& items, + const UIEditorMenuBarState& state, + const UIEditorMenuBarPalette& palette = {}, + const UIEditorMenuBarMetrics& metrics = {}); + +} // namespace XCEngine::UI::Editor::Widgets diff --git a/new_editor/include/XCEditor/Widgets/UIEditorMenuPopup.h b/new_editor/include/XCEditor/Widgets/UIEditorMenuPopup.h new file mode 100644 index 00000000..2e4fa96b --- /dev/null +++ b/new_editor/include/XCEditor/Widgets/UIEditorMenuPopup.h @@ -0,0 +1,134 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include + +namespace XCEngine::UI::Editor::Widgets { + +inline constexpr std::size_t UIEditorMenuPopupInvalidIndex = + static_cast(-1); + +struct UIEditorMenuPopupItem { + std::string itemId = {}; + ::XCEngine::UI::Editor::UIEditorMenuItemKind kind = + ::XCEngine::UI::Editor::UIEditorMenuItemKind::Command; + std::string label = {}; + std::string shortcutText = {}; + bool enabled = true; + bool checked = false; + bool hasSubmenu = false; + float desiredLabelWidth = 0.0f; + float desiredShortcutWidth = 0.0f; +}; + +struct UIEditorMenuPopupState { + std::size_t hoveredIndex = UIEditorMenuPopupInvalidIndex; + std::size_t submenuOpenIndex = UIEditorMenuPopupInvalidIndex; + bool focused = false; +}; + +struct UIEditorMenuPopupMetrics { + float contentPaddingX = 6.0f; + float contentPaddingY = 6.0f; + float itemHeight = 28.0f; + float separatorHeight = 9.0f; + float checkColumnWidth = 18.0f; + float shortcutGap = 20.0f; + float submenuIndicatorWidth = 14.0f; + float rowCornerRounding = 5.0f; + float popupCornerRounding = 8.0f; + float labelInsetX = 14.0f; + float labelInsetY = -1.0f; + float shortcutInsetRight = 24.0f; + float estimatedGlyphWidth = 7.0f; + float separatorThickness = 1.0f; + float borderThickness = 1.0f; +}; + +struct UIEditorMenuPopupPalette { + ::XCEngine::UI::UIColor popupColor = + ::XCEngine::UI::UIColor(0.17f, 0.17f, 0.17f, 1.0f); + ::XCEngine::UI::UIColor borderColor = + ::XCEngine::UI::UIColor(0.31f, 0.31f, 0.31f, 1.0f); + ::XCEngine::UI::UIColor itemHoverColor = + ::XCEngine::UI::UIColor(0.28f, 0.28f, 0.28f, 1.0f); + ::XCEngine::UI::UIColor itemOpenColor = + ::XCEngine::UI::UIColor(0.34f, 0.34f, 0.34f, 1.0f); + ::XCEngine::UI::UIColor separatorColor = + ::XCEngine::UI::UIColor(0.32f, 0.32f, 0.32f, 1.0f); + ::XCEngine::UI::UIColor textPrimary = + ::XCEngine::UI::UIColor(0.94f, 0.94f, 0.94f, 1.0f); + ::XCEngine::UI::UIColor textMuted = + ::XCEngine::UI::UIColor(0.76f, 0.76f, 0.76f, 1.0f); + ::XCEngine::UI::UIColor textDisabled = + ::XCEngine::UI::UIColor(0.50f, 0.50f, 0.50f, 1.0f); + ::XCEngine::UI::UIColor glyphColor = + ::XCEngine::UI::UIColor(0.90f, 0.90f, 0.90f, 1.0f); +}; + +struct UIEditorMenuPopupLayout { + ::XCEngine::UI::UIRect popupRect = {}; + ::XCEngine::UI::UIRect contentRect = {}; + std::vector<::XCEngine::UI::UIRect> itemRects = {}; +}; + +enum class UIEditorMenuPopupHitTargetKind : std::uint8_t { + None = 0, + PopupSurface, + Item +}; + +struct UIEditorMenuPopupHitTarget { + UIEditorMenuPopupHitTargetKind kind = UIEditorMenuPopupHitTargetKind::None; + std::size_t index = UIEditorMenuPopupInvalidIndex; +}; + +float ResolveUIEditorMenuPopupDesiredWidth( + const std::vector& items, + const UIEditorMenuPopupMetrics& metrics = {}); + +float MeasureUIEditorMenuPopupHeight( + const std::vector& items, + const UIEditorMenuPopupMetrics& metrics = {}); + +UIEditorMenuPopupLayout BuildUIEditorMenuPopupLayout( + const ::XCEngine::UI::UIRect& popupRect, + const std::vector& items, + const UIEditorMenuPopupMetrics& metrics = {}); + +UIEditorMenuPopupHitTarget HitTestUIEditorMenuPopup( + const UIEditorMenuPopupLayout& layout, + const std::vector& items, + const ::XCEngine::UI::UIPoint& point); + +void AppendUIEditorMenuPopupBackground( + ::XCEngine::UI::UIDrawList& drawList, + const UIEditorMenuPopupLayout& layout, + const std::vector& items, + const UIEditorMenuPopupState& state, + const UIEditorMenuPopupPalette& palette = {}, + const UIEditorMenuPopupMetrics& metrics = {}); + +void AppendUIEditorMenuPopupForeground( + ::XCEngine::UI::UIDrawList& drawList, + const UIEditorMenuPopupLayout& layout, + const std::vector& items, + const UIEditorMenuPopupState& state, + const UIEditorMenuPopupPalette& palette = {}, + const UIEditorMenuPopupMetrics& metrics = {}); + +void AppendUIEditorMenuPopup( + ::XCEngine::UI::UIDrawList& drawList, + const ::XCEngine::UI::UIRect& popupRect, + const std::vector& items, + const UIEditorMenuPopupState& state, + const UIEditorMenuPopupPalette& palette = {}, + const UIEditorMenuPopupMetrics& metrics = {}); + +} // namespace XCEngine::UI::Editor::Widgets diff --git a/new_editor/include/XCEditor/Widgets/UIEditorStatusBar.h b/new_editor/include/XCEditor/Widgets/UIEditorStatusBar.h new file mode 100644 index 00000000..952376c5 --- /dev/null +++ b/new_editor/include/XCEditor/Widgets/UIEditorStatusBar.h @@ -0,0 +1,144 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include + +namespace XCEngine::UI::Editor::Widgets { + +inline constexpr std::size_t UIEditorStatusBarInvalidIndex = + static_cast(-1); + +enum class UIEditorStatusBarSlot : std::uint8_t { + Leading = 0, + Trailing +}; + +enum class UIEditorStatusBarTextTone : std::uint8_t { + Primary = 0, + Muted, + Accent +}; + +enum class UIEditorStatusBarHitTargetKind : std::uint8_t { + None = 0, + Background, + Segment, + Separator +}; + +struct UIEditorStatusBarSegment { + std::string segmentId = {}; + std::string label = {}; + UIEditorStatusBarSlot slot = UIEditorStatusBarSlot::Leading; + UIEditorStatusBarTextTone tone = UIEditorStatusBarTextTone::Primary; + bool interactive = true; + bool showSeparator = false; + float desiredWidth = 0.0f; +}; + +struct UIEditorStatusBarState { + std::size_t hoveredIndex = UIEditorStatusBarInvalidIndex; + std::size_t activeIndex = UIEditorStatusBarInvalidIndex; + bool focused = false; +}; + +struct UIEditorStatusBarMetrics { + float barHeight = 28.0f; + float outerPaddingX = 10.0f; + float segmentPaddingX = 10.0f; + float segmentPaddingY = 6.0f; + float segmentGap = 4.0f; + float separatorWidth = 1.0f; + float separatorInsetY = 6.0f; + float slotGapMin = 18.0f; + float cornerRounding = 8.0f; + float estimatedGlyphWidth = 7.0f; + float borderThickness = 1.0f; + float focusedBorderThickness = 1.5f; +}; + +struct UIEditorStatusBarPalette { + ::XCEngine::UI::UIColor surfaceColor = + ::XCEngine::UI::UIColor(0.15f, 0.15f, 0.15f, 1.0f); + ::XCEngine::UI::UIColor borderColor = + ::XCEngine::UI::UIColor(0.28f, 0.28f, 0.28f, 1.0f); + ::XCEngine::UI::UIColor focusedBorderColor = + ::XCEngine::UI::UIColor(0.78f, 0.78f, 0.78f, 1.0f); + ::XCEngine::UI::UIColor segmentColor = + ::XCEngine::UI::UIColor(0.17f, 0.17f, 0.17f, 1.0f); + ::XCEngine::UI::UIColor segmentHoveredColor = + ::XCEngine::UI::UIColor(0.23f, 0.23f, 0.23f, 1.0f); + ::XCEngine::UI::UIColor segmentActiveColor = + ::XCEngine::UI::UIColor(0.29f, 0.29f, 0.29f, 1.0f); + ::XCEngine::UI::UIColor segmentBorderColor = + ::XCEngine::UI::UIColor(0.35f, 0.35f, 0.35f, 1.0f); + ::XCEngine::UI::UIColor separatorColor = + ::XCEngine::UI::UIColor(0.32f, 0.32f, 0.32f, 1.0f); + ::XCEngine::UI::UIColor textPrimary = + ::XCEngine::UI::UIColor(0.92f, 0.92f, 0.92f, 1.0f); + ::XCEngine::UI::UIColor textMuted = + ::XCEngine::UI::UIColor(0.66f, 0.66f, 0.66f, 1.0f); + ::XCEngine::UI::UIColor textAccent = + ::XCEngine::UI::UIColor(0.94f, 0.94f, 0.94f, 1.0f); +}; + +struct UIEditorStatusBarLayout { + ::XCEngine::UI::UIRect bounds = {}; + ::XCEngine::UI::UIRect leadingSlotRect = {}; + ::XCEngine::UI::UIRect trailingSlotRect = {}; + std::vector<::XCEngine::UI::UIRect> segmentRects = {}; + std::vector<::XCEngine::UI::UIRect> separatorRects = {}; +}; + +struct UIEditorStatusBarHitTarget { + UIEditorStatusBarHitTargetKind kind = UIEditorStatusBarHitTargetKind::None; + std::size_t index = UIEditorStatusBarInvalidIndex; +}; + +float ResolveUIEditorStatusBarDesiredSegmentWidth( + const UIEditorStatusBarSegment& segment, + const UIEditorStatusBarMetrics& metrics = {}); + +UIEditorStatusBarLayout BuildUIEditorStatusBarLayout( + const ::XCEngine::UI::UIRect& bounds, + const std::vector& segments, + const UIEditorStatusBarMetrics& metrics = {}); + +UIEditorStatusBarHitTarget HitTestUIEditorStatusBar( + const UIEditorStatusBarLayout& layout, + const ::XCEngine::UI::UIPoint& point); + +::XCEngine::UI::UIColor ResolveUIEditorStatusBarTextColor( + UIEditorStatusBarTextTone tone, + const UIEditorStatusBarPalette& palette = {}); + +void AppendUIEditorStatusBarBackground( + ::XCEngine::UI::UIDrawList& drawList, + const UIEditorStatusBarLayout& layout, + const std::vector& segments, + const UIEditorStatusBarState& state, + const UIEditorStatusBarPalette& palette = {}, + const UIEditorStatusBarMetrics& metrics = {}); + +void AppendUIEditorStatusBarForeground( + ::XCEngine::UI::UIDrawList& drawList, + const UIEditorStatusBarLayout& layout, + const std::vector& segments, + const UIEditorStatusBarState& state, + const UIEditorStatusBarPalette& palette = {}, + const UIEditorStatusBarMetrics& metrics = {}); + +void AppendUIEditorStatusBar( + ::XCEngine::UI::UIDrawList& drawList, + const ::XCEngine::UI::UIRect& bounds, + const std::vector& segments, + const UIEditorStatusBarState& state, + const UIEditorStatusBarPalette& palette = {}, + const UIEditorStatusBarMetrics& metrics = {}); + +} // namespace XCEngine::UI::Editor::Widgets diff --git a/new_editor/src/Core/UIEditorMenuSession.cpp b/new_editor/src/Core/UIEditorMenuSession.cpp index 627165b2..5de9e4d6 100644 --- a/new_editor/src/Core/UIEditorMenuSession.cpp +++ b/new_editor/src/Core/UIEditorMenuSession.cpp @@ -5,6 +5,27 @@ namespace XCEngine::UI::Editor { +namespace { + +bool AreEquivalentPopupEntries( + const Widgets::UIPopupOverlayEntry& lhs, + const Widgets::UIPopupOverlayEntry& rhs) { + return lhs.popupId == rhs.popupId && + lhs.parentPopupId == rhs.parentPopupId && + lhs.anchorRect.x == rhs.anchorRect.x && + lhs.anchorRect.y == rhs.anchorRect.y && + lhs.anchorRect.width == rhs.anchorRect.width && + lhs.anchorRect.height == rhs.anchorRect.height && + lhs.anchorPath == rhs.anchorPath && + lhs.surfacePath == rhs.surfacePath && + lhs.placement == rhs.placement && + lhs.dismissOnPointerOutside == rhs.dismissOnPointerOutside && + lhs.dismissOnEscape == rhs.dismissOnEscape && + lhs.dismissOnFocusLoss == rhs.dismissOnFocusLoss; +} + +} // namespace + bool UIEditorMenuSession::IsPopupOpen(std::string_view popupId) const { return m_popupOverlayModel.FindPopup(popupId) != nullptr; } @@ -28,6 +49,12 @@ void UIEditorMenuSession::Reset() { } UIEditorMenuSessionMutationResult UIEditorMenuSession::OpenMenuBarRoot( + std::string_view menuId, + Widgets::UIPopupOverlayEntry entry) { + return OpenRootMenu(menuId, std::move(entry)); +} + +UIEditorMenuSessionMutationResult UIEditorMenuSession::OpenRootMenu( std::string_view menuId, Widgets::UIPopupOverlayEntry entry) { if (menuId.empty() || entry.popupId.empty()) { @@ -37,7 +64,7 @@ UIEditorMenuSessionMutationResult UIEditorMenuSession::OpenMenuBarRoot( const Widgets::UIPopupOverlayEntry* rootPopup = m_popupOverlayModel.GetRootPopup(); if (rootPopup != nullptr && m_openRootMenuId == menuId && - rootPopup->popupId == entry.popupId) { + AreEquivalentPopupEntries(*rootPopup, entry)) { return BuildResult({}); } @@ -64,7 +91,7 @@ UIEditorMenuSessionMutationResult UIEditorMenuSession::HoverMenuBarRoot( return BuildResult({}); } - return OpenMenuBarRoot(menuId, std::move(entry)); + return OpenRootMenu(menuId, std::move(entry)); } UIEditorMenuSessionMutationResult UIEditorMenuSession::HoverSubmenu( diff --git a/new_editor/src/Widgets/UIEditorMenuBar.cpp b/new_editor/src/Widgets/UIEditorMenuBar.cpp new file mode 100644 index 00000000..0f61d8f6 --- /dev/null +++ b/new_editor/src/Widgets/UIEditorMenuBar.cpp @@ -0,0 +1,215 @@ +#include + +#include + +namespace XCEngine::UI::Editor::Widgets { + +namespace { + +using ::XCEngine::UI::UIColor; +using ::XCEngine::UI::UIDrawList; +using ::XCEngine::UI::UIPoint; +using ::XCEngine::UI::UIRect; + +constexpr float kMenuBarFontSize = 13.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; +} + +float ResolveEstimatedLabelWidth( + const UIEditorMenuBarItem& item, + const UIEditorMenuBarMetrics& metrics) { + if (item.desiredLabelWidth > 0.0f) { + return item.desiredLabelWidth; + } + + return static_cast(item.label.size()) * ClampNonNegative(metrics.estimatedGlyphWidth); +} + +float ResolveLabelTop(const UIRect& rect, const UIEditorMenuBarMetrics& metrics) { + return rect.y + (std::max)(0.0f, (rect.height - kMenuBarFontSize) * 0.5f) + metrics.labelInsetY; +} + +UIColor ResolveButtonFillColor( + bool open, + bool hovered, + const UIEditorMenuBarPalette& palette) { + if (open) { + return palette.buttonOpenColor; + } + + if (hovered) { + return palette.buttonHoveredColor; + } + + return palette.buttonColor; +} + +UIColor ResolveButtonBorderColor( + bool open, + bool focused, + const UIEditorMenuBarPalette& palette) { + if (focused) { + return palette.focusedBorderColor; + } + + if (open) { + return palette.openBorderColor; + } + + return palette.borderColor; +} + +float ResolveButtonBorderThickness( + bool open, + bool focused, + const UIEditorMenuBarMetrics& metrics) { + if (focused) { + return metrics.focusedBorderThickness; + } + + if (open) { + return metrics.openBorderThickness; + } + + return metrics.baseBorderThickness; +} + +} // namespace + +float ResolveUIEditorMenuBarDesiredButtonWidth( + const UIEditorMenuBarItem& item, + const UIEditorMenuBarMetrics& metrics) { + return ResolveEstimatedLabelWidth(item, metrics) + ClampNonNegative(metrics.buttonPaddingX) * 2.0f; +} + +UIEditorMenuBarLayout BuildUIEditorMenuBarLayout( + const UIRect& bounds, + const std::vector& items, + const UIEditorMenuBarMetrics& metrics) { + UIEditorMenuBarLayout layout = {}; + layout.bounds = UIRect( + bounds.x, + bounds.y, + ClampNonNegative(bounds.width), + ClampNonNegative(bounds.height)); + layout.contentRect = UIRect( + layout.bounds.x + ClampNonNegative(metrics.horizontalInset), + layout.bounds.y + ClampNonNegative(metrics.verticalInset), + (std::max)( + layout.bounds.width - ClampNonNegative(metrics.horizontalInset) * 2.0f, + 0.0f), + (std::max)( + layout.bounds.height - ClampNonNegative(metrics.verticalInset) * 2.0f, + 0.0f)); + + layout.buttonRects.reserve(items.size()); + float cursorX = layout.contentRect.x; + for (const UIEditorMenuBarItem& item : items) { + const float width = ResolveUIEditorMenuBarDesiredButtonWidth(item, metrics); + layout.buttonRects.emplace_back( + cursorX, + layout.contentRect.y, + width, + layout.contentRect.height); + cursorX += width + ClampNonNegative(metrics.buttonGap); + } + + return layout; +} + +UIEditorMenuBarHitTarget HitTestUIEditorMenuBar( + const UIEditorMenuBarLayout& layout, + const UIPoint& point) { + UIEditorMenuBarHitTarget target = {}; + if (!IsPointInsideRect(layout.bounds, point)) { + return target; + } + + for (std::size_t index = 0; index < layout.buttonRects.size(); ++index) { + if (IsPointInsideRect(layout.buttonRects[index], point)) { + target.kind = UIEditorMenuBarHitTargetKind::Button; + target.index = index; + return target; + } + } + + target.kind = UIEditorMenuBarHitTargetKind::BarBackground; + return target; +} + +void AppendUIEditorMenuBarBackground( + UIDrawList& drawList, + const UIEditorMenuBarLayout& layout, + const std::vector& items, + const UIEditorMenuBarState& state, + const UIEditorMenuBarPalette& palette, + const UIEditorMenuBarMetrics& metrics) { + drawList.AddFilledRect(layout.bounds, palette.barColor, metrics.barCornerRounding); + drawList.AddRectOutline( + layout.bounds, + state.focused ? palette.focusedBorderColor : palette.borderColor, + state.focused ? metrics.focusedBorderThickness : metrics.baseBorderThickness, + metrics.barCornerRounding); + + for (std::size_t index = 0; index < layout.buttonRects.size() && index < items.size(); ++index) { + const bool open = state.openIndex == index; + const bool hovered = state.hoveredIndex == index; + drawList.AddFilledRect( + layout.buttonRects[index], + ResolveButtonFillColor(open, hovered, palette), + metrics.buttonCornerRounding); + drawList.AddRectOutline( + layout.buttonRects[index], + ResolveButtonBorderColor(open, state.focused && open, palette), + ResolveButtonBorderThickness(open, state.focused && open, metrics), + metrics.buttonCornerRounding); + } +} + +void AppendUIEditorMenuBarForeground( + UIDrawList& drawList, + const UIEditorMenuBarLayout& layout, + const std::vector& items, + const UIEditorMenuBarState&, + const UIEditorMenuBarPalette& palette, + const UIEditorMenuBarMetrics& metrics) { + for (std::size_t index = 0; index < layout.buttonRects.size() && index < items.size(); ++index) { + const UIRect& rect = layout.buttonRects[index]; + const float textLeft = rect.x + ClampNonNegative(metrics.buttonPaddingX); + const float textRight = rect.x + rect.width - ClampNonNegative(metrics.buttonPaddingX); + if (textRight <= textLeft) { + continue; + } + + drawList.PushClipRect(UIRect(textLeft, rect.y, textRight - textLeft, rect.height), true); + drawList.AddText( + UIPoint(textLeft, ResolveLabelTop(rect, metrics)), + items[index].label, + items[index].enabled ? palette.textPrimary : palette.textDisabled, + kMenuBarFontSize); + drawList.PopClipRect(); + } +} + +void AppendUIEditorMenuBar( + UIDrawList& drawList, + const UIRect& bounds, + const std::vector& items, + const UIEditorMenuBarState& state, + const UIEditorMenuBarPalette& palette, + const UIEditorMenuBarMetrics& metrics) { + const UIEditorMenuBarLayout layout = BuildUIEditorMenuBarLayout(bounds, items, metrics); + AppendUIEditorMenuBarBackground(drawList, layout, items, state, palette, metrics); + AppendUIEditorMenuBarForeground(drawList, layout, items, state, palette, metrics); +} + +} // namespace XCEngine::UI::Editor::Widgets diff --git a/new_editor/src/Widgets/UIEditorMenuPopup.cpp b/new_editor/src/Widgets/UIEditorMenuPopup.cpp new file mode 100644 index 00000000..76646169 --- /dev/null +++ b/new_editor/src/Widgets/UIEditorMenuPopup.cpp @@ -0,0 +1,289 @@ +#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::Editor::UIEditorMenuItemKind; + +constexpr float kPopupFontSize = 13.0f; +constexpr float kGlyphFontSize = 12.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; +} + +float ResolveEstimatedWidth(float explicitWidth, std::string_view text, float glyphWidth) { + if (explicitWidth > 0.0f) { + return explicitWidth; + } + + return static_cast(text.size()) * glyphWidth; +} + +bool IsInteractiveItem(const UIEditorMenuPopupItem& item) { + return item.kind != UIEditorMenuItemKind::Separator; +} + +float ResolveRowTextTop(const UIRect& rect, const UIEditorMenuPopupMetrics& metrics) { + return rect.y + (std::max)(0.0f, (rect.height - kPopupFontSize) * 0.5f) + metrics.labelInsetY; +} + +float ResolveGlyphTop(const UIRect& rect) { + return rect.y + (std::max)(0.0f, (rect.height - kGlyphFontSize) * 0.5f) - 0.5f; +} + +bool IsHighlighted(const UIEditorMenuPopupState& state, std::size_t index) { + return state.hoveredIndex == index || state.submenuOpenIndex == index; +} + +} // namespace + +float ResolveUIEditorMenuPopupDesiredWidth( + const std::vector& items, + const UIEditorMenuPopupMetrics& metrics) { + float widestRow = 0.0f; + for (const UIEditorMenuPopupItem& item : items) { + if (item.kind == UIEditorMenuItemKind::Separator) { + continue; + } + + const float labelWidth = + ResolveEstimatedWidth(item.desiredLabelWidth, item.label, metrics.estimatedGlyphWidth); + const float shortcutWidth = + item.shortcutText.empty() + ? 0.0f + : ResolveEstimatedWidth( + item.desiredShortcutWidth, + item.shortcutText, + metrics.estimatedGlyphWidth); + const float submenuWidth = + item.hasSubmenu ? ClampNonNegative(metrics.submenuIndicatorWidth) : 0.0f; + const float rowWidth = + ClampNonNegative(metrics.labelInsetX) + + ClampNonNegative(metrics.checkColumnWidth) + + labelWidth + + (shortcutWidth > 0.0f ? ClampNonNegative(metrics.shortcutGap) + shortcutWidth : 0.0f) + + submenuWidth + + ClampNonNegative(metrics.shortcutInsetRight); + widestRow = (std::max)(widestRow, rowWidth); + } + + return widestRow + ClampNonNegative(metrics.contentPaddingX) * 2.0f; +} + +float MeasureUIEditorMenuPopupHeight( + const std::vector& items, + const UIEditorMenuPopupMetrics& metrics) { + float height = ClampNonNegative(metrics.contentPaddingY) * 2.0f; + for (const UIEditorMenuPopupItem& item : items) { + height += item.kind == UIEditorMenuItemKind::Separator + ? ClampNonNegative(metrics.separatorHeight) + : ClampNonNegative(metrics.itemHeight); + } + return height; +} + +UIEditorMenuPopupLayout BuildUIEditorMenuPopupLayout( + const UIRect& popupRect, + const std::vector& items, + const UIEditorMenuPopupMetrics& metrics) { + UIEditorMenuPopupLayout layout = {}; + layout.popupRect = UIRect( + popupRect.x, + popupRect.y, + ClampNonNegative(popupRect.width), + ClampNonNegative(popupRect.height)); + layout.contentRect = UIRect( + layout.popupRect.x + ClampNonNegative(metrics.contentPaddingX), + layout.popupRect.y + ClampNonNegative(metrics.contentPaddingY), + (std::max)( + layout.popupRect.width - ClampNonNegative(metrics.contentPaddingX) * 2.0f, + 0.0f), + (std::max)( + layout.popupRect.height - ClampNonNegative(metrics.contentPaddingY) * 2.0f, + 0.0f)); + + float cursorY = layout.contentRect.y; + layout.itemRects.reserve(items.size()); + for (const UIEditorMenuPopupItem& item : items) { + const float itemHeight = item.kind == UIEditorMenuItemKind::Separator + ? ClampNonNegative(metrics.separatorHeight) + : ClampNonNegative(metrics.itemHeight); + layout.itemRects.emplace_back( + layout.contentRect.x, + cursorY, + layout.contentRect.width, + itemHeight); + cursorY += itemHeight; + } + + return layout; +} + +UIEditorMenuPopupHitTarget HitTestUIEditorMenuPopup( + const UIEditorMenuPopupLayout& layout, + const std::vector& items, + const UIPoint& point) { + UIEditorMenuPopupHitTarget target = {}; + if (!IsPointInsideRect(layout.popupRect, point)) { + return target; + } + + for (std::size_t index = 0; index < layout.itemRects.size() && index < items.size(); ++index) { + if (IsInteractiveItem(items[index]) && IsPointInsideRect(layout.itemRects[index], point)) { + target.kind = UIEditorMenuPopupHitTargetKind::Item; + target.index = index; + return target; + } + } + + target.kind = UIEditorMenuPopupHitTargetKind::PopupSurface; + return target; +} + +void AppendUIEditorMenuPopupBackground( + UIDrawList& drawList, + const UIEditorMenuPopupLayout& layout, + const std::vector& items, + const UIEditorMenuPopupState& state, + const UIEditorMenuPopupPalette& palette, + const UIEditorMenuPopupMetrics& metrics) { + drawList.AddFilledRect(layout.popupRect, palette.popupColor, metrics.popupCornerRounding); + drawList.AddRectOutline( + layout.popupRect, + palette.borderColor, + metrics.borderThickness, + metrics.popupCornerRounding); + + for (std::size_t index = 0; index < layout.itemRects.size() && index < items.size(); ++index) { + const UIEditorMenuPopupItem& item = items[index]; + const UIRect& rect = layout.itemRects[index]; + if (item.kind == UIEditorMenuItemKind::Separator) { + const float lineY = rect.y + rect.height * 0.5f; + drawList.AddFilledRect( + UIRect(rect.x + 8.0f, lineY, (std::max)(rect.width - 16.0f, 0.0f), metrics.separatorThickness), + palette.separatorColor); + continue; + } + + if (IsHighlighted(state, index)) { + drawList.AddFilledRect( + rect, + state.submenuOpenIndex == index ? palette.itemOpenColor : palette.itemHoverColor, + metrics.rowCornerRounding); + } + } +} + +void AppendUIEditorMenuPopupForeground( + UIDrawList& drawList, + const UIEditorMenuPopupLayout& layout, + const std::vector& items, + const UIEditorMenuPopupState&, + const UIEditorMenuPopupPalette& palette, + const UIEditorMenuPopupMetrics& metrics) { + for (std::size_t index = 0; index < layout.itemRects.size() && index < items.size(); ++index) { + const UIEditorMenuPopupItem& item = items[index]; + if (item.kind == UIEditorMenuItemKind::Separator) { + continue; + } + + const UIRect& rect = layout.itemRects[index]; + const UIColor mainColor = item.enabled ? palette.textPrimary : palette.textDisabled; + const UIColor secondaryColor = item.enabled ? palette.textMuted : palette.textDisabled; + + const float checkLeft = rect.x + 6.0f; + if (item.checked) { + drawList.AddText( + UIPoint(checkLeft, ResolveGlyphTop(rect)), + "*", + palette.glyphColor, + kGlyphFontSize); + } + + const float labelLeft = + rect.x + ClampNonNegative(metrics.labelInsetX) + ClampNonNegative(metrics.checkColumnWidth); + const float labelRight = + rect.x + rect.width - ClampNonNegative(metrics.shortcutInsetRight); + float labelClipWidth = (std::max)(labelRight - labelLeft, 0.0f); + if (!item.shortcutText.empty()) { + const float shortcutWidth = + ResolveEstimatedWidth( + item.desiredShortcutWidth, + item.shortcutText, + metrics.estimatedGlyphWidth); + labelClipWidth = (std::max)( + labelClipWidth - shortcutWidth - ClampNonNegative(metrics.shortcutGap), + 0.0f); + } + if (item.hasSubmenu) { + labelClipWidth = (std::max)( + labelClipWidth - ClampNonNegative(metrics.submenuIndicatorWidth), + 0.0f); + } + + drawList.PushClipRect(UIRect(labelLeft, rect.y, labelClipWidth, rect.height), true); + drawList.AddText( + UIPoint(labelLeft, ResolveRowTextTop(rect, metrics)), + item.label, + mainColor, + kPopupFontSize); + drawList.PopClipRect(); + + if (!item.shortcutText.empty()) { + const float shortcutWidth = + ResolveEstimatedWidth( + item.desiredShortcutWidth, + item.shortcutText, + metrics.estimatedGlyphWidth); + const float shortcutLeft = rect.x + rect.width - + ClampNonNegative(metrics.shortcutInsetRight) - + shortcutWidth - + (item.hasSubmenu ? ClampNonNegative(metrics.submenuIndicatorWidth) : 0.0f); + drawList.AddText( + UIPoint(shortcutLeft, ResolveRowTextTop(rect, metrics)), + item.shortcutText, + secondaryColor, + kPopupFontSize); + } + + if (item.hasSubmenu) { + drawList.AddText( + UIPoint( + rect.x + rect.width - ClampNonNegative(metrics.shortcutInsetRight), + ResolveGlyphTop(rect)), + ">", + palette.glyphColor, + kGlyphFontSize); + } + } +} + +void AppendUIEditorMenuPopup( + UIDrawList& drawList, + const UIRect& popupRect, + const std::vector& items, + const UIEditorMenuPopupState& state, + const UIEditorMenuPopupPalette& palette, + const UIEditorMenuPopupMetrics& metrics) { + const UIEditorMenuPopupLayout layout = + BuildUIEditorMenuPopupLayout(popupRect, items, metrics); + AppendUIEditorMenuPopupBackground(drawList, layout, items, state, palette, metrics); + AppendUIEditorMenuPopupForeground(drawList, layout, items, state, palette, metrics); +} + +} // namespace XCEngine::UI::Editor::Widgets diff --git a/new_editor/src/Widgets/UIEditorStatusBar.cpp b/new_editor/src/Widgets/UIEditorStatusBar.cpp new file mode 100644 index 00000000..cac9174b --- /dev/null +++ b/new_editor/src/Widgets/UIEditorStatusBar.cpp @@ -0,0 +1,279 @@ +#include + +#include + +namespace XCEngine::UI::Editor::Widgets { + +namespace { + +using ::XCEngine::UI::UIColor; +using ::XCEngine::UI::UIDrawList; +using ::XCEngine::UI::UIPoint; +using ::XCEngine::UI::UIRect; + +bool ContainsPoint(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 HasArea(const UIRect& rect) { + return rect.width > 0.0f && rect.height > 0.0f; +} + +float ClampNonNegative(float value) { + return (std::max)(value, 0.0f); +} + +UIColor ResolveSegmentFillColor( + bool hovered, + bool active, + const UIEditorStatusBarPalette& palette) { + if (active) { + return palette.segmentActiveColor; + } + + if (hovered) { + return palette.segmentHoveredColor; + } + + return palette.segmentColor; +} + +} // namespace + +float ResolveUIEditorStatusBarDesiredSegmentWidth( + const UIEditorStatusBarSegment& segment, + const UIEditorStatusBarMetrics& metrics) { + if (segment.desiredWidth > 0.0f) { + return segment.desiredWidth; + } + + return segment.label.empty() + ? metrics.segmentPaddingX * 2.0f + : metrics.segmentPaddingX * 2.0f + + static_cast(segment.label.size()) * metrics.estimatedGlyphWidth; +} + +UIEditorStatusBarLayout BuildUIEditorStatusBarLayout( + const UIRect& bounds, + const std::vector& segments, + const UIEditorStatusBarMetrics& metrics) { + UIEditorStatusBarLayout layout = {}; + layout.bounds = UIRect( + bounds.x, + bounds.y, + ClampNonNegative(bounds.width), + ClampNonNegative(bounds.height)); + layout.segmentRects.resize(segments.size(), UIRect{}); + layout.separatorRects.resize(segments.size(), UIRect{}); + + const float contentTop = layout.bounds.y; + const float contentHeight = layout.bounds.height; + const float leftStart = layout.bounds.x + metrics.outerPaddingX; + const float rightLimit = layout.bounds.x + layout.bounds.width - metrics.outerPaddingX; + + float leadingCursor = leftStart; + float leadingRight = leftStart; + for (std::size_t index = 0u; index < segments.size(); ++index) { + const auto& segment = segments[index]; + if (segment.slot != UIEditorStatusBarSlot::Leading) { + continue; + } + + const float segmentWidth = ResolveUIEditorStatusBarDesiredSegmentWidth(segment, metrics); + layout.segmentRects[index] = UIRect( + leadingCursor, + contentTop, + segmentWidth, + contentHeight); + leadingCursor += segmentWidth; + leadingRight = leadingCursor; + + if (segment.showSeparator) { + layout.separatorRects[index] = UIRect( + leadingCursor, + contentTop + metrics.separatorInsetY, + metrics.separatorWidth, + (std::max)(contentHeight - metrics.separatorInsetY * 2.0f, 0.0f)); + leadingCursor += metrics.separatorWidth; + } + + leadingCursor += metrics.segmentGap; + leadingRight = (std::max)(leadingRight, leadingCursor - metrics.segmentGap); + } + + float trailingCursor = rightLimit; + float trailingLeft = rightLimit; + for (std::size_t reverseIndex = segments.size(); reverseIndex > 0u; --reverseIndex) { + const std::size_t index = reverseIndex - 1u; + const auto& segment = segments[index]; + if (segment.slot != UIEditorStatusBarSlot::Trailing) { + continue; + } + + const float segmentWidth = ResolveUIEditorStatusBarDesiredSegmentWidth(segment, metrics); + const bool hasSeparator = segment.showSeparator; + + if (hasSeparator) { + trailingCursor -= metrics.separatorWidth; + layout.separatorRects[index] = UIRect( + trailingCursor, + contentTop + metrics.separatorInsetY, + metrics.separatorWidth, + (std::max)(contentHeight - metrics.separatorInsetY * 2.0f, 0.0f)); + trailingCursor -= metrics.segmentGap; + } + + trailingCursor -= segmentWidth; + layout.segmentRects[index] = UIRect( + trailingCursor, + contentTop, + segmentWidth, + contentHeight); + trailingCursor -= metrics.segmentGap; + trailingLeft = (std::min)(trailingLeft, layout.segmentRects[index].x); + } + + const float leadingWidth = + leadingRight > leftStart ? leadingRight - leftStart : 0.0f; + layout.leadingSlotRect = UIRect(leftStart, contentTop, leadingWidth, contentHeight); + + const float trailingWidth = + trailingLeft < rightLimit ? rightLimit - trailingLeft : 0.0f; + layout.trailingSlotRect = UIRect(trailingLeft, contentTop, trailingWidth, contentHeight); + + if (HasArea(layout.leadingSlotRect) && + HasArea(layout.trailingSlotRect) && + layout.leadingSlotRect.x + layout.leadingSlotRect.width + metrics.slotGapMin > + layout.trailingSlotRect.x) { + const float overlap = + layout.leadingSlotRect.x + layout.leadingSlotRect.width + metrics.slotGapMin - + layout.trailingSlotRect.x; + layout.trailingSlotRect.x += overlap; + layout.trailingSlotRect.width = (std::max)(layout.trailingSlotRect.width - overlap, 0.0f); + } + + return layout; +} + +UIEditorStatusBarHitTarget HitTestUIEditorStatusBar( + const UIEditorStatusBarLayout& layout, + const UIPoint& point) { + if (!ContainsPoint(layout.bounds, point)) { + return {}; + } + + for (std::size_t index = 0u; index < layout.separatorRects.size(); ++index) { + if (HasArea(layout.separatorRects[index]) && + ContainsPoint(layout.separatorRects[index], point)) { + return { UIEditorStatusBarHitTargetKind::Separator, index }; + } + } + + for (std::size_t index = 0u; index < layout.segmentRects.size(); ++index) { + if (HasArea(layout.segmentRects[index]) && + ContainsPoint(layout.segmentRects[index], point)) { + return { UIEditorStatusBarHitTargetKind::Segment, index }; + } + } + + return { UIEditorStatusBarHitTargetKind::Background, UIEditorStatusBarInvalidIndex }; +} + +UIColor ResolveUIEditorStatusBarTextColor( + UIEditorStatusBarTextTone tone, + const UIEditorStatusBarPalette& palette) { + switch (tone) { + case UIEditorStatusBarTextTone::Muted: + return palette.textMuted; + case UIEditorStatusBarTextTone::Accent: + return palette.textAccent; + case UIEditorStatusBarTextTone::Primary: + default: + return palette.textPrimary; + } +} + +void AppendUIEditorStatusBarBackground( + UIDrawList& drawList, + const UIEditorStatusBarLayout& layout, + const std::vector& segments, + const UIEditorStatusBarState& state, + const UIEditorStatusBarPalette& palette, + const UIEditorStatusBarMetrics& metrics) { + drawList.AddFilledRect(layout.bounds, palette.surfaceColor, metrics.cornerRounding); + drawList.AddRectOutline( + layout.bounds, + state.focused ? palette.focusedBorderColor : palette.borderColor, + state.focused ? metrics.focusedBorderThickness : metrics.borderThickness, + metrics.cornerRounding); + + for (std::size_t index = 0u; index < segments.size(); ++index) { + if (!HasArea(layout.segmentRects[index])) { + continue; + } + + const bool hovered = state.hoveredIndex == index; + const bool active = state.activeIndex == index; + if (!hovered && !active) { + continue; + } + + drawList.AddFilledRect( + layout.segmentRects[index], + ResolveSegmentFillColor(hovered, active, palette), + 6.0f); + drawList.AddRectOutline( + layout.segmentRects[index], + palette.segmentBorderColor, + 1.0f, + 6.0f); + } + + for (const UIRect& separatorRect : layout.separatorRects) { + if (!HasArea(separatorRect)) { + continue; + } + + drawList.AddFilledRect(separatorRect, palette.separatorColor); + } +} + +void AppendUIEditorStatusBarForeground( + UIDrawList& drawList, + const UIEditorStatusBarLayout& layout, + const std::vector& segments, + const UIEditorStatusBarState&, + const UIEditorStatusBarPalette& palette, + const UIEditorStatusBarMetrics& metrics) { + for (std::size_t index = 0u; index < segments.size(); ++index) { + if (!HasArea(layout.segmentRects[index]) || segments[index].label.empty()) { + continue; + } + + drawList.AddText( + UIPoint( + layout.segmentRects[index].x + metrics.segmentPaddingX, + layout.segmentRects[index].y + metrics.segmentPaddingY), + segments[index].label, + ResolveUIEditorStatusBarTextColor(segments[index].tone, palette), + 12.0f); + } +} + +void AppendUIEditorStatusBar( + UIDrawList& drawList, + const UIRect& bounds, + const std::vector& segments, + const UIEditorStatusBarState& state, + const UIEditorStatusBarPalette& palette, + const UIEditorStatusBarMetrics& metrics) { + const UIEditorStatusBarLayout layout = + BuildUIEditorStatusBarLayout(bounds, segments, metrics); + AppendUIEditorStatusBarBackground(drawList, layout, segments, state, palette, metrics); + AppendUIEditorStatusBarForeground(drawList, layout, segments, 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 724e0ed6..f9050de4 100644 --- a/tests/UI/Editor/integration/CMakeLists.txt +++ b/tests/UI/Editor/integration/CMakeLists.txt @@ -4,13 +4,27 @@ add_subdirectory(shared) add_subdirectory(shell) add_subdirectory(state) +set(EDITOR_UI_INTEGRATION_TARGETS + 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 +) + +if(TARGET editor_ui_status_bar_basic_validation) + list(APPEND EDITOR_UI_INTEGRATION_TARGETS + editor_ui_status_bar_basic_validation) +endif() + +if(TARGET editor_ui_context_menu_basic_validation) + list(APPEND EDITOR_UI_INTEGRATION_TARGETS + editor_ui_context_menu_basic_validation) +endif() + 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 + ${EDITOR_UI_INTEGRATION_TARGETS} ) diff --git a/tests/UI/Editor/integration/README.md b/tests/UI/Editor/integration/README.md index 8046ad6f..6e1d0a66 100644 --- a/tests/UI/Editor/integration/README.md +++ b/tests/UI/Editor/integration/README.md @@ -14,7 +14,9 @@ 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/context_menu_basic/`: context menu root/submenu/dismiss/dispatch only - `shell/panel_frame_basic/`: panel frame layout/state/hit-test only +- `shell/status_bar_basic/`: status bar slot/segment/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 @@ -32,11 +34,21 @@ Scenarios: Executable: `XCUIEditorMenuBarBasicValidation.exe` Scope: menu bar open/close, hover, dismiss, menu command dispatch only +- `editor.shell.context_menu_basic` + Build target: `editor_ui_context_menu_basic_validation` + Executable: `XCUIEditorContextMenuBasicValidation.exe` + Scope: context menu root anchor, submenu hover, outside/Esc dismiss, 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.status_bar_basic` + Build target: `editor_ui_status_bar_basic_validation` + Executable: `XCUIEditorStatusBarBasicValidation.exe` + Scope: status bar slot layout, hover/active segment hit target, separator layout only + - `editor.shell.tab_strip_basic` Build target: `editor_ui_tab_strip_basic_validation` Executable: `XCUIEditorTabStripBasicValidation.exe` @@ -63,6 +75,11 @@ Run: cmake --build build --config Debug --target editor_ui_integration_tests ``` +Auto capture: + +- Set `XCUI_AUTO_CAPTURE_ON_STARTUP=1` before launching a validation executable to force a first-frame screenshot into that scenario's `captures/` directory. +- Manual validation still uses `F12`; startup auto capture is only for deterministic self-check / automation. + Selected controls: - `shell/workspace_shell_compose/` @@ -71,9 +88,15 @@ 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/context_menu_basic/` + Right click inside `Context Target`, hover `Workspace Tools`, click actions, 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/status_bar_basic/` + Move the mouse across leading/trailing segments, click interactive segments, toggle focus/active, 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`. diff --git a/tests/UI/Editor/integration/shell/CMakeLists.txt b/tests/UI/Editor/integration/shell/CMakeLists.txt index 1a5c2e8b..be8a3e39 100644 --- a/tests/UI/Editor/integration/shell/CMakeLists.txt +++ b/tests/UI/Editor/integration/shell/CMakeLists.txt @@ -4,3 +4,9 @@ add_subdirectory(tab_strip_basic) if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/menu_bar_basic/CMakeLists.txt") add_subdirectory(menu_bar_basic) endif() +if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/status_bar_basic/CMakeLists.txt") + add_subdirectory(status_bar_basic) +endif() +if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/context_menu_basic/CMakeLists.txt") + add_subdirectory(context_menu_basic) +endif() diff --git a/tests/UI/Editor/integration/shell/context_menu_basic/CMakeLists.txt b/tests/UI/Editor/integration/shell/context_menu_basic/CMakeLists.txt new file mode 100644 index 00000000..8810654c --- /dev/null +++ b/tests/UI/Editor/integration/shell/context_menu_basic/CMakeLists.txt @@ -0,0 +1,31 @@ +add_executable(editor_ui_context_menu_basic_validation WIN32 + main.cpp +) + +target_include_directories(editor_ui_context_menu_basic_validation PRIVATE + ${CMAKE_SOURCE_DIR}/engine/include + ${CMAKE_SOURCE_DIR}/new_editor/include + ${CMAKE_SOURCE_DIR}/new_editor/app + ${CMAKE_SOURCE_DIR}/new_editor/src +) + +target_compile_definitions(editor_ui_context_menu_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_context_menu_basic_validation PRIVATE /utf-8 /FS) + set_property(TARGET editor_ui_context_menu_basic_validation PROPERTY + MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>DLL") +endif() + +target_link_libraries(editor_ui_context_menu_basic_validation PRIVATE + XCUIEditorLib + XCUIEditorHost +) + +set_target_properties(editor_ui_context_menu_basic_validation PROPERTIES + OUTPUT_NAME "XCUIEditorContextMenuBasicValidation" +) diff --git a/tests/UI/Editor/integration/shell/context_menu_basic/main.cpp b/tests/UI/Editor/integration/shell/context_menu_basic/main.cpp new file mode 100644 index 00000000..95d26690 --- /dev/null +++ b/tests/UI/Editor/integration/shell/context_menu_basic/main.cpp @@ -0,0 +1,1249 @@ +#ifndef NOMINMAX +#define NOMINMAX +#endif + +#include +#include +#include +#include +#include +#include +#include +#include "Host/AutoScreenshot.h" +#include "Host/NativeRenderer.h" + +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef XCENGINE_EDITOR_UI_TESTS_REPO_ROOT +#define XCENGINE_EDITOR_UI_TESTS_REPO_ROOT "." +#endif + +namespace { + +using XCEngine::Input::KeyCode; +using XCEngine::UI::Editor::BuildDefaultUIEditorWorkspaceController; +using XCEngine::UI::Editor::BuildUIEditorResolvedMenuModel; +using XCEngine::UI::Editor::BuildUIEditorWorkspacePanel; +using XCEngine::UI::Editor::BuildUIEditorWorkspaceSplit; +using XCEngine::UI::Editor::BuildUIEditorWorkspaceTabStack; +using XCEngine::UI::Editor::CollectUIEditorWorkspaceVisiblePanels; +using XCEngine::UI::Editor::FindUIEditorPanelSessionState; +using XCEngine::UI::Editor::GetUIEditorCommandDispatchStatusName; +using XCEngine::UI::Editor::GetUIEditorWorkspaceCommandStatusName; +using XCEngine::UI::Editor::UIEditorCommandDispatchResult; +using XCEngine::UI::Editor::UIEditorCommandDispatcher; +using XCEngine::UI::Editor::UIEditorCommandPanelSource; +using XCEngine::UI::Editor::UIEditorCommandRegistry; +using XCEngine::UI::Editor::UIEditorMenuCheckedStateSource; +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::UIEditorWorkspaceController; +using XCEngine::UI::Editor::UIEditorWorkspaceModel; +using XCEngine::UI::Editor::UIEditorWorkspaceSession; +using XCEngine::UI::Editor::UIEditorWorkspaceSplitAxis; +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::Widgets::AppendUIEditorMenuPopupBackground; +using XCEngine::UI::Editor::Widgets::AppendUIEditorMenuPopupForeground; +using XCEngine::UI::Editor::Widgets::BuildUIEditorMenuPopupLayout; +using XCEngine::UI::Editor::Widgets::MeasureUIEditorMenuPopupHeight; +using XCEngine::UI::Editor::Widgets::ResolveUIEditorMenuPopupDesiredWidth; +using UIEditorMenuPopupWidgetItem = XCEngine::UI::Editor::Widgets::UIEditorMenuPopupItem; +using UIEditorMenuPopupWidgetState = XCEngine::UI::Editor::Widgets::UIEditorMenuPopupState; +using XCEngine::UI::Editor::Widgets::UIEditorMenuPopupInvalidIndex; +using XCEngine::UI::Editor::Host::AutoScreenshotController; +using XCEngine::UI::Editor::Host::NativeRenderer; + +constexpr const wchar_t* kWindowClassName = L"XCUIEditorContextMenuBasicValidation"; +constexpr const wchar_t* kWindowTitle = L"XCUI Editor | Context Menu Basic"; + +constexpr UIColor kWindowBg(0.14f, 0.14f, 0.14f, 1.0f); +constexpr UIColor kCardBg(0.19f, 0.19f, 0.19f, 1.0f); +constexpr UIColor kCardBorder(0.29f, 0.29f, 0.29f, 1.0f); +constexpr UIColor kTextPrimary(0.94f, 0.94f, 0.94f, 1.0f); +constexpr UIColor kTextMuted(0.70f, 0.70f, 0.70f, 1.0f); +constexpr UIColor kTextWeak(0.56f, 0.56f, 0.56f, 1.0f); +constexpr UIColor kSuccess(0.43f, 0.71f, 0.47f, 1.0f); +constexpr UIColor kWarning(0.78f, 0.60f, 0.30f, 1.0f); +constexpr UIColor kDanger(0.78f, 0.34f, 0.34f, 1.0f); +constexpr UIColor kTargetBg(0.17f, 0.17f, 0.17f, 1.0f); +constexpr UIColor kTargetHoverBg(0.21f, 0.21f, 0.21f, 1.0f); +constexpr UIColor kIndicatorBg(0.23f, 0.23f, 0.23f, 1.0f); +constexpr UIElementId kContextTargetPathRoot = 3000u; +constexpr UIElementId kPopupPathRoot = 4000u; + +struct MenuPopupLayoutInfo { + std::string popupId = {}; + std::string menuId = {}; + UIRect rect = {}; + UIInputPath surfacePath = {}; +}; + +struct MenuItemLayoutInfo { + std::string popupId = {}; + std::string itemId = {}; + std::string label = {}; + std::string commandId = {}; + std::string childPopupId = {}; + UIRect rect = {}; + UIInputPath path = {}; + UIEditorMenuItemKind kind = UIEditorMenuItemKind::Command; + bool enabled = false; + bool hasSubmenu = false; +}; + +std::filesystem::path ResolveRepoRootPath(); +std::uint64_t HashText(std::string_view text); +bool ContainsPoint(const UIRect& rect, float x, float y); +UIEditorPanelRegistry BuildPanelRegistry(); +UIEditorWorkspaceModel BuildWorkspace(); +UIEditorCommandRegistry BuildCommandRegistry(); +UIShortcutBinding MakeBinding(std::string commandId, KeyCode keyCode); +UIEditorShortcutManager BuildShortcutManager(); +UIEditorMenuModel BuildMenuModel(); +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 BuildTargetPath(); +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::vector BuildPopupWidgetItems(const std::vector& items); +std::size_t FindPopupWidgetIndex(const std::vector& items, std::string_view itemId); +std::string FormatAnchorPoint(const UIPoint& point, bool hasAnchor); + +class ScenarioApp { +public: + int Run(HINSTANCE hInstance, int nCmdShow); + +private: + static LRESULT CALLBACK WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam); + + bool Initialize(HINSTANCE hInstance, int nCmdShow); + void Shutdown(); + void ResetScenario(); + void OnResize(UINT width, UINT height); + void UpdateRects(); + void SetDispatchResult(std::string actionName, const UIEditorCommandDispatchResult& result); + void SetCustomResult(std::string actionName, std::string statusLabel, std::string message); + const MenuPopupLayoutInfo* HitTestMenuPopup(float x, float y) const; + const MenuItemLayoutInfo* HitTestMenuItem(float x, float y) const; + UIPopupOverlayEntry BuildRootPopupEntry(const UIPoint& anchorPoint) const; + UIPopupOverlayEntry BuildSubmenuPopupEntry(const MenuItemLayoutInfo& item) const; + void ClearHoverWhenMenuClosed(); + void HandleMouseMove(float x, float y); + void HandleLeftClick(float x, float y); + void HandleRightClick(float x, float y); + void HandleKeyDown(UINT keyCode); + void DrawPopup(UIDrawList& drawList, std::string_view menuId, std::string_view popupId, const std::vector& items, const UIPopupOverlayEntry& popupEntry); + void DrawOpenPopups(UIDrawList& drawList, const UIEditorResolvedMenuModel& resolvedModel); + void BuildDrawData(UIDrawData& drawData, float width, float height); + void RenderFrame(); + + HWND m_hwnd = nullptr; + HINSTANCE m_hInstance = nullptr; + ATOM m_windowClassAtom = 0; + NativeRenderer m_renderer = {}; + AutoScreenshotController m_autoScreenshot = {}; + UIEditorWorkspaceController m_controller = {}; + UIEditorCommandDispatcher m_commandDispatcher = {}; + UIEditorShortcutManager m_shortcutManager = {}; + UIEditorMenuModel m_menuModel = {}; + UIEditorMenuSession m_menuSession = {}; + std::vector m_menuPopups = {}; + std::vector m_menuItems = {}; + UIRect m_headerRect = {}; + UIRect m_targetRect = {}; + UIRect m_stateRect = {}; + UIRect m_footerRect = {}; + bool m_targetHovered = false; + UIPoint m_contextAnchorPoint = {}; + bool m_hasContextAnchor = false; + std::string m_hoveredPopupId = {}; + std::string m_hoveredItemId = {}; + std::string m_lastActionName = {}; + std::string m_lastStatusLabel = {}; + std::string m_lastMessage = {}; + UIColor m_lastStatusColor = kTextMuted; +}; + +} // namespace + +namespace { + +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(); +} + +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; +} + +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; +} + +UIEditorPanelRegistry BuildPanelRegistry() { + UIEditorPanelRegistry registry = {}; + registry.panels = { + { "doc-a", "Document A", {}, true, true, true }, + { "doc-b", "Document B", {}, true, true, true }, + { "details", "Details", {}, true, true, true } + }; + return registry; +} + +UIEditorWorkspaceModel BuildWorkspace() { + UIEditorWorkspaceModel workspace = {}; + workspace.root = BuildUIEditorWorkspaceSplit( + "root-split", + UIEditorWorkspaceSplitAxis::Horizontal, + 0.66f, + BuildUIEditorWorkspaceTabStack( + "document-tabs", + { + BuildUIEditorWorkspacePanel("doc-a-node", "doc-a", "Document A", true), + BuildUIEditorWorkspacePanel("doc-b-node", "doc-b", "Document B", true) + }, + 0u), + BuildUIEditorWorkspacePanel("details-node", "details", "Details", true)); + workspace.activePanelId = "doc-a"; + return workspace; +} + +UIEditorCommandRegistry BuildCommandRegistry() { + UIEditorCommandRegistry registry = {}; + registry.commands = { + { + "workspace.show_details", + "Show Details", + { UIEditorWorkspaceCommandKind::ShowPanel, UIEditorCommandPanelSource::FixedPanelId, "details" } + }, + { + "workspace.hide_active", + "Hide Active", + { UIEditorWorkspaceCommandKind::HidePanel, UIEditorCommandPanelSource::ActivePanel, {} } + }, + { + "workspace.activate_details", + "Activate Details", + { UIEditorWorkspaceCommandKind::ActivatePanel, UIEditorCommandPanelSource::FixedPanelId, "details" } + }, + { + "workspace.reset", + "Reset Workspace", + { UIEditorWorkspaceCommandKind::ResetWorkspace, UIEditorCommandPanelSource::None, {} } + } + }; + return registry; +} + +UIShortcutBinding MakeBinding(std::string commandId, KeyCode keyCode) { + UIShortcutBinding binding = {}; + binding.commandId = std::move(commandId); + binding.scope = UIShortcutScope::Global; + binding.triggerEventType = UIInputEventType::KeyDown; + binding.chord.keyCode = static_cast(keyCode); + binding.chord.modifiers.control = true; + return binding; +} + +UIEditorShortcutManager BuildShortcutManager() { + UIEditorShortcutManager manager(BuildCommandRegistry()); + manager.RegisterBinding(MakeBinding("workspace.show_details", KeyCode::O)); + manager.RegisterBinding(MakeBinding("workspace.hide_active", KeyCode::H)); + manager.RegisterBinding(MakeBinding("workspace.activate_details", KeyCode::P)); + manager.RegisterBinding(MakeBinding("workspace.reset", KeyCode::R)); + return manager; +} + +UIEditorMenuModel BuildMenuModel() { + UIEditorMenuModel model = {}; + model.menus = { + UIEditorMenuDescriptor{ + "scene-context", + "Scene Context", + { + UIEditorMenuItemDescriptor{ + UIEditorMenuItemKind::Command, + "context-show-details", + {}, + "workspace.show_details", + { UIEditorMenuCheckedStateSource::PanelVisible, "details" }, + {} + }, + UIEditorMenuItemDescriptor{ + UIEditorMenuItemKind::Submenu, + "context-workspace-tools", + "Workspace Tools", + {}, + {}, + { + UIEditorMenuItemDescriptor{ + UIEditorMenuItemKind::Command, + "context-activate-details", + {}, + "workspace.activate_details", + { UIEditorMenuCheckedStateSource::PanelActive, "details" }, + {} + }, + UIEditorMenuItemDescriptor{ + UIEditorMenuItemKind::Command, + "context-reset", + {}, + "workspace.reset", + {}, + {} + } + } + }, + UIEditorMenuItemDescriptor{ + UIEditorMenuItemKind::Separator, + "context-separator-1", + {}, + {}, + {}, + {} + }, + UIEditorMenuItemDescriptor{ + UIEditorMenuItemKind::Command, + "context-hide-active", + {}, + "workspace.hide_active", + {}, + {} + } + } + } + }; + return model; +} + +std::string JoinVisiblePanelIds( + const UIEditorWorkspaceModel& workspace, + const UIEditorWorkspaceSession& session) { + const auto visiblePanels = CollectUIEditorWorkspaceVisiblePanels(workspace, session); + if (visiblePanels.empty()) { + return "(none)"; + } + + std::ostringstream stream = {}; + for (std::size_t index = 0; index < visiblePanels.size(); ++index) { + if (index > 0u) { + stream << ", "; + } + stream << visiblePanels[index].panelId; + } + return stream.str(); +} + +void DrawCard( + UIDrawList& drawList, + const UIRect& rect, + std::string_view title, + std::string_view subtitle) { + drawList.AddFilledRect(rect, kCardBg, 12.0f); + drawList.AddRectOutline(rect, kCardBorder, 1.0f, 12.0f); + drawList.AddText(UIPoint(rect.x + 18.0f, rect.y + 16.0f), std::string(title), kTextPrimary, 17.0f); + if (!subtitle.empty()) { + drawList.AddText(UIPoint(rect.x + 18.0f, rect.y + 42.0f), std::string(subtitle), kTextMuted, 12.0f); + } +} + +UIColor ResolveResultColor(std::string_view statusLabel) { + if (statusLabel == "Changed" || statusLabel == "Dispatched" || statusLabel == "Applied") { + return kSuccess; + } + if (statusLabel == "NoOp" || statusLabel == "Dismissed" || statusLabel == "Disabled" || statusLabel == "Ready") { + return kWarning; + } + if (statusLabel == "Rejected") { + return kDanger; + } + return kTextMuted; +} + +std::string BuildRootPopupId(std::string_view menuId) { + return "context.root." + std::string(menuId); +} + +std::string BuildSubmenuPopupId(std::string_view itemId) { + return "context.submenu." + std::string(itemId); +} + +UIInputPath BuildTargetPath() { + return UIInputPath{ kContextTargetPathRoot, 1u }; +} + +UIInputPath BuildPopupSurfacePath(std::string_view popupId) { + return UIInputPath{ + kPopupPathRoot, + 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; +} + +std::vector BuildPopupWidgetItems( + const std::vector& items) { + std::vector popupItems = {}; + popupItems.reserve(items.size()); + for (const UIEditorResolvedMenuItem& item : items) { + UIEditorMenuPopupWidgetItem popupItem = {}; + popupItem.itemId = item.itemId; + popupItem.kind = item.kind; + popupItem.label = item.label; + popupItem.shortcutText = item.shortcutText; + popupItem.enabled = item.enabled; + popupItem.checked = item.checked; + popupItem.hasSubmenu = + item.kind == UIEditorMenuItemKind::Submenu && !item.children.empty(); + popupItems.push_back(std::move(popupItem)); + } + return popupItems; +} + +std::size_t FindPopupWidgetIndex( + const std::vector& items, + std::string_view itemId) { + for (std::size_t index = 0; index < items.size(); ++index) { + if (items[index].itemId == itemId) { + return index; + } + } + + return UIEditorMenuPopupInvalidIndex; +} + +std::string FormatAnchorPoint(const UIPoint& point, bool hasAnchor) { + if (!hasAnchor) { + return "(none)"; + } + + std::ostringstream stream = {}; + stream << static_cast(point.x) << ", " << static_cast(point.y); + return stream.str(); +} + +int ScenarioApp::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); +} + +LRESULT CALLBACK ScenarioApp::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_PAINT: + if (app != nullptr) { + PAINTSTRUCT paintStruct = {}; + BeginPaint(hwnd, &paintStruct); + app->RenderFrame(); + EndPaint(hwnd, &paintStruct); + return 0; + } + break; + 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_LBUTTONUP: + if (app != nullptr) { + app->HandleLeftClick( + static_cast(GET_X_LPARAM(lParam)), + static_cast(GET_Y_LPARAM(lParam))); + return 0; + } + break; + case WM_RBUTTONUP: + if (app != nullptr) { + app->HandleRightClick( + 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_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 ScenarioApp::Initialize(HINSTANCE hInstance, int nCmdShow) { + m_hInstance = hInstance; + ResetScenario(); + + 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, + 1380, + 920, + nullptr, + nullptr, + hInstance, + this); + if (m_hwnd == nullptr) { + return false; + } + + ShowWindow(m_hwnd, nCmdShow); + UpdateWindow(m_hwnd); + + if (!m_renderer.Initialize(m_hwnd)) { + return false; + } + + m_autoScreenshot.Initialize( + (ResolveRepoRootPath() / "tests/UI/Editor/integration/shell/context_menu_basic/captures") + .lexically_normal()); + UpdateRects(); + return true; +} + +void ScenarioApp::Shutdown() { + m_autoScreenshot.Shutdown(); + m_renderer.Shutdown(); + + if (m_hwnd != nullptr && IsWindow(m_hwnd)) { + DestroyWindow(m_hwnd); + } + m_hwnd = nullptr; + + if (m_windowClassAtom != 0 && m_hInstance != nullptr) { + UnregisterClassW(kWindowClassName, m_hInstance); + m_windowClassAtom = 0; + } +} + +void ScenarioApp::ResetScenario() { + m_controller = + BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace()); + m_commandDispatcher = UIEditorCommandDispatcher(BuildCommandRegistry()); + m_shortcutManager = BuildShortcutManager(); + m_menuModel = BuildMenuModel(); + m_menuSession.Reset(); + m_menuPopups.clear(); + m_menuItems.clear(); + m_targetHovered = false; + m_hoveredPopupId.clear(); + m_hoveredItemId.clear(); + m_contextAnchorPoint = {}; + m_hasContextAnchor = false; + SetCustomResult( + "等待操作", + "Ready", + "右键 Context Target 打开菜单;hover `Workspace Tools` 展开子菜单;Esc / 外部点击关闭。"); +} + +void ScenarioApp::OnResize(UINT width, UINT height) { + if (width == 0u || height == 0u) { + return; + } + + m_renderer.Resize(width, height); + UpdateRects(); +} + +void ScenarioApp::UpdateRects() { + RECT clientRect = {}; + GetClientRect(m_hwnd, &clientRect); + const float width = static_cast((std::max)(clientRect.right - clientRect.left, 1L)); + const float height = static_cast((std::max)(clientRect.bottom - clientRect.top, 1L)); + + constexpr float margin = 20.0f; + m_headerRect = UIRect(margin, margin, width - margin * 2.0f, 176.0f); + m_targetRect = UIRect(margin, 212.0f, width * 0.58f, height - 332.0f); + m_stateRect = UIRect( + m_targetRect.x + m_targetRect.width + 16.0f, + m_targetRect.y, + width - m_targetRect.width - margin * 2.0f - 16.0f, + height - 332.0f); + m_footerRect = UIRect(margin, height - 124.0f, width - margin * 2.0f, 104.0f); +} + +void ScenarioApp::SetDispatchResult( + std::string actionName, + const UIEditorCommandDispatchResult& result) { + m_lastActionName = std::move(actionName); + if (result.commandExecuted) { + m_lastStatusLabel = + std::string(GetUIEditorWorkspaceCommandStatusName(result.commandResult.status)); + m_lastMessage = + result.displayName + " -> " + result.commandResult.message; + } else { + m_lastStatusLabel = + std::string(GetUIEditorCommandDispatchStatusName(result.status)); + m_lastMessage = result.message; + } + m_lastStatusColor = ResolveResultColor(m_lastStatusLabel); +} + +void ScenarioApp::SetCustomResult( + std::string actionName, + std::string statusLabel, + std::string message) { + m_lastActionName = std::move(actionName); + m_lastStatusLabel = std::move(statusLabel); + m_lastMessage = std::move(message); + m_lastStatusColor = ResolveResultColor(m_lastStatusLabel); +} + +const MenuPopupLayoutInfo* 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 MenuItemLayoutInfo* ScenarioApp::HitTestMenuItem(float x, float y) const { + for (auto it = m_menuItems.rbegin(); it != m_menuItems.rend(); ++it) { + if (it->kind != UIEditorMenuItemKind::Separator && + ContainsPoint(it->rect, x, y)) { + return &(*it); + } + } + + return nullptr; +} + +UIPopupOverlayEntry ScenarioApp::BuildRootPopupEntry(const UIPoint& anchorPoint) const { + UIPopupOverlayEntry entry = {}; + entry.popupId = BuildRootPopupId("scene-context"); + entry.anchorRect = UIRect(anchorPoint.x, anchorPoint.y, 1.0f, 1.0f); + entry.anchorPath = BuildTargetPath(); + entry.surfacePath = BuildPopupSurfacePath(entry.popupId); + entry.placement = UIPopupPlacement::BottomStart; + return entry; +} + +UIPopupOverlayEntry ScenarioApp::BuildSubmenuPopupEntry(const MenuItemLayoutInfo& 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 MenuItemLayoutInfo* hoveredItem = HitTestMenuItem(x, y); + const MenuPopupLayoutInfo* hoveredPopup = HitTestMenuPopup(x, y); + + const std::string hoveredItemId = + hoveredItem != nullptr ? hoveredItem->itemId : std::string(); + const std::string hoveredPopupId = + hoveredPopup != nullptr ? hoveredPopup->popupId : std::string(); + const bool targetHovered = ContainsPoint(m_targetRect, x, y); + + bool dirty = false; + if (hoveredItemId != m_hoveredItemId || + hoveredPopupId != m_hoveredPopupId || + targetHovered != m_targetHovered) { + m_hoveredItemId = hoveredItemId; + m_hoveredPopupId = hoveredPopupId; + m_targetHovered = targetHovered; + dirty = true; + } + + if (m_menuSession.HasOpenMenu()) { + 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 空白区后,更深层 child popup 已回收。closed: " + + JoinClosedPopupIds(mutation)); + dirty = true; + } + } + } + + ClearHoverWhenMenuClosed(); + if (dirty) { + InvalidateRect(m_hwnd, nullptr, FALSE); + } +} + +void ScenarioApp::HandleLeftClick(float x, float y) { + const MenuItemLayoutInfo* 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 MenuPopupLayoutInfo* 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); + } +} + +void ScenarioApp::HandleRightClick(float x, float y) { + if (!ContainsPoint(m_targetRect, x, y)) { + 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; + } + + m_contextAnchorPoint = UIPoint(x, y); + m_hasContextAnchor = true; + const auto mutation = + m_menuSession.OpenRootMenu("scene-context", BuildRootPopupEntry(m_contextAnchorPoint)); + SetCustomResult( + "右键打开 ContextMenu", + mutation.changed ? "Changed" : "NoOp", + mutation.changed + ? "已在右键位置打开 root popup。重点检查:菜单应贴近鼠标位置出现。" + : "ContextMenu 状态没有变化。"); + InvalidateRect(m_hwnd, nullptr, FALSE); +} + +void ScenarioApp::HandleKeyDown(UINT keyCode) { + switch (keyCode) { + case VK_ESCAPE: { + const auto mutation = m_menuSession.DismissFromEscape(); + SetCustomResult( + "Escape 关闭菜单", + mutation.changed ? "Dismissed" : "NoOp", + mutation.changed + ? "按下 Escape 后,topmost popup 已关闭。closed: " + + JoinClosedPopupIds(mutation) + : "当前没有可关闭的 popup。"); + 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; + } +} + +void ScenarioApp::DrawPopup( + UIDrawList& drawList, + std::string_view menuId, + std::string_view popupId, + const std::vector& items, + const UIPopupOverlayEntry& popupEntry) { + const auto popupItems = BuildPopupWidgetItems(items); + const float popupWidth = (std::max)( + 300.0f, + ResolveUIEditorMenuPopupDesiredWidth(popupItems)); + const auto placementResult = + ResolvePopupPlacementRect( + popupEntry.anchorRect, + XCEngine::UI::UISize(popupWidth, MeasureUIEditorMenuPopupHeight(popupItems)), + m_targetRect, + popupEntry.placement); + const UIRect popupRect = placementResult.rect; + + UIEditorMenuPopupWidgetState popupState = {}; + popupState.hoveredIndex = FindPopupWidgetIndex(popupItems, m_hoveredItemId); + popupState.focused = true; + for (std::size_t index = 0; index < popupItems.size(); ++index) { + if (popupItems[index].hasSubmenu && + m_menuSession.IsPopupOpen(BuildSubmenuPopupId(popupItems[index].itemId))) { + popupState.submenuOpenIndex = index; + break; + } + } + + const auto popupLayout = BuildUIEditorMenuPopupLayout(popupRect, popupItems); + AppendUIEditorMenuPopupBackground(drawList, popupLayout, popupItems, popupState); + AppendUIEditorMenuPopupForeground(drawList, popupLayout, popupItems, popupState); + + m_menuPopups.push_back( + { std::string(popupId), std::string(menuId), popupRect, popupEntry.surfacePath }); + + for (std::size_t index = 0; index < items.size() && index < popupItems.size() && index < popupLayout.itemRects.size(); ++index) { + const UIEditorResolvedMenuItem& item = items[index]; + const bool hasSubmenu = + item.kind == UIEditorMenuItemKind::Submenu && !item.children.empty(); + const std::string childPopupId = + hasSubmenu ? BuildSubmenuPopupId(item.itemId) : std::string(); + + m_menuItems.push_back( + { + std::string(popupId), + item.itemId, + item.label, + item.commandId, + childPopupId, + popupLayout.itemRects[index], + BuildMenuItemPath(popupId, item.itemId), + item.kind, + item.enabled, + hasSubmenu + }); + } +} + +void ScenarioApp::DrawOpenPopups( + UIDrawList& drawList, + const UIEditorResolvedMenuModel& resolvedModel) { + 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); + } +} + +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_menuPopups.clear(); + m_menuItems.clear(); + + UIDrawList& drawList = drawData.EmplaceDrawList("Editor Context Menu Basic"); + drawList.AddFilledRect(UIRect(0.0f, 0.0f, width, height), kWindowBg); + + DrawCard( + drawList, + m_headerRect, + "测试功能:Editor ContextMenu 基础层", + "本场景只验证 root popup 锚点、submenu hover、outside/Esc dismiss、命令派发;不验证业务面板。"); + drawList.AddText(UIPoint(m_headerRect.x + 18.0f, m_headerRect.y + 70.0f), "1. 在左侧 Context Target 内右键,root popup 必须贴近鼠标位置打开。", kTextPrimary, 13.0f); + drawList.AddText(UIPoint(m_headerRect.x + 18.0f, m_headerRect.y + 92.0f), "2. hover `Workspace Tools`,右侧 child popup 必须立即弹出。", kTextPrimary, 13.0f); + drawList.AddText(UIPoint(m_headerRect.x + 18.0f, m_headerRect.y + 114.0f), "3. 点击 Show/Activate/Hide/Reset,右侧 Details visible / active / activePanel 状态必须同步变化。", kTextPrimary, 13.0f); + drawList.AddText(UIPoint(m_headerRect.x + 18.0f, m_headerRect.y + 136.0f), "4. 点击 popup 外部区域应整条关闭;如果 child popup 已打开,Esc 先关 topmost,再关 root。", kTextPrimary, 13.0f); + drawList.AddText(UIPoint(m_headerRect.x + 18.0f, m_headerRect.y + 158.0f), "5. F12 截图;R 直接触发 Reset Workspace,便于确认命令派发仍正常。", kTextPrimary, 13.0f); + + DrawCard(drawList, m_targetRect, "Context Target", "只在这里接受右键打开 ContextMenu。"); + DrawCard(drawList, m_stateRect, "状态摘要", "重点看 popup chain、anchor 和 Details 状态。"); + DrawCard(drawList, m_footerRect, "最近结果", "显示最近一次交互、命令状态和截图输出。"); + + const UIRect targetSurface( + m_targetRect.x + 18.0f, + m_targetRect.y + 74.0f, + m_targetRect.width - 36.0f, + m_targetRect.height - 92.0f); + drawList.AddFilledRect( + targetSurface, + m_targetHovered ? kTargetHoverBg : kTargetBg, + 10.0f); + drawList.AddRectOutline(targetSurface, kCardBorder, 1.0f, 10.0f); + drawList.AddText(UIPoint(targetSurface.x + 18.0f, targetSurface.y + 18.0f), "在这块区域右键打开 ContextMenu", kTextPrimary, 16.0f); + drawList.AddText(UIPoint(targetSurface.x + 18.0f, targetSurface.y + 46.0f), "当前 anchor: " + FormatAnchorPoint(m_contextAnchorPoint, m_hasContextAnchor), kTextMuted, 12.0f); + drawList.AddText(UIPoint(targetSurface.x + 18.0f, targetSurface.y + 68.0f), "Visible Panels: " + JoinVisiblePanelIds(workspace, session), kTextMuted, 12.0f); + drawList.AddText(UIPoint(targetSurface.x + 18.0f, targetSurface.y + 90.0f), "Details visible: " + std::string(detailsState != nullptr && detailsState->visible ? "true" : "false"), kTextMuted, 12.0f); + drawList.AddText(UIPoint(targetSurface.x + 18.0f, targetSurface.y + 112.0f), "Details active: " + std::string(workspace.activePanelId == "details" ? "true" : "false"), kTextMuted, 12.0f); + + const UIRect stateBox(m_stateRect.x + 18.0f, m_stateRect.y + 74.0f, m_stateRect.width - 36.0f, 220.0f); + drawList.AddFilledRect(stateBox, kIndicatorBg, 10.0f); + drawList.AddRectOutline(stateBox, kCardBorder, 1.0f, 10.0f); + drawList.AddText(UIPoint(stateBox.x + 14.0f, stateBox.y + 14.0f), "Open root menu", kTextMuted, 12.0f); + drawList.AddText(UIPoint(stateBox.x + 14.0f, stateBox.y + 34.0f), m_menuSession.HasOpenMenu() ? std::string(m_menuSession.GetOpenRootMenuId()) : "(none)", kTextPrimary, 14.0f); + drawList.AddText(UIPoint(stateBox.x + 14.0f, stateBox.y + 62.0f), "Popup chain", kTextMuted, 12.0f); + drawList.AddText(UIPoint(stateBox.x + 14.0f, stateBox.y + 82.0f), JoinPopupChainIds(m_menuSession), kTextPrimary, 14.0f); + drawList.AddText(UIPoint(stateBox.x + 14.0f, stateBox.y + 110.0f), "Submenu path", kTextMuted, 12.0f); + drawList.AddText(UIPoint(stateBox.x + 14.0f, stateBox.y + 130.0f), JoinSubmenuPathIds(m_menuSession), kTextPrimary, 14.0f); + drawList.AddText(UIPoint(stateBox.x + 14.0f, stateBox.y + 158.0f), "Active panel", kTextMuted, 12.0f); + drawList.AddText(UIPoint(stateBox.x + 14.0f, stateBox.y + 178.0f), workspace.activePanelId.empty() ? "(none)" : workspace.activePanelId, kTextPrimary, 14.0f); + drawList.AddText(UIPoint(stateBox.x + 14.0f, stateBox.y + 202.0f), "Menu validation: " + std::string(menuValidation.IsValid() ? "OK" : menuValidation.message), menuValidation.IsValid() ? kSuccess : kDanger, 12.0f); + + const std::string captureSummary = + m_autoScreenshot.HasPendingCapture() + ? "截图排队中..." + : (m_autoScreenshot.GetLastCaptureSummary().empty() + ? std::string("F12 -> tests/UI/Editor/integration/shell/context_menu_basic/captures/") + : m_autoScreenshot.GetLastCaptureSummary()); + drawList.AddText(UIPoint(m_footerRect.x + 18.0f, m_footerRect.y + 56.0f), "Action: " + m_lastActionName, kTextPrimary, 13.0f); + drawList.AddText(UIPoint(m_footerRect.x + 18.0f, m_footerRect.y + 74.0f), "Result: " + m_lastStatusLabel + " | " + m_lastMessage, m_lastStatusColor, 12.0f); + drawList.AddText(UIPoint(m_footerRect.x + 18.0f, m_footerRect.y + 92.0f), captureSummary, kTextWeak, 12.0f); + + DrawOpenPopups(drawList, resolvedModel); +} + +void ScenarioApp::RenderFrame() { + if (m_hwnd == nullptr) { + return; + } + + RECT clientRect = {}; + GetClientRect(m_hwnd, &clientRect); + const float width = static_cast((std::max)(clientRect.right - clientRect.left, 1L)); + const float height = static_cast((std::max)(clientRect.bottom - clientRect.top, 1L)); + + UIDrawData drawData = {}; + BuildDrawData(drawData, width, height); + + const bool framePresented = m_renderer.Render(drawData); + m_autoScreenshot.CaptureIfRequested( + m_renderer, + drawData, + static_cast(width), + static_cast(height), + framePresented); +} + +} // namespace + +int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) { + return ScenarioApp().Run(hInstance, nCmdShow); +} 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 aa2726c5..da60152c 100644 --- a/tests/UI/Editor/integration/shell/menu_bar_basic/main.cpp +++ b/tests/UI/Editor/integration/shell/menu_bar_basic/main.cpp @@ -6,6 +6,8 @@ #include #include #include +#include +#include #include "Host/AutoScreenshot.h" #include "Host/NativeRenderer.h" @@ -77,6 +79,20 @@ using XCEngine::UI::UIShortcutScope; using XCEngine::UI::Widgets::ResolvePopupPlacementRect; using XCEngine::UI::Widgets::UIPopupOverlayEntry; using XCEngine::UI::Widgets::UIPopupPlacement; +using XCEngine::UI::Editor::Widgets::AppendUIEditorMenuBarBackground; +using XCEngine::UI::Editor::Widgets::AppendUIEditorMenuBarForeground; +using XCEngine::UI::Editor::Widgets::AppendUIEditorMenuPopupBackground; +using XCEngine::UI::Editor::Widgets::AppendUIEditorMenuPopupForeground; +using XCEngine::UI::Editor::Widgets::BuildUIEditorMenuBarLayout; +using XCEngine::UI::Editor::Widgets::BuildUIEditorMenuPopupLayout; +using XCEngine::UI::Editor::Widgets::MeasureUIEditorMenuPopupHeight; +using XCEngine::UI::Editor::Widgets::ResolveUIEditorMenuPopupDesiredWidth; +using XCEngine::UI::Editor::Widgets::UIEditorMenuBarItem; +using XCEngine::UI::Editor::Widgets::UIEditorMenuBarState; +using UIEditorMenuPopupWidgetItem = XCEngine::UI::Editor::Widgets::UIEditorMenuPopupItem; +using UIEditorMenuPopupWidgetState = XCEngine::UI::Editor::Widgets::UIEditorMenuPopupState; +using XCEngine::UI::Editor::Widgets::UIEditorMenuBarInvalidIndex; +using XCEngine::UI::Editor::Widgets::UIEditorMenuPopupInvalidIndex; using XCEngine::UI::Editor::Host::AutoScreenshotController; using XCEngine::UI::Editor::Host::NativeRenderer; @@ -159,8 +175,11 @@ 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::vector BuildMenuBarWidgetItems(const UIEditorResolvedMenuModel& model); +std::vector BuildMenuPopupWidgetItems(const std::vector& items); +std::size_t FindMenuBarWidgetIndex(const std::vector& items, std::string_view menuId); +std::size_t FindMenuPopupWidgetIndex(const std::vector& items, std::string_view itemId); std::uint64_t HashText(std::string_view text); -float MeasureMenuPopupHeight(const std::vector& items); class ScenarioApp { public: @@ -602,13 +621,61 @@ const std::vector* ResolvePopupItems( return &item->children; } -float MeasureMenuPopupHeight(const std::vector& items) { - float contentHeight = 10.0f; +std::vector BuildMenuBarWidgetItems( + const UIEditorResolvedMenuModel& model) { + std::vector items = {}; + items.reserve(model.menus.size()); + for (const UIEditorResolvedMenuDescriptor& menu : model.menus) { + UIEditorMenuBarItem item = {}; + item.menuId = menu.menuId; + item.label = menu.label; + item.enabled = true; + items.push_back(std::move(item)); + } + return items; +} + +std::vector BuildMenuPopupWidgetItems( + const std::vector& items) { + std::vector popupItems = {}; + popupItems.reserve(items.size()); for (const UIEditorResolvedMenuItem& item : items) { - contentHeight += item.kind == UIEditorMenuItemKind::Separator ? 12.0f : 34.0f; + UIEditorMenuPopupWidgetItem popupItem = {}; + popupItem.itemId = item.itemId; + popupItem.kind = item.kind; + popupItem.label = item.label; + popupItem.shortcutText = item.shortcutText; + popupItem.enabled = item.enabled; + popupItem.checked = item.checked; + popupItem.hasSubmenu = + item.kind == UIEditorMenuItemKind::Submenu && !item.children.empty(); + popupItems.push_back(std::move(popupItem)); + } + return popupItems; +} + +std::size_t FindMenuBarWidgetIndex( + const std::vector& items, + std::string_view menuId) { + for (std::size_t index = 0; index < items.size(); ++index) { + if (items[index].menuId == menuId) { + return index; + } } - return contentHeight + 8.0f; + return UIEditorMenuBarInvalidIndex; +} + +std::size_t FindMenuPopupWidgetIndex( + const std::vector& items, + std::string_view itemId) { + for (std::size_t index = 0; index < items.size(); ++index) { + if (items[index].itemId == itemId) { + return index; + } + } + + return UIEditorMenuPopupInvalidIndex; } int ScenarioApp::Run(HINSTANCE hInstance, int nCmdShow) { @@ -867,7 +934,8 @@ const MenuPopupLayout* ScenarioApp::HitTestMenuPopup(float x, float y) const { 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)) { + if (it->kind != UIEditorMenuItemKind::Separator && + ContainsPoint(it->rect, x, y)) { return &(*it); } } @@ -1234,31 +1302,25 @@ 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); + const auto barItems = BuildMenuBarWidgetItems(resolvedModel); + UIEditorMenuBarState barState = {}; + barState.openIndex = FindMenuBarWidgetIndex(barItems, m_menuSession.GetOpenRootMenuId()); + barState.hoveredIndex = FindMenuBarWidgetIndex(barItems, m_hoveredMenuId); + barState.focused = m_menuSession.HasOpenMenu(); - 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); + const auto barLayout = BuildUIEditorMenuBarLayout(rect, barItems); + AppendUIEditorMenuBarBackground(drawList, barLayout, barItems, barState); + AppendUIEditorMenuBarForeground(drawList, barLayout, barItems, barState); + for (std::size_t index = 0; index < barItems.size() && index < barLayout.buttonRects.size(); ++index) { m_menuButtons.push_back( - { menu.menuId, menu.label, BuildRootPopupId(menu.menuId), buttonRect, BuildMenuButtonPath(menu.menuId) }); - - buttonX += buttonWidth + 10.0f; + { + barItems[index].menuId, + barItems[index].label, + BuildRootPopupId(barItems[index].menuId), + barLayout.buttonRects[index], + BuildMenuButtonPath(barItems[index].menuId) + }); } } @@ -1269,75 +1331,45 @@ void ScenarioApp::DrawPopup( const std::vector& items, const UIPopupOverlayEntry& popupEntry, const UIRect& viewportRect) { + const auto popupItems = BuildMenuPopupWidgetItems(items); + const float popupWidth = (std::max)( + kMenuPopupWidth, + ResolveUIEditorMenuPopupDesiredWidth(popupItems)); const auto placementResult = ResolvePopupPlacementRect( popupEntry.anchorRect, - XCEngine::UI::UISize(kMenuPopupWidth, MeasureMenuPopupHeight(items)), + XCEngine::UI::UISize(popupWidth, MeasureUIEditorMenuPopupHeight(popupItems)), viewportRect, popupEntry.placement); const UIRect popupRect = placementResult.rect; - drawList.AddFilledRect(popupRect, kMenuDropBg, 8.0f); - drawList.AddRectOutline(popupRect, kCardBorder, 1.0f, 8.0f); + UIEditorMenuPopupWidgetState popupState = {}; + popupState.hoveredIndex = FindMenuPopupWidgetIndex(popupItems, m_hoveredItemId); + popupState.focused = true; + for (std::size_t index = 0; index < popupItems.size(); ++index) { + if (!popupItems[index].hasSubmenu) { + continue; + } + + if (m_menuSession.IsPopupOpen(BuildSubmenuPopupId(popupItems[index].itemId))) { + popupState.submenuOpenIndex = index; + break; + } + } + + const auto popupLayout = BuildUIEditorMenuPopupLayout(popupRect, popupItems); + AppendUIEditorMenuPopupBackground(drawList, popupLayout, popupItems, popupState); + AppendUIEditorMenuPopupForeground(drawList, popupLayout, popupItems, popupState); 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; - } - + for (std::size_t index = 0; index < items.size() && index < popupItems.size() && index < popupLayout.itemRects.size(); ++index) { + const UIEditorResolvedMenuItem& item = items[index]; 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), @@ -1348,13 +1380,12 @@ void ScenarioApp::DrawPopup( item.commandId, item.shortcutText, childPopupId, - itemRect, + popupLayout.itemRects[index], BuildMenuItemPath(popupId, item.itemId), item.enabled, item.checked, hasSubmenu }); - itemY += 34.0f; } } @@ -1410,7 +1441,7 @@ void ScenarioApp::BuildDrawData(UIDrawData& drawData, float width, float height) 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); + const UIRect footerRect(margin, height - 124.0f, width - margin * 2.0f, 104.0f); DrawCard( drawList, @@ -1461,8 +1492,8 @@ void ScenarioApp::BuildDrawData(UIDrawData& drawData, float width, float height) 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); + drawList.AddText(UIPoint(footerRect.x + 18.0f, footerRect.y + 56.0f), "Last interaction: " + m_lastActionName + " | Result: " + m_lastStatusLabel, m_lastStatusColor, 13.0f); + drawList.AddText(UIPoint(footerRect.x + 18.0f, footerRect.y + 74.0f), m_lastMessage, kTextPrimary, 12.0f); const std::string captureSummary = m_autoScreenshot.HasPendingCapture() @@ -1470,7 +1501,7 @@ void ScenarioApp::BuildDrawData(UIDrawData& drawData, float width, float height) : (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); + drawList.AddText(UIPoint(footerRect.x + 18.0f, footerRect.y + 92.0f), captureSummary, kTextMuted, 12.0f); DrawOpenPopups(drawList, resolvedModel, viewportRect); } diff --git a/tests/UI/Editor/integration/shell/status_bar_basic/CMakeLists.txt b/tests/UI/Editor/integration/shell/status_bar_basic/CMakeLists.txt new file mode 100644 index 00000000..09052c33 --- /dev/null +++ b/tests/UI/Editor/integration/shell/status_bar_basic/CMakeLists.txt @@ -0,0 +1,30 @@ +add_executable(editor_ui_status_bar_basic_validation WIN32 + main.cpp +) + +target_include_directories(editor_ui_status_bar_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_status_bar_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_status_bar_basic_validation PRIVATE /utf-8 /FS) + set_property(TARGET editor_ui_status_bar_basic_validation PROPERTY + MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>DLL") +endif() + +target_link_libraries(editor_ui_status_bar_basic_validation PRIVATE + XCUIEditorLib + XCUIEditorHost +) + +set_target_properties(editor_ui_status_bar_basic_validation PROPERTIES + OUTPUT_NAME "XCUIEditorStatusBarBasicValidation" +) diff --git a/tests/UI/Editor/integration/shell/status_bar_basic/main.cpp b/tests/UI/Editor/integration/shell/status_bar_basic/main.cpp new file mode 100644 index 00000000..e701dce5 --- /dev/null +++ b/tests/UI/Editor/integration/shell/status_bar_basic/main.cpp @@ -0,0 +1,531 @@ +#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::AppendUIEditorStatusBarBackground; +using XCEngine::UI::Editor::Widgets::AppendUIEditorStatusBarForeground; +using XCEngine::UI::Editor::Widgets::BuildUIEditorStatusBarLayout; +using XCEngine::UI::Editor::Widgets::HitTestUIEditorStatusBar; +using XCEngine::UI::Editor::Widgets::UIEditorStatusBarHitTarget; +using XCEngine::UI::Editor::Widgets::UIEditorStatusBarHitTargetKind; +using XCEngine::UI::Editor::Widgets::UIEditorStatusBarInvalidIndex; +using XCEngine::UI::Editor::Widgets::UIEditorStatusBarLayout; +using XCEngine::UI::Editor::Widgets::UIEditorStatusBarSegment; +using XCEngine::UI::Editor::Widgets::UIEditorStatusBarSlot; +using XCEngine::UI::Editor::Widgets::UIEditorStatusBarState; +using XCEngine::UI::Editor::Widgets::UIEditorStatusBarTextTone; + +constexpr const wchar_t* kWindowClassName = L"XCUIEditorStatusBarBasicValidation"; +constexpr const wchar_t* kWindowTitle = L"XCUI Editor | StatusBar 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.40f, 0.40f, 0.40f, 1.0f); +constexpr UIColor kButtonBorder(0.48f, 0.48f, 0.48f, 1.0f); + +enum class ActionId : unsigned char { + ToggleAccent = 0, + ToggleSeparator, + MoveToTrailing, + Reset, + Capture +}; + +struct ButtonState { + ActionId action = ActionId::ToggleAccent; + 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 DescribeHitTarget(const UIEditorStatusBarHitTarget& hit) { + switch (hit.kind) { + case UIEditorStatusBarHitTargetKind::Segment: + return "Segment[" + std::to_string(hit.index) + "]"; + case UIEditorStatusBarHitTargetKind::Separator: + return "Separator[" + std::to_string(hit.index) + "]"; + case UIEditorStatusBarHitTargetKind::Background: + return "Background"; + case UIEditorStatusBarHitTargetKind::None: + default: + return "None"; + } +} + +std::string DescribeSlot(UIEditorStatusBarSlot slot) { + return slot == UIEditorStatusBarSlot::Leading ? "Leading" : "Trailing"; +} + +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"); + InvalidateRect(hwnd, nullptr, FALSE); + UpdateWindow(hwnd); + 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: + 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/status_bar_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; + } + + if (!m_renderer.Initialize(m_hwnd)) { + return false; + } + + ShowWindow(m_hwnd, nCmdShow); + + 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); + UpdateHover(); + } + + void HandleMouseLeave() { + m_mousePosition = UIPoint(-1000.0f, -1000.0f); + m_state.hoveredIndex = UIEditorStatusBarInvalidIndex; + m_hoverTarget = {}; + } + + void HandleClick(float x, float y) { + for (const ButtonState& button : m_buttons) { + if (ContainsPoint(button.rect, x, y)) { + ExecuteAction(button.action); + return; + } + } + + m_hoverTarget = HitTestUIEditorStatusBar(m_layout, UIPoint(x, y)); + if (m_hoverTarget.kind == UIEditorStatusBarHitTargetKind::Segment) { + m_state.activeIndex = m_hoverTarget.index; + m_state.focused = true; + m_lastResult = "激活 segment: " + m_segments[m_hoverTarget.index].segmentId; + } else if (m_hoverTarget.kind == UIEditorStatusBarHitTargetKind::Background) { + m_state.activeIndex = UIEditorStatusBarInvalidIndex; + m_lastResult = "点击 status bar background"; + } else { + m_lastResult = "命中 " + DescribeHitTarget(m_hoverTarget); + } + UpdateHover(); + } + + void ExecuteAction(ActionId action) { + switch (action) { + case ActionId::ToggleAccent: + m_segments[1].tone = + m_segments[1].tone == UIEditorStatusBarTextTone::Accent + ? UIEditorStatusBarTextTone::Primary + : UIEditorStatusBarTextTone::Accent; + m_lastResult = "切换 Selection 文本强调"; + break; + case ActionId::ToggleSeparator: + m_segments[0].showSeparator = !m_segments[0].showSeparator; + m_lastResult = m_segments[0].showSeparator ? "开启 Leading separator" : "关闭 Leading separator"; + break; + case ActionId::MoveToTrailing: + m_segments[1].slot = + m_segments[1].slot == UIEditorStatusBarSlot::Leading + ? UIEditorStatusBarSlot::Trailing + : UIEditorStatusBarSlot::Leading; + m_lastResult = "切换 Selection slot -> " + DescribeSlot(m_segments[1].slot); + break; + case ActionId::Reset: + ResetState(); + m_lastResult = "状态重置"; + break; + case ActionId::Capture: + m_autoScreenshot.RequestCapture("manual_button"); + InvalidateRect(m_hwnd, nullptr, FALSE); + UpdateWindow(m_hwnd); + m_lastResult = "截图已排队"; + break; + } + UpdateHover(); + } + + void ResetState() { + m_segments = { + { "scene", "Scene: Main", UIEditorStatusBarSlot::Leading, UIEditorStatusBarTextTone::Primary, true, true, 96.0f }, + { "selection", "Selection: Camera", UIEditorStatusBarSlot::Leading, UIEditorStatusBarTextTone::Accent, true, false, 140.0f }, + { "frame", "16.7 ms", UIEditorStatusBarSlot::Trailing, UIEditorStatusBarTextTone::Muted, true, true, 64.0f }, + { "gpu", "GPU Ready", UIEditorStatusBarSlot::Trailing, UIEditorStatusBarTextTone::Primary, true, false, 86.0f } + }; + m_state = {}; + m_state.focused = true; + m_state.activeIndex = 1u; + m_hoverTarget = {}; + m_lastResult = "Ready"; + } + + void UpdateHover() { + m_hoverTarget = HitTestUIEditorStatusBar(m_layout, m_mousePosition); + m_state.hoveredIndex = + m_hoverTarget.kind == UIEditorStatusBarHitTargetKind::Segment + ? m_hoverTarget.index + : UIEditorStatusBarInvalidIndex; + } + + void BuildButtons(float left, float top, float width) { + const float buttonHeight = 34.0f; + const float gap = 10.0f; + m_buttons = { + { ActionId::ToggleAccent, "强调文本", UIRect(left, top, width, buttonHeight), m_segments[1].tone == UIEditorStatusBarTextTone::Accent }, + { ActionId::ToggleSeparator, "Leading 分隔线", UIRect(left, top + (buttonHeight + gap), width, buttonHeight), m_segments[0].showSeparator }, + { ActionId::MoveToTrailing, "切换 Selection Slot", UIRect(left, top + (buttonHeight + gap) * 2.0f, width, buttonHeight), m_segments[1].slot == UIEditorStatusBarSlot::Trailing }, + { ActionId::Reset, "Reset", UIRect(left, top + (buttonHeight + gap) * 3.0f, width, buttonHeight), false }, + { ActionId::Capture, "截图", UIRect(left, top + (buttonHeight + gap) * 4.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, 156.0f); + const UIRect controlsRect(outerPadding, 196.0f, leftColumnWidth, 276.0f); + const UIRect stateRect(outerPadding, 492.0f, leftColumnWidth, 244.0f); + const UIRect previewRect( + leftColumnWidth + outerPadding * 2.0f, + outerPadding, + width - leftColumnWidth - outerPadding * 3.0f, + height - outerPadding * 2.0f); + const UIRect viewportRect( + previewRect.x, + previewRect.y, + previewRect.width, + previewRect.height - 28.0f); + const UIRect statusBarRect( + previewRect.x, + previewRect.y + previewRect.height - 28.0f, + previewRect.width, + 28.0f); + + BuildButtons(controlsRect.x + 16.0f, controlsRect.y + 54.0f, controlsRect.width - 32.0f); + m_layout = BuildUIEditorStatusBarLayout(statusBarRect, m_segments); + UpdateHover(); + + UIDrawData drawData = {}; + UIDrawList& drawList = drawData.EmplaceDrawList("StatusBarBasic"); + drawList.AddFilledRect(UIRect(0.0f, 0.0f, width, height), kWindowBg); + + DrawCard( + drawList, + introRect, + "测试功能:StatusBar 基础壳层", + "重点检查:Leading / Trailing slot 对齐,文本强调,separator 开关,hover / active 命中。"); + drawList.AddText( + UIPoint(introRect.x + 16.0f, introRect.y + 66.0f), + "操作:hover 观察 segment 高亮;点击 segment 切 active;切换左侧按钮检查 slot / separator / emphasis;按 F12 或点“截图”。", + kTextMuted, + 12.0f); + drawList.AddText( + UIPoint(introRect.x + 16.0f, introRect.y + 90.0f), + "这个场景只验证 Editor 基础层 StatusBar,不混入任何业务面板。", + kTextWeak, + 12.0f); + + DrawCard(drawList, controlsRect, "开关", "只保留和当前 StatusBar contract 直接相关的操作。"); + for (const ButtonState& button : m_buttons) { + DrawButton(drawList, button); + } + + DrawCard(drawList, stateRect, "状态", "命中、active、slot 和结果统一回显在这里。"); + drawList.AddText( + UIPoint(stateRect.x + 16.0f, stateRect.y + 66.0f), + "Hover: " + DescribeHitTarget(m_hoverTarget), + kTextPrimary, + 13.0f); + drawList.AddText( + UIPoint(stateRect.x + 16.0f, stateRect.y + 92.0f), + "Active: " + (m_state.activeIndex == UIEditorStatusBarInvalidIndex ? std::string("None") : m_segments[m_state.activeIndex].segmentId), + kTextPrimary, + 13.0f); + drawList.AddText( + UIPoint(stateRect.x + 16.0f, stateRect.y + 118.0f), + "Selection Slot: " + DescribeSlot(m_segments[1].slot), + kTextPrimary, + 13.0f); + drawList.AddText( + UIPoint(stateRect.x + 16.0f, stateRect.y + 144.0f), + std::string("Leading Separator: ") + (m_segments[0].showSeparator ? "On" : "Off"), + kTextPrimary, + 13.0f); + drawList.AddText( + UIPoint(stateRect.x + 16.0f, stateRect.y + 170.0f), + "Result: " + m_lastResult, + kTextMuted, + 12.0f); + const std::string captureSummary = + m_autoScreenshot.HasPendingCapture() + ? "截图排队中..." + : (m_autoScreenshot.GetLastCaptureSummary().empty() + ? std::string("截图: F12 或按钮 -> status_bar_basic/captures/") + : m_autoScreenshot.GetLastCaptureSummary()); + drawList.AddText( + UIPoint(stateRect.x + 16.0f, stateRect.y + 196.0f), + captureSummary, + kTextWeak, + 12.0f); + + drawList.AddFilledRect(viewportRect, UIColor(0.17f, 0.17f, 0.17f, 1.0f), 10.0f); + drawList.AddRectOutline(viewportRect, kCardBorder, 1.0f, 10.0f); + drawList.AddText( + UIPoint(viewportRect.x + 18.0f, viewportRect.y + 18.0f), + "Preview Host", + kTextPrimary, + 18.0f); + drawList.AddText( + UIPoint(viewportRect.x + 18.0f, viewportRect.y + 48.0f), + "这里只保留一个空白宿主区域,专门看底部 StatusBar 的对齐和交互。", + kTextMuted, + 12.0f); + + AppendUIEditorStatusBarBackground(drawList, m_layout, m_segments, m_state); + AppendUIEditorStatusBarForeground(drawList, m_layout, m_segments, m_state); + + 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 = {}; + std::vector m_segments = {}; + UIPoint m_mousePosition = UIPoint(-1000.0f, -1000.0f); + UIEditorStatusBarState m_state = {}; + UIEditorStatusBarLayout m_layout = {}; + UIEditorStatusBarHitTarget m_hoverTarget = {}; + 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 5d4504b7..b6918e81 100644 --- a/tests/UI/Editor/unit/CMakeLists.txt +++ b/tests/UI/Editor/unit/CMakeLists.txt @@ -6,11 +6,14 @@ set(EDITOR_UI_UNIT_TEST_SOURCES test_ui_editor_command_registry.cpp test_ui_editor_menu_model.cpp test_ui_editor_menu_session.cpp + test_ui_editor_menu_bar.cpp + test_ui_editor_menu_popup.cpp test_ui_editor_panel_registry.cpp test_ui_editor_collection_primitives.cpp test_ui_editor_dock_host.cpp test_ui_editor_panel_chrome.cpp test_ui_editor_panel_frame.cpp + test_ui_editor_status_bar.cpp test_ui_editor_tab_strip.cpp test_ui_editor_shortcut_manager.cpp test_ui_editor_workspace_controller.cpp diff --git a/tests/UI/Editor/unit/test_ui_editor_menu_bar.cpp b/tests/UI/Editor/unit/test_ui_editor_menu_bar.cpp new file mode 100644 index 00000000..4010eb85 --- /dev/null +++ b/tests/UI/Editor/unit/test_ui_editor_menu_bar.cpp @@ -0,0 +1,117 @@ +#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::AppendUIEditorMenuBarBackground; +using XCEngine::UI::Editor::Widgets::AppendUIEditorMenuBarForeground; +using XCEngine::UI::Editor::Widgets::BuildUIEditorMenuBarLayout; +using XCEngine::UI::Editor::Widgets::HitTestUIEditorMenuBar; +using XCEngine::UI::Editor::Widgets::ResolveUIEditorMenuBarDesiredButtonWidth; +using XCEngine::UI::Editor::Widgets::UIEditorMenuBarHitTargetKind; +using XCEngine::UI::Editor::Widgets::UIEditorMenuBarInvalidIndex; +using XCEngine::UI::Editor::Widgets::UIEditorMenuBarItem; +using XCEngine::UI::Editor::Widgets::UIEditorMenuBarState; + +TEST(UIEditorMenuBarTest, DesiredWidthUsesLabelEstimateAndHorizontalPadding) { + XCEngine::UI::Editor::Widgets::UIEditorMenuBarMetrics metrics = {}; + metrics.estimatedGlyphWidth = 8.0f; + metrics.buttonPaddingX = 12.0f; + + EXPECT_FLOAT_EQ( + ResolveUIEditorMenuBarDesiredButtonWidth( + UIEditorMenuBarItem{ "file", "File", true, 0.0f }, + metrics), + 56.0f); + EXPECT_FLOAT_EQ( + ResolveUIEditorMenuBarDesiredButtonWidth( + UIEditorMenuBarItem{ "window", "Window", true, 50.0f }, + metrics), + 74.0f); +} + +TEST(UIEditorMenuBarTest, LayoutBuildsHorizontalButtonsInsideBarInsets) { + XCEngine::UI::Editor::Widgets::UIEditorMenuBarMetrics metrics = {}; + metrics.horizontalInset = 8.0f; + metrics.verticalInset = 3.0f; + metrics.buttonGap = 4.0f; + metrics.buttonPaddingX = 10.0f; + metrics.estimatedGlyphWidth = 8.0f; + + const std::vector items = { + { "file", "File", true, 0.0f }, + { "window", "Window", true, 32.0f } + }; + + const auto layout = + BuildUIEditorMenuBarLayout(UIRect(10.0f, 20.0f, 240.0f, 32.0f), items, metrics); + + EXPECT_FLOAT_EQ(layout.contentRect.x, 18.0f); + EXPECT_FLOAT_EQ(layout.contentRect.y, 23.0f); + EXPECT_FLOAT_EQ(layout.contentRect.width, 224.0f); + EXPECT_FLOAT_EQ(layout.contentRect.height, 26.0f); + + ASSERT_EQ(layout.buttonRects.size(), 2u); + EXPECT_FLOAT_EQ(layout.buttonRects[0].x, 18.0f); + EXPECT_FLOAT_EQ(layout.buttonRects[0].width, 52.0f); + EXPECT_FLOAT_EQ(layout.buttonRects[1].x, 74.0f); + EXPECT_FLOAT_EQ(layout.buttonRects[1].width, 52.0f); +} + +TEST(UIEditorMenuBarTest, HitTestResolvesButtonBeforeBarBackground) { + const std::vector items = { + { "file", "File", true, 0.0f }, + { "window", "Window", true, 32.0f } + }; + const auto layout = + BuildUIEditorMenuBarLayout(UIRect(10.0f, 20.0f, 240.0f, 32.0f), items); + + const auto buttonHit = HitTestUIEditorMenuBar(layout, UIPoint(30.0f, 32.0f)); + EXPECT_EQ(buttonHit.kind, UIEditorMenuBarHitTargetKind::Button); + EXPECT_EQ(buttonHit.index, 0u); + + const auto backgroundHit = HitTestUIEditorMenuBar(layout, UIPoint(220.0f, 32.0f)); + EXPECT_EQ(backgroundHit.kind, UIEditorMenuBarHitTargetKind::BarBackground); + EXPECT_EQ(backgroundHit.index, UIEditorMenuBarInvalidIndex); +} + +TEST(UIEditorMenuBarTest, BackgroundAndForegroundEmitStableCommands) { + const std::vector items = { + { "file", "File", true, 0.0f }, + { "window", "Window", false, 32.0f } + }; + + UIEditorMenuBarState state = {}; + state.openIndex = 0u; + state.hoveredIndex = 1u; + state.focused = true; + const auto layout = + BuildUIEditorMenuBarLayout(UIRect(10.0f, 20.0f, 240.0f, 32.0f), items); + + UIDrawList background("MenuBarBackground"); + AppendUIEditorMenuBarBackground(background, layout, items, state); + ASSERT_EQ(background.GetCommandCount(), 6u); + 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[5].type, UIDrawCommandType::RectOutline); + + UIDrawList foreground("MenuBarForeground"); + AppendUIEditorMenuBarForeground(foreground, layout, items, state); + ASSERT_EQ(foreground.GetCommandCount(), 6u); + const auto& foregroundCommands = foreground.GetCommands(); + EXPECT_EQ(foregroundCommands[0].type, UIDrawCommandType::PushClipRect); + EXPECT_EQ(foregroundCommands[1].type, UIDrawCommandType::Text); + EXPECT_EQ(foregroundCommands[1].text, "File"); + EXPECT_EQ(foregroundCommands[4].type, UIDrawCommandType::Text); + EXPECT_EQ(foregroundCommands[4].text, "Window"); +} + +} // namespace diff --git a/tests/UI/Editor/unit/test_ui_editor_menu_popup.cpp b/tests/UI/Editor/unit/test_ui_editor_menu_popup.cpp new file mode 100644 index 00000000..5b0817e8 --- /dev/null +++ b/tests/UI/Editor/unit/test_ui_editor_menu_popup.cpp @@ -0,0 +1,136 @@ +#include + +#include +#include + +namespace { + +using XCEngine::UI::UIDrawCommandType; +using XCEngine::UI::UIDrawList; +using XCEngine::UI::UIPoint; +using XCEngine::UI::UIRect; +using XCEngine::UI::Editor::UIEditorMenuItemKind; +using XCEngine::UI::Editor::Widgets::AppendUIEditorMenuPopupBackground; +using XCEngine::UI::Editor::Widgets::AppendUIEditorMenuPopupForeground; +using XCEngine::UI::Editor::Widgets::BuildUIEditorMenuPopupLayout; +using XCEngine::UI::Editor::Widgets::HitTestUIEditorMenuPopup; +using XCEngine::UI::Editor::Widgets::MeasureUIEditorMenuPopupHeight; +using XCEngine::UI::Editor::Widgets::ResolveUIEditorMenuPopupDesiredWidth; +using XCEngine::UI::Editor::Widgets::UIEditorMenuPopupHitTargetKind; +using XCEngine::UI::Editor::Widgets::UIEditorMenuPopupInvalidIndex; +using XCEngine::UI::Editor::Widgets::UIEditorMenuPopupItem; +using XCEngine::UI::Editor::Widgets::UIEditorMenuPopupState; + +std::vector BuildItems() { + return { + { "show-inspector", UIEditorMenuItemKind::Command, "Show Inspector", "Ctrl+I", true, true, false, 0.0f, 0.0f }, + { "separator-1", UIEditorMenuItemKind::Separator, {}, {}, false, false, false, 0.0f, 0.0f }, + { "layout", UIEditorMenuItemKind::Submenu, "Layout", {}, true, false, true, 0.0f, 0.0f }, + { "close", UIEditorMenuItemKind::Command, "Close", "Ctrl+W", false, false, false, 0.0f, 0.0f } + }; +} + +} // namespace + +TEST(UIEditorMenuPopupTest, DesiredWidthAndHeightUseLabelShortcutAndSeparatorMetrics) { + XCEngine::UI::Editor::Widgets::UIEditorMenuPopupMetrics metrics = {}; + metrics.contentPaddingX = 8.0f; + metrics.contentPaddingY = 5.0f; + metrics.labelInsetX = 12.0f; + metrics.checkColumnWidth = 20.0f; + metrics.shortcutGap = 18.0f; + metrics.shortcutInsetRight = 22.0f; + metrics.submenuIndicatorWidth = 14.0f; + metrics.estimatedGlyphWidth = 8.0f; + metrics.itemHeight = 30.0f; + metrics.separatorHeight = 10.0f; + + EXPECT_FLOAT_EQ( + ResolveUIEditorMenuPopupDesiredWidth(BuildItems(), metrics), + 248.0f); + EXPECT_FLOAT_EQ( + MeasureUIEditorMenuPopupHeight(BuildItems(), metrics), + 110.0f); +} + +TEST(UIEditorMenuPopupTest, LayoutBuildsStackedRowsAndSeparatorSlots) { + XCEngine::UI::Editor::Widgets::UIEditorMenuPopupMetrics metrics = {}; + metrics.contentPaddingX = 6.0f; + metrics.contentPaddingY = 4.0f; + metrics.itemHeight = 26.0f; + metrics.separatorHeight = 8.0f; + + const auto layout = BuildUIEditorMenuPopupLayout( + UIRect(100.0f, 50.0f, 220.0f, 94.0f), + BuildItems(), + metrics); + + EXPECT_FLOAT_EQ(layout.contentRect.x, 106.0f); + EXPECT_FLOAT_EQ(layout.contentRect.y, 54.0f); + EXPECT_FLOAT_EQ(layout.contentRect.width, 208.0f); + EXPECT_FLOAT_EQ(layout.contentRect.height, 86.0f); + + ASSERT_EQ(layout.itemRects.size(), 4u); + EXPECT_FLOAT_EQ(layout.itemRects[0].y, 54.0f); + EXPECT_FLOAT_EQ(layout.itemRects[0].height, 26.0f); + EXPECT_FLOAT_EQ(layout.itemRects[1].y, 80.0f); + EXPECT_FLOAT_EQ(layout.itemRects[1].height, 8.0f); + EXPECT_FLOAT_EQ(layout.itemRects[2].y, 88.0f); + EXPECT_FLOAT_EQ(layout.itemRects[3].y, 114.0f); +} + +TEST(UIEditorMenuPopupTest, HitTestIgnoresSeparatorsAndFallsBackToPopupSurface) { + const auto items = BuildItems(); + const auto layout = BuildUIEditorMenuPopupLayout( + UIRect(100.0f, 50.0f, 220.0f, 118.0f), + items); + + const auto itemHit = HitTestUIEditorMenuPopup(layout, items, UIPoint(130.0f, 66.0f)); + EXPECT_EQ(itemHit.kind, UIEditorMenuPopupHitTargetKind::Item); + EXPECT_EQ(itemHit.index, 0u); + + const auto separatorHit = HitTestUIEditorMenuPopup(layout, items, UIPoint(130.0f, 88.0f)); + EXPECT_EQ(separatorHit.kind, UIEditorMenuPopupHitTargetKind::PopupSurface); + EXPECT_EQ(separatorHit.index, UIEditorMenuPopupInvalidIndex); + + const auto outsideHit = HitTestUIEditorMenuPopup(layout, items, UIPoint(20.0f, 20.0f)); + EXPECT_EQ(outsideHit.kind, UIEditorMenuPopupHitTargetKind::None); +} + +TEST(UIEditorMenuPopupTest, BackgroundAndForegroundEmitStableCommands) { + const auto items = BuildItems(); + UIEditorMenuPopupState state = {}; + state.hoveredIndex = 0u; + state.submenuOpenIndex = 2u; + const auto layout = BuildUIEditorMenuPopupLayout( + UIRect(100.0f, 50.0f, 220.0f, 118.0f), + items); + + UIDrawList background("MenuPopupBackground"); + AppendUIEditorMenuPopupBackground(background, layout, items, state); + ASSERT_EQ(background.GetCommandCount(), 5u); + 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[4].type, UIDrawCommandType::FilledRect); + + UIDrawList foreground("MenuPopupForeground"); + AppendUIEditorMenuPopupForeground(foreground, layout, items, state); + ASSERT_EQ(foreground.GetCommandCount(), 13u); + const auto& foregroundCommands = foreground.GetCommands(); + EXPECT_EQ(foregroundCommands[0].type, UIDrawCommandType::Text); + EXPECT_EQ(foregroundCommands[0].text, "*"); + EXPECT_EQ(foregroundCommands[2].type, UIDrawCommandType::Text); + EXPECT_EQ(foregroundCommands[2].text, "Show Inspector"); + EXPECT_EQ(foregroundCommands[4].type, UIDrawCommandType::Text); + EXPECT_EQ(foregroundCommands[4].text, "Ctrl+I"); + EXPECT_EQ(foregroundCommands[6].type, UIDrawCommandType::Text); + EXPECT_EQ(foregroundCommands[6].text, "Layout"); + EXPECT_EQ(foregroundCommands[8].type, UIDrawCommandType::Text); + EXPECT_EQ(foregroundCommands[8].text, ">"); + EXPECT_EQ(foregroundCommands[10].type, UIDrawCommandType::Text); + EXPECT_EQ(foregroundCommands[10].text, "Close"); + EXPECT_EQ(foregroundCommands[12].type, UIDrawCommandType::Text); + EXPECT_EQ(foregroundCommands[12].text, "Ctrl+W"); +} diff --git a/tests/UI/Editor/unit/test_ui_editor_menu_session.cpp b/tests/UI/Editor/unit/test_ui_editor_menu_session.cpp index 3d547dda..3191136e 100644 --- a/tests/UI/Editor/unit/test_ui_editor_menu_session.cpp +++ b/tests/UI/Editor/unit/test_ui_editor_menu_session.cpp @@ -73,6 +73,45 @@ TEST(UIEditorMenuSessionTest, OpenMenuBarRootTracksActiveMenuAndRootPopup) { EXPECT_TRUE(session.GetOpenSubmenuItemIds().empty()); } +TEST(UIEditorMenuSessionTest, OpenRootMenuSupportsGenericRootPopupEntry) { + UIEditorMenuSession session = {}; + + const auto result = session.OpenRootMenu( + "context.scene", + MakePopup("menu.context.scene.root", "", UIInputPath{500u, 510u}, UIInputPath{5u, 6u})); + + EXPECT_TRUE(result.changed); + EXPECT_EQ(result.openRootMenuId, "context.scene"); + EXPECT_EQ(result.openedPopupId, "menu.context.scene.root"); + EXPECT_TRUE(result.closedPopupIds.empty()); + EXPECT_TRUE(session.IsMenuOpen("context.scene")); + EXPECT_TRUE(session.IsPopupOpen("menu.context.scene.root")); + ASSERT_EQ(session.GetPopupOverlayModel().GetPopupCount(), 1u); + ASSERT_EQ(session.GetPopupStates().size(), 1u); + EXPECT_TRUE(session.GetPopupStates().front().IsRootPopup()); +} + +TEST(UIEditorMenuSessionTest, OpenRootMenuRepositionsPopupWhenAnchorChanges) { + UIEditorMenuSession session = {}; + ASSERT_TRUE(session.OpenRootMenu( + "context.scene", + MakePopup("menu.context.scene.root", "", UIInputPath{500u, 510u}, UIInputPath{5u, 6u})) + .changed); + + UIPopupOverlayEntry movedPopup = + MakePopup("menu.context.scene.root", "", UIInputPath{500u, 510u}, UIInputPath{5u, 6u}); + movedPopup.anchorRect = { 320.0f, 180.0f, 1.0f, 1.0f }; + + const auto result = session.OpenRootMenu("context.scene", movedPopup); + + EXPECT_TRUE(result.changed); + EXPECT_EQ(result.openRootMenuId, "context.scene"); + EXPECT_EQ(result.openedPopupId, "menu.context.scene.root"); + ASSERT_NE(session.GetPopupOverlayModel().GetRootPopup(), nullptr); + EXPECT_FLOAT_EQ(session.GetPopupOverlayModel().GetRootPopup()->anchorRect.x, 320.0f); + EXPECT_FLOAT_EQ(session.GetPopupOverlayModel().GetRootPopup()->anchorRect.y, 180.0f); +} + TEST(UIEditorMenuSessionTest, HoverMenuBarRootReplacesOpenRootAndClearsSubmenuPath) { UIEditorMenuSession session = {}; ASSERT_TRUE(session.OpenMenuBarRoot( diff --git a/tests/UI/Editor/unit/test_ui_editor_status_bar.cpp b/tests/UI/Editor/unit/test_ui_editor_status_bar.cpp new file mode 100644 index 00000000..57c2c023 --- /dev/null +++ b/tests/UI/Editor/unit/test_ui_editor_status_bar.cpp @@ -0,0 +1,120 @@ +#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::AppendUIEditorStatusBarBackground; +using XCEngine::UI::Editor::Widgets::AppendUIEditorStatusBarForeground; +using XCEngine::UI::Editor::Widgets::BuildUIEditorStatusBarLayout; +using XCEngine::UI::Editor::Widgets::HitTestUIEditorStatusBar; +using XCEngine::UI::Editor::Widgets::ResolveUIEditorStatusBarDesiredSegmentWidth; +using XCEngine::UI::Editor::Widgets::ResolveUIEditorStatusBarTextColor; +using XCEngine::UI::Editor::Widgets::UIEditorStatusBarHitTargetKind; +using XCEngine::UI::Editor::Widgets::UIEditorStatusBarInvalidIndex; +using XCEngine::UI::Editor::Widgets::UIEditorStatusBarLayout; +using XCEngine::UI::Editor::Widgets::UIEditorStatusBarPalette; +using XCEngine::UI::Editor::Widgets::UIEditorStatusBarSegment; +using XCEngine::UI::Editor::Widgets::UIEditorStatusBarSlot; +using XCEngine::UI::Editor::Widgets::UIEditorStatusBarState; +using XCEngine::UI::Editor::Widgets::UIEditorStatusBarTextTone; + +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); +} + +std::vector BuildSegments() { + return { + { "scene", "Scene: Main", UIEditorStatusBarSlot::Leading, UIEditorStatusBarTextTone::Primary, true, true, 92.0f }, + { "selection", "Selection: Camera", UIEditorStatusBarSlot::Leading, UIEditorStatusBarTextTone::Accent, true, false, 138.0f }, + { "frame", "16.7 ms", UIEditorStatusBarSlot::Trailing, UIEditorStatusBarTextTone::Muted, true, true, 64.0f }, + { "gpu", "GPU Ready", UIEditorStatusBarSlot::Trailing, UIEditorStatusBarTextTone::Primary, true, false, 86.0f } + }; +} + +TEST(UIEditorStatusBarTest, DesiredWidthUsesExplicitValueBeforeLabelEstimate) { + UIEditorStatusBarSegment explicitWidth = {}; + explicitWidth.label = "Scene"; + explicitWidth.desiredWidth = 84.0f; + + UIEditorStatusBarSegment inferredWidth = {}; + inferredWidth.label = "Scene"; + + EXPECT_FLOAT_EQ(ResolveUIEditorStatusBarDesiredSegmentWidth(explicitWidth), 84.0f); + EXPECT_FLOAT_EQ(ResolveUIEditorStatusBarDesiredSegmentWidth(inferredWidth), 55.0f); +} + +TEST(UIEditorStatusBarTest, LayoutBuildsLeadingAndTrailingSlotsWithSeparators) { + const UIEditorStatusBarLayout layout = + BuildUIEditorStatusBarLayout(UIRect(20.0f, 40.0f, 520.0f, 28.0f), BuildSegments()); + + EXPECT_FLOAT_EQ(layout.leadingSlotRect.x, 30.0f); + EXPECT_FLOAT_EQ(layout.leadingSlotRect.width, 235.0f); + EXPECT_FLOAT_EQ(layout.trailingSlotRect.x, 371.0f); + EXPECT_FLOAT_EQ(layout.trailingSlotRect.width, 159.0f); + + EXPECT_FLOAT_EQ(layout.segmentRects[0].x, 30.0f); + EXPECT_FLOAT_EQ(layout.segmentRects[1].x, 127.0f); + EXPECT_FLOAT_EQ(layout.segmentRects[2].x, 371.0f); + EXPECT_FLOAT_EQ(layout.segmentRects[3].x, 444.0f); + + EXPECT_FLOAT_EQ(layout.separatorRects[0].x, 122.0f); + EXPECT_FLOAT_EQ(layout.separatorRects[2].x, 439.0f); + EXPECT_FLOAT_EQ(layout.separatorRects[1].width, 0.0f); +} + +TEST(UIEditorStatusBarTest, HitTestReturnsSeparatorThenSegmentThenBackground) { + const UIEditorStatusBarLayout layout = + BuildUIEditorStatusBarLayout(UIRect(20.0f, 40.0f, 520.0f, 28.0f), BuildSegments()); + + auto hit = HitTestUIEditorStatusBar(layout, UIPoint(122.5f, 54.0f)); + EXPECT_EQ(hit.kind, UIEditorStatusBarHitTargetKind::Separator); + EXPECT_EQ(hit.index, 0u); + + hit = HitTestUIEditorStatusBar(layout, UIPoint(150.0f, 54.0f)); + EXPECT_EQ(hit.kind, UIEditorStatusBarHitTargetKind::Segment); + EXPECT_EQ(hit.index, 1u); + + hit = HitTestUIEditorStatusBar(layout, UIPoint(300.0f, 54.0f)); + EXPECT_EQ(hit.kind, UIEditorStatusBarHitTargetKind::Background); + EXPECT_EQ(hit.index, UIEditorStatusBarInvalidIndex); +} + +TEST(UIEditorStatusBarTest, BackgroundAndForegroundEmitStableChromeAndTextCommands) { + const auto segments = BuildSegments(); + UIEditorStatusBarState state = {}; + state.hoveredIndex = 0u; + state.activeIndex = 1u; + state.focused = true; + + const UIEditorStatusBarPalette palette = {}; + const UIEditorStatusBarLayout layout = + BuildUIEditorStatusBarLayout(UIRect(12.0f, 16.0f, 520.0f, 28.0f), segments); + + UIDrawList background("StatusBarBackground"); + AppendUIEditorStatusBarBackground(background, layout, segments, state, palette); + ASSERT_EQ(background.GetCommandCount(), 8u); + EXPECT_EQ(background.GetCommands()[0].type, UIDrawCommandType::FilledRect); + EXPECT_EQ(background.GetCommands()[1].type, UIDrawCommandType::RectOutline); + ExpectColorEq(background.GetCommands()[1].color, palette.focusedBorderColor); + + UIDrawList foreground("StatusBarForeground"); + AppendUIEditorStatusBarForeground(foreground, layout, segments, state, palette); + ASSERT_EQ(foreground.GetCommandCount(), 4u); + EXPECT_EQ(foreground.GetCommands()[0].text, "Scene: Main"); + EXPECT_EQ(foreground.GetCommands()[1].text, "Selection: Camera"); + ExpectColorEq( + foreground.GetCommands()[1].color, + ResolveUIEditorStatusBarTextColor(UIEditorStatusBarTextTone::Accent, palette)); +} + +} // namespace