From ec06340f582f56a0c7b663155f283ffa33fdad25 Mon Sep 17 00:00:00 2001 From: ssdfasd <2156608475@qq.com> Date: Tue, 7 Apr 2026 10:16:55 +0800 Subject: [PATCH] Add editor shell interaction contract --- new_editor/CMakeLists.txt | 1 + .../XCEditor/Core/UIEditorShellInteraction.h | 139 +++ .../src/Core/UIEditorShellInteraction.cpp | 756 ++++++++++++++++ tests/UI/Editor/integration/CMakeLists.txt | 1 + tests/UI/Editor/integration/README.md | 9 + .../Editor/integration/shell/CMakeLists.txt | 3 + .../editor_shell_interaction/CMakeLists.txt | 31 + .../captures/.gitkeep | 1 + .../shell/editor_shell_interaction/main.cpp | 825 ++++++++++++++++++ tests/UI/Editor/unit/CMakeLists.txt | 1 + .../unit/test_ui_editor_shell_interaction.cpp | 545 ++++++++++++ 11 files changed, 2312 insertions(+) create mode 100644 new_editor/include/XCEditor/Core/UIEditorShellInteraction.h create mode 100644 new_editor/src/Core/UIEditorShellInteraction.cpp create mode 100644 tests/UI/Editor/integration/shell/editor_shell_interaction/CMakeLists.txt create mode 100644 tests/UI/Editor/integration/shell/editor_shell_interaction/captures/.gitkeep create mode 100644 tests/UI/Editor/integration/shell/editor_shell_interaction/main.cpp create mode 100644 tests/UI/Editor/unit/test_ui_editor_shell_interaction.cpp diff --git a/new_editor/CMakeLists.txt b/new_editor/CMakeLists.txt index fe923c5e..26b4a9ee 100644 --- a/new_editor/CMakeLists.txt +++ b/new_editor/CMakeLists.txt @@ -19,6 +19,7 @@ add_library(XCUIEditorLib STATIC src/Core/UIEditorMenuSession.cpp src/Core/UIEditorPanelRegistry.cpp src/Core/UIEditorShellCompose.cpp + src/Core/UIEditorShellInteraction.cpp src/Core/UIEditorShortcutManager.cpp src/Core/UIEditorViewportInputBridge.cpp src/Core/UIEditorViewportShell.cpp diff --git a/new_editor/include/XCEditor/Core/UIEditorShellInteraction.h b/new_editor/include/XCEditor/Core/UIEditorShellInteraction.h new file mode 100644 index 00000000..27366ac2 --- /dev/null +++ b/new_editor/include/XCEditor/Core/UIEditorShellInteraction.h @@ -0,0 +1,139 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include + +#include +#include + +namespace XCEngine::UI::Editor { + +struct UIEditorShellInteractionModel { + UIEditorResolvedMenuModel resolvedMenuModel = {}; + std::vector statusSegments = {}; + std::vector workspacePresentations = {}; +}; + +struct UIEditorShellInteractionState { + UIEditorShellComposeState composeState = {}; + UIEditorMenuSession menuSession = {}; + ::XCEngine::UI::UIPoint pointerPosition = {}; + bool focused = false; + bool hasPointerPosition = false; +}; + +struct UIEditorShellInteractionMetrics { + UIEditorShellComposeMetrics shellMetrics = {}; + Widgets::UIEditorMenuPopupMetrics popupMetrics = {}; +}; + +struct UIEditorShellInteractionPalette { + UIEditorShellComposePalette shellPalette = {}; + Widgets::UIEditorMenuPopupPalette popupPalette = {}; +}; + +struct UIEditorShellInteractionMenuButtonRequest { + std::string menuId = {}; + std::string label = {}; + std::string popupId = {}; + ::XCEngine::UI::UIRect rect = {}; + ::XCEngine::UI::UIInputPath path = {}; + bool enabled = true; +}; + +struct UIEditorShellInteractionPopupItemRequest { + std::string popupId = {}; + std::string menuId = {}; + std::string itemId = {}; + std::string label = {}; + std::string commandId = {}; + std::string childPopupId = {}; + ::XCEngine::UI::UIRect rect = {}; + ::XCEngine::UI::UIInputPath path = {}; + UIEditorMenuItemKind kind = UIEditorMenuItemKind::Command; + bool enabled = false; + bool checked = false; + bool hasSubmenu = false; +}; + +struct UIEditorShellInteractionPopupRequest { + std::string popupId = {}; + std::string menuId = {}; + std::string sourceItemId = {}; + ::XCEngine::UI::Widgets::UIPopupOverlayEntry overlayEntry = {}; + ::XCEngine::UI::Widgets::UIPopupPlacementResult placement = {}; + Widgets::UIEditorMenuPopupLayout layout = {}; + std::vector resolvedItems = {}; + std::vector widgetItems = {}; + std::vector itemRequests = {}; +}; + +struct UIEditorShellInteractionRequest { + UIEditorShellComposeRequest shellRequest = {}; + std::vector menuBarItems = {}; + std::vector menuButtons = {}; + std::vector popupRequests = {}; +}; + +struct UIEditorShellInteractionResult { + bool consumed = false; + bool commandTriggered = false; + std::string menuId = {}; + std::string popupId = {}; + std::string itemId = {}; + std::string commandId = {}; + UIEditorMenuSessionMutationResult menuMutation = {}; +}; + +struct UIEditorShellInteractionPopupFrame { + std::string popupId = {}; + Widgets::UIEditorMenuPopupState popupState = {}; +}; + +struct UIEditorShellInteractionFrame { + UIEditorShellInteractionRequest request = {}; + UIEditorShellComposeFrame shellFrame = {}; + std::vector popupFrames = {}; + UIEditorShellInteractionResult result = {}; + std::string openRootMenuId = {}; + std::string hoveredMenuId = {}; + std::string hoveredPopupId = {}; + std::string hoveredItemId = {}; + bool focused = false; +}; + +UIEditorShellInteractionRequest ResolveUIEditorShellInteractionRequest( + const ::XCEngine::UI::UIRect& bounds, + const UIEditorPanelRegistry& panelRegistry, + const UIEditorWorkspaceModel& workspace, + const UIEditorWorkspaceSession& session, + const UIEditorShellInteractionModel& model, + const Widgets::UIEditorDockHostState& dockHostState = {}, + const UIEditorShellInteractionState& state = {}, + const UIEditorShellInteractionMetrics& metrics = {}); + +UIEditorShellInteractionFrame UpdateUIEditorShellInteraction( + UIEditorShellInteractionState& state, + const ::XCEngine::UI::UIRect& bounds, + const UIEditorPanelRegistry& panelRegistry, + const UIEditorWorkspaceModel& workspace, + const UIEditorWorkspaceSession& session, + const UIEditorShellInteractionModel& model, + const std::vector<::XCEngine::UI::UIInputEvent>& inputEvents, + const Widgets::UIEditorDockHostState& dockHostState = {}, + const UIEditorShellInteractionMetrics& metrics = {}); + +void AppendUIEditorShellInteraction( + ::XCEngine::UI::UIDrawList& drawList, + const UIEditorShellInteractionFrame& frame, + const UIEditorShellInteractionModel& model, + const UIEditorShellInteractionState& state, + const UIEditorShellInteractionPalette& palette = {}, + const UIEditorShellInteractionMetrics& metrics = {}); + +} // namespace XCEngine::UI::Editor diff --git a/new_editor/src/Core/UIEditorShellInteraction.cpp b/new_editor/src/Core/UIEditorShellInteraction.cpp new file mode 100644 index 00000000..cf2324ba --- /dev/null +++ b/new_editor/src/Core/UIEditorShellInteraction.cpp @@ -0,0 +1,756 @@ +#include + +#include +#include + +#include +#include +#include +#include + +namespace XCEngine::UI::Editor { + +namespace { + +using XCEngine::Input::KeyCode; +using ::XCEngine::UI::UIElementId; +using ::XCEngine::UI::UIInputEvent; +using ::XCEngine::UI::UIInputEventType; +using ::XCEngine::UI::UIInputPath; +using ::XCEngine::UI::UIPoint; +using ::XCEngine::UI::UIRect; +using ::XCEngine::UI::UISize; +using ::XCEngine::UI::Widgets::ResolvePopupPlacementRect; +using ::XCEngine::UI::Widgets::UIPopupOverlayEntry; +using ::XCEngine::UI::Widgets::UIPopupPlacement; +using Widgets::AppendUIEditorMenuPopupBackground; +using Widgets::AppendUIEditorMenuPopupForeground; +using Widgets::BuildUIEditorMenuBarLayout; +using Widgets::BuildUIEditorMenuPopupLayout; +using Widgets::HitTestUIEditorMenuBar; +using Widgets::HitTestUIEditorMenuPopup; +using Widgets::MeasureUIEditorMenuPopupHeight; +using Widgets::ResolveUIEditorMenuPopupDesiredWidth; +using Widgets::UIEditorMenuBarHitTargetKind; +using Widgets::UIEditorMenuBarInvalidIndex; +using Widgets::UIEditorMenuPopupHitTargetKind; +using Widgets::UIEditorMenuPopupInvalidIndex; + +constexpr UIElementId kShellPathRoot = 0x1E1001ull; +constexpr UIElementId kMenuBarPathRoot = 0x1E1002ull; +constexpr UIElementId kPopupPathRoot = 0x1E1003ull; +constexpr UIElementId kMenuItemPathRoot = 0x1E1004ull; +constexpr UIElementId kOutsidePointerPath = 0x1E10FFull; + +struct RequestHit { + const UIEditorShellInteractionMenuButtonRequest* menuButton = nullptr; + const UIEditorShellInteractionPopupRequest* popupRequest = nullptr; + const UIEditorShellInteractionPopupItemRequest* popupItem = nullptr; +}; + +struct BuildRequestOutput { + UIEditorShellInteractionRequest request = {}; + bool hadInvalidPopupState = false; +}; + +UIElementId HashText(std::string_view text) { + std::uint64_t hash = 1469598103934665603ull; + for (const unsigned char value : text) { + hash ^= value; + hash *= 1099511628211ull; + } + + hash &= 0x7FFFFFFFFFFFFFFFull; + return hash == 0u ? 1u : hash; +} + +std::string BuildRootPopupId(std::string_view menuId) { + return "editor.menu.root." + std::string(menuId); +} + +std::string BuildSubmenuPopupId(std::string_view popupId, std::string_view itemId) { + return std::string(popupId) + ".child." + std::string(itemId); +} + +UIInputPath BuildMenuButtonPath(std::string_view menuId) { + return UIInputPath { kShellPathRoot, kMenuBarPathRoot, HashText(menuId) }; +} + +UIInputPath BuildPopupSurfacePath(std::string_view popupId) { + return UIInputPath { kShellPathRoot, kPopupPathRoot, HashText(popupId) }; +} + +UIInputPath BuildMenuItemPath(std::string_view popupId, std::string_view itemId) { + return UIInputPath { + kShellPathRoot, + kPopupPathRoot, + HashText(popupId), + kMenuItemPathRoot, + HashText(itemId) + }; +} + +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* child = + FindResolvedMenuItemRecursive(item.children, itemId); + child != nullptr) { + return child; + } + } + } + + 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.itemId.empty()) { + 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 BuildMenuBarItems( + const UIEditorResolvedMenuModel& model) { + std::vector items = {}; + items.reserve(model.menus.size()); + + for (const UIEditorResolvedMenuDescriptor& menu : model.menus) { + Widgets::UIEditorMenuBarItem item = {}; + item.menuId = menu.menuId; + item.label = menu.label; + item.enabled = !menu.items.empty(); + items.push_back(std::move(item)); + } + + return items; +} + +UIEditorShellComposeModel BuildShellComposeModel( + const UIEditorShellInteractionModel& model, + const std::vector& menuBarItems) { + UIEditorShellComposeModel shellModel = {}; + shellModel.menuBarItems = menuBarItems; + shellModel.statusSegments = model.statusSegments; + shellModel.workspacePresentations = model.workspacePresentations; + return shellModel; +} + +std::vector BuildPopupWidgetItems( + const std::vector& items) { + std::vector widgetItems = {}; + widgetItems.reserve(items.size()); + + for (const UIEditorResolvedMenuItem& item : items) { + Widgets::UIEditorMenuPopupItem widgetItem = {}; + widgetItem.itemId = item.itemId; + widgetItem.kind = item.kind; + widgetItem.label = item.label; + widgetItem.shortcutText = item.shortcutText; + widgetItem.enabled = item.enabled; + widgetItem.checked = item.checked; + widgetItem.hasSubmenu = item.kind == UIEditorMenuItemKind::Submenu && !item.children.empty(); + widgetItems.push_back(std::move(widgetItem)); + } + + return widgetItems; +} + +std::size_t FindMenuBarIndex( + 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 UIEditorMenuBarInvalidIndex; +} + +std::size_t FindPopupItemIndex( + 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; +} + +bool HasMeaningfulInteractionResult( + const UIEditorShellInteractionResult& result) { + return result.consumed || + result.commandTriggered || + result.menuMutation.changed || + !result.menuId.empty() || + !result.popupId.empty() || + !result.itemId.empty() || + !result.commandId.empty(); +} + +BuildRequestOutput BuildRequest( + const UIRect& bounds, + const UIEditorPanelRegistry& panelRegistry, + const UIEditorWorkspaceModel& workspace, + const UIEditorWorkspaceSession& session, + const UIEditorShellInteractionModel& model, + const Widgets::UIEditorDockHostState& dockHostState, + const UIEditorShellInteractionState& state, + const UIEditorShellInteractionMetrics& metrics) { + BuildRequestOutput output = {}; + UIEditorShellInteractionRequest& request = output.request; + request.menuBarItems = BuildMenuBarItems(model.resolvedMenuModel); + + const UIEditorShellComposeModel shellModel = + BuildShellComposeModel(model, request.menuBarItems); + request.shellRequest = ResolveUIEditorShellComposeRequest( + bounds, + panelRegistry, + workspace, + session, + shellModel, + dockHostState, + state.composeState, + metrics.shellMetrics); + + request.menuButtons.reserve(request.menuBarItems.size()); + for (std::size_t index = 0; index < request.menuBarItems.size(); ++index) { + UIEditorShellInteractionMenuButtonRequest button = {}; + button.menuId = request.menuBarItems[index].menuId; + button.label = request.menuBarItems[index].label; + button.popupId = BuildRootPopupId(button.menuId); + button.enabled = request.menuBarItems[index].enabled; + if (index < request.shellRequest.layout.menuBarLayout.buttonRects.size()) { + button.rect = request.shellRequest.layout.menuBarLayout.buttonRects[index]; + } + button.path = BuildMenuButtonPath(button.menuId); + request.menuButtons.push_back(std::move(button)); + } + + const auto& popupStates = state.menuSession.GetPopupStates(); + request.popupRequests.reserve(popupStates.size()); + for (const UIEditorMenuPopupState& popupState : popupStates) { + const UIPopupOverlayEntry* overlayEntry = + state.menuSession.GetPopupOverlayModel().FindPopup(popupState.popupId); + const std::vector* resolvedItems = + ResolvePopupItems(model.resolvedMenuModel, popupState); + if (overlayEntry == nullptr || resolvedItems == nullptr) { + output.hadInvalidPopupState = true; + continue; + } + + UIEditorShellInteractionPopupRequest popupRequest = {}; + popupRequest.popupId = popupState.popupId; + popupRequest.menuId = popupState.menuId; + popupRequest.sourceItemId = popupState.itemId; + popupRequest.overlayEntry = *overlayEntry; + popupRequest.resolvedItems = *resolvedItems; + popupRequest.widgetItems = BuildPopupWidgetItems(popupRequest.resolvedItems); + + const float popupWidth = + ResolveUIEditorMenuPopupDesiredWidth(popupRequest.widgetItems, metrics.popupMetrics); + const float popupHeight = + MeasureUIEditorMenuPopupHeight(popupRequest.widgetItems, metrics.popupMetrics); + popupRequest.placement = ResolvePopupPlacementRect( + overlayEntry->anchorRect, + UISize(popupWidth, popupHeight), + request.shellRequest.layout.bounds, + overlayEntry->placement); + popupRequest.layout = + BuildUIEditorMenuPopupLayout(popupRequest.placement.rect, popupRequest.widgetItems, metrics.popupMetrics); + + popupRequest.itemRequests.reserve(popupRequest.resolvedItems.size()); + for (std::size_t index = 0; index < popupRequest.resolvedItems.size(); ++index) { + const UIEditorResolvedMenuItem& resolvedItem = popupRequest.resolvedItems[index]; + UIEditorShellInteractionPopupItemRequest itemRequest = {}; + itemRequest.popupId = popupRequest.popupId; + itemRequest.menuId = popupRequest.menuId; + itemRequest.itemId = resolvedItem.itemId; + itemRequest.label = resolvedItem.label; + itemRequest.commandId = resolvedItem.commandId; + itemRequest.kind = resolvedItem.kind; + itemRequest.enabled = resolvedItem.enabled; + itemRequest.checked = resolvedItem.checked; + itemRequest.hasSubmenu = + resolvedItem.kind == UIEditorMenuItemKind::Submenu && + !resolvedItem.children.empty(); + if (itemRequest.hasSubmenu) { + itemRequest.childPopupId = + BuildSubmenuPopupId(popupRequest.popupId, itemRequest.itemId); + } + if (index < popupRequest.layout.itemRects.size()) { + itemRequest.rect = popupRequest.layout.itemRects[index]; + } + itemRequest.path = BuildMenuItemPath(popupRequest.popupId, itemRequest.itemId); + popupRequest.itemRequests.push_back(std::move(itemRequest)); + } + + request.popupRequests.push_back(std::move(popupRequest)); + } + + return output; +} + +RequestHit HitTestRequest( + const UIEditorShellInteractionRequest& request, + const UIPoint& point, + bool hasPointerPosition) { + RequestHit hit = {}; + if (!hasPointerPosition) { + return hit; + } + + for (std::size_t index = request.popupRequests.size(); index > 0u; --index) { + const UIEditorShellInteractionPopupRequest& popupRequest = + request.popupRequests[index - 1u]; + const auto popupHit = + HitTestUIEditorMenuPopup(popupRequest.layout, popupRequest.widgetItems, point); + if (popupHit.kind == UIEditorMenuPopupHitTargetKind::Item && + popupHit.index < popupRequest.itemRequests.size()) { + hit.popupRequest = &popupRequest; + hit.popupItem = &popupRequest.itemRequests[popupHit.index]; + return hit; + } + if (popupHit.kind == UIEditorMenuPopupHitTargetKind::PopupSurface) { + hit.popupRequest = &popupRequest; + return hit; + } + } + + const auto menuHit = + HitTestUIEditorMenuBar(request.shellRequest.layout.menuBarLayout, point); + if (menuHit.kind == UIEditorMenuBarHitTargetKind::Button && + menuHit.index < request.menuButtons.size()) { + hit.menuButton = &request.menuButtons[menuHit.index]; + } + + return hit; +} + +UIPopupOverlayEntry BuildRootPopupEntry( + const UIEditorShellInteractionMenuButtonRequest& button) { + UIPopupOverlayEntry entry = {}; + entry.popupId = button.popupId; + entry.anchorRect = button.rect; + entry.anchorPath = button.path; + entry.surfacePath = BuildPopupSurfacePath(button.popupId); + entry.placement = UIPopupPlacement::BottomStart; + return entry; +} + +UIPopupOverlayEntry BuildSubmenuPopupEntry( + const UIEditorShellInteractionPopupItemRequest& item) { + 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 UpdateMenuBarVisualState( + UIEditorShellInteractionState& state, + const UIEditorShellInteractionRequest& request, + const RequestHit& hit) { + state.composeState.menuBarState.openIndex = FindMenuBarIndex( + request.menuBarItems, + state.menuSession.GetOpenRootMenuId()); + state.composeState.menuBarState.hoveredIndex = + hit.menuButton != nullptr + ? FindMenuBarIndex(request.menuBarItems, hit.menuButton->menuId) + : UIEditorMenuBarInvalidIndex; + state.composeState.menuBarState.focused = + state.focused || state.menuSession.HasOpenMenu(); +} + +std::vector BuildPopupFrames( + const UIEditorShellInteractionRequest& request, + const UIEditorShellInteractionState& state, + std::string_view hoveredPopupId, + std::string_view hoveredItemId) { + std::vector popupFrames = {}; + popupFrames.reserve(request.popupRequests.size()); + + for (std::size_t index = 0; index < request.popupRequests.size(); ++index) { + const UIEditorShellInteractionPopupRequest& popupRequest = + request.popupRequests[index]; + UIEditorShellInteractionPopupFrame popupFrame = {}; + popupFrame.popupId = popupRequest.popupId; + popupFrame.popupState.focused = state.focused || state.menuSession.HasOpenMenu(); + popupFrame.popupState.hoveredIndex = + popupRequest.popupId == hoveredPopupId + ? FindPopupItemIndex(popupRequest.itemRequests, hoveredItemId) + : UIEditorMenuPopupInvalidIndex; + if (index + 1u < request.popupRequests.size()) { + popupFrame.popupState.submenuOpenIndex = FindPopupItemIndex( + popupRequest.itemRequests, + request.popupRequests[index + 1u].sourceItemId); + } + popupFrames.push_back(std::move(popupFrame)); + } + + return popupFrames; +} + +bool ShouldUsePointerPosition(const UIInputEvent& event) { + switch (event.type) { + case UIInputEventType::PointerMove: + case UIInputEventType::PointerEnter: + case UIInputEventType::PointerButtonDown: + case UIInputEventType::PointerButtonUp: + case UIInputEventType::PointerWheel: + return true; + default: + return false; + } +} + +std::vector FilterComposeInputEvents( + const std::vector& inputEvents, + bool menuModalDuringFrame) { + if (!menuModalDuringFrame) { + return inputEvents; + } + + std::vector filtered = {}; + for (const UIInputEvent& event : inputEvents) { + if (event.type == UIInputEventType::FocusGained || + event.type == UIInputEventType::FocusLost) { + filtered.push_back(event); + } + } + + return filtered; +} + +} // namespace + +UIEditorShellInteractionRequest ResolveUIEditorShellInteractionRequest( + const UIRect& bounds, + const UIEditorPanelRegistry& panelRegistry, + const UIEditorWorkspaceModel& workspace, + const UIEditorWorkspaceSession& session, + const UIEditorShellInteractionModel& model, + const Widgets::UIEditorDockHostState& dockHostState, + const UIEditorShellInteractionState& state, + const UIEditorShellInteractionMetrics& metrics) { + return BuildRequest( + bounds, + panelRegistry, + workspace, + session, + model, + dockHostState, + state, + metrics).request; +} + +UIEditorShellInteractionFrame UpdateUIEditorShellInteraction( + UIEditorShellInteractionState& state, + const UIRect& bounds, + const UIEditorPanelRegistry& panelRegistry, + const UIEditorWorkspaceModel& workspace, + const UIEditorWorkspaceSession& session, + const UIEditorShellInteractionModel& model, + const std::vector& inputEvents, + const Widgets::UIEditorDockHostState& dockHostState, + const UIEditorShellInteractionMetrics& metrics) { + UIEditorShellInteractionResult interactionResult = {}; + bool menuModalDuringFrame = state.menuSession.HasOpenMenu(); + + BuildRequestOutput requestBuild = BuildRequest( + bounds, + panelRegistry, + workspace, + session, + model, + dockHostState, + state, + metrics); + UIEditorShellInteractionRequest request = std::move(requestBuild.request); + + if (requestBuild.hadInvalidPopupState && state.menuSession.HasOpenMenu()) { + interactionResult.menuMutation = state.menuSession.CloseAll(); + interactionResult.consumed = interactionResult.menuMutation.changed; + menuModalDuringFrame = + interactionResult.menuMutation.changed || state.menuSession.HasOpenMenu(); + + requestBuild = BuildRequest( + bounds, + panelRegistry, + workspace, + session, + model, + dockHostState, + state, + metrics); + request = std::move(requestBuild.request); + } + + for (const UIInputEvent& event : inputEvents) { + UIEditorShellInteractionResult eventResult = {}; + + if (ShouldUsePointerPosition(event)) { + state.pointerPosition = event.position; + state.hasPointerPosition = true; + } else if (event.type == UIInputEventType::PointerLeave) { + state.hasPointerPosition = false; + } + + const RequestHit hit = + HitTestRequest(request, state.pointerPosition, state.hasPointerPosition); + + switch (event.type) { + case UIInputEventType::FocusGained: + state.focused = true; + break; + + case UIInputEventType::FocusLost: + state.focused = false; + if (state.menuSession.HasOpenMenu()) { + eventResult.menuMutation = + state.menuSession.DismissFromFocusLoss({}); + eventResult.consumed = eventResult.menuMutation.changed; + } + break; + + case UIInputEventType::PointerMove: + case UIInputEventType::PointerEnter: + if (state.menuSession.HasOpenMenu()) { + if (hit.menuButton != nullptr && hit.menuButton->enabled) { + eventResult.menuId = hit.menuButton->menuId; + if (!state.menuSession.IsMenuOpen(hit.menuButton->menuId)) { + eventResult.menuMutation = + state.menuSession.HoverMenuBarRoot( + hit.menuButton->menuId, + BuildRootPopupEntry(*hit.menuButton)); + } else { + eventResult.menuMutation = + state.menuSession.DismissFromFocusLoss(hit.menuButton->path); + } + } else if (hit.popupItem != nullptr) { + eventResult.menuId = hit.popupItem->menuId; + eventResult.popupId = hit.popupItem->popupId; + eventResult.itemId = hit.popupItem->itemId; + if (hit.popupItem->hasSubmenu && hit.popupItem->enabled) { + eventResult.menuMutation = + state.menuSession.HoverSubmenu( + hit.popupItem->itemId, + BuildSubmenuPopupEntry(*hit.popupItem)); + } else { + eventResult.menuMutation = + state.menuSession.DismissFromFocusLoss(hit.popupItem->path); + } + } else if (hit.popupRequest != nullptr) { + eventResult.menuId = hit.popupRequest->menuId; + eventResult.popupId = hit.popupRequest->popupId; + eventResult.menuMutation = + state.menuSession.DismissFromFocusLoss( + hit.popupRequest->overlayEntry.surfacePath); + } + + if (eventResult.menuMutation.changed) { + eventResult.consumed = true; + } + } + break; + + case UIInputEventType::PointerButtonDown: + if (event.pointerButton != ::XCEngine::UI::UIPointerButton::Left) { + break; + } + + if (hit.menuButton != nullptr && hit.menuButton->enabled) { + state.focused = true; + eventResult.consumed = true; + eventResult.menuId = hit.menuButton->menuId; + if (state.menuSession.IsMenuOpen(hit.menuButton->menuId)) { + eventResult.menuMutation = state.menuSession.CloseAll(); + } else { + eventResult.menuMutation = + state.menuSession.OpenMenuBarRoot( + hit.menuButton->menuId, + BuildRootPopupEntry(*hit.menuButton)); + } + } else if (hit.popupItem != nullptr) { + state.focused = true; + eventResult.consumed = true; + eventResult.menuId = hit.popupItem->menuId; + eventResult.popupId = hit.popupItem->popupId; + eventResult.itemId = hit.popupItem->itemId; + if (hit.popupItem->hasSubmenu && hit.popupItem->enabled) { + eventResult.menuMutation = + state.menuSession.HoverSubmenu( + hit.popupItem->itemId, + BuildSubmenuPopupEntry(*hit.popupItem)); + } else if (hit.popupItem->enabled) { + eventResult.commandTriggered = true; + eventResult.commandId = hit.popupItem->commandId; + eventResult.menuMutation = state.menuSession.CloseAll(); + } else { + eventResult.menuMutation = + state.menuSession.DismissFromPointerDown(hit.popupItem->path); + } + } else if (hit.popupRequest != nullptr) { + eventResult.consumed = true; + eventResult.menuId = hit.popupRequest->menuId; + eventResult.popupId = hit.popupRequest->popupId; + eventResult.menuMutation = + state.menuSession.DismissFromPointerDown( + hit.popupRequest->overlayEntry.surfacePath); + } else if (state.menuSession.HasOpenMenu()) { + eventResult.consumed = true; + eventResult.menuMutation = + state.menuSession.DismissFromPointerDown(UIInputPath { kOutsidePointerPath }); + } + break; + + case UIInputEventType::KeyDown: + if (event.keyCode == static_cast(KeyCode::Escape) && + state.menuSession.HasOpenMenu()) { + eventResult.consumed = true; + eventResult.menuMutation = state.menuSession.DismissFromEscape(); + } + break; + + default: + break; + } + + if (HasMeaningfulInteractionResult(eventResult)) { + interactionResult = std::move(eventResult); + } + + if (interactionResult.menuMutation.changed || state.menuSession.HasOpenMenu()) { + menuModalDuringFrame = true; + request = BuildRequest( + bounds, + panelRegistry, + workspace, + session, + model, + dockHostState, + state, + metrics).request; + } + } + + const RequestHit finalHit = + HitTestRequest(request, state.pointerPosition, state.hasPointerPosition); + UpdateMenuBarVisualState(state, request, finalHit); + + const UIEditorShellComposeModel shellModel = + BuildShellComposeModel(model, request.menuBarItems); + const std::vector composeInputEvents = + FilterComposeInputEvents(inputEvents, menuModalDuringFrame); + + UIEditorShellInteractionFrame frame = {}; + frame.request = request; + frame.shellFrame = UpdateUIEditorShellCompose( + state.composeState, + bounds, + panelRegistry, + workspace, + session, + shellModel, + composeInputEvents, + dockHostState, + metrics.shellMetrics); + frame.popupFrames = BuildPopupFrames( + frame.request, + state, + finalHit.popupRequest != nullptr ? finalHit.popupRequest->popupId : std::string_view(), + finalHit.popupItem != nullptr ? finalHit.popupItem->itemId : std::string_view()); + frame.result = interactionResult; + frame.openRootMenuId = std::string(state.menuSession.GetOpenRootMenuId()); + frame.hoveredMenuId = + finalHit.menuButton != nullptr ? finalHit.menuButton->menuId : std::string(); + frame.hoveredPopupId = + finalHit.popupRequest != nullptr ? finalHit.popupRequest->popupId : std::string(); + frame.hoveredItemId = + finalHit.popupItem != nullptr ? finalHit.popupItem->itemId : std::string(); + frame.focused = state.focused || state.menuSession.HasOpenMenu(); + return frame; +} + +void AppendUIEditorShellInteraction( + ::XCEngine::UI::UIDrawList& drawList, + const UIEditorShellInteractionFrame& frame, + const UIEditorShellInteractionModel& model, + const UIEditorShellInteractionState& state, + const UIEditorShellInteractionPalette& palette, + const UIEditorShellInteractionMetrics& metrics) { + const UIEditorShellComposeModel shellModel = + BuildShellComposeModel(model, frame.request.menuBarItems); + AppendUIEditorShellCompose( + drawList, + frame.shellFrame, + shellModel, + state.composeState, + palette.shellPalette, + metrics.shellMetrics); + + const std::size_t popupCount = + (std::min)(frame.request.popupRequests.size(), frame.popupFrames.size()); + for (std::size_t index = 0; index < popupCount; ++index) { + const UIEditorShellInteractionPopupRequest& popupRequest = + frame.request.popupRequests[index]; + const UIEditorShellInteractionPopupFrame& popupFrame = + frame.popupFrames[index]; + AppendUIEditorMenuPopupBackground( + drawList, + popupRequest.layout, + popupRequest.widgetItems, + popupFrame.popupState, + palette.popupPalette, + metrics.popupMetrics); + AppendUIEditorMenuPopupForeground( + drawList, + popupRequest.layout, + popupRequest.widgetItems, + popupFrame.popupState, + palette.popupPalette, + metrics.popupMetrics); + } +} + +} // namespace XCEngine::UI::Editor diff --git a/tests/UI/Editor/integration/CMakeLists.txt b/tests/UI/Editor/integration/CMakeLists.txt index 20b85a9e..6b16377e 100644 --- a/tests/UI/Editor/integration/CMakeLists.txt +++ b/tests/UI/Editor/integration/CMakeLists.txt @@ -7,6 +7,7 @@ add_subdirectory(state) set(EDITOR_UI_INTEGRATION_TARGETS editor_ui_workspace_shell_compose_validation editor_ui_editor_shell_compose_validation + editor_ui_editor_shell_interaction_validation editor_ui_menu_bar_basic_validation editor_ui_panel_frame_basic_validation editor_ui_tab_strip_basic_validation diff --git a/tests/UI/Editor/integration/README.md b/tests/UI/Editor/integration/README.md index 4d8cb856..f401aa8c 100644 --- a/tests/UI/Editor/integration/README.md +++ b/tests/UI/Editor/integration/README.md @@ -16,6 +16,7 @@ Layout: - `shared/`: shared host wrapper, scenario registry, shared theme - `shell/workspace_shell_compose/`: split/tab/panel shell compose only - `shell/editor_shell_compose/`: editor root shell compose only +- `shell/editor_shell_interaction/`: editor root shell interaction 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 @@ -41,6 +42,11 @@ Scenarios: Executable: `XCUIEditorShellComposeValidation.exe` Scope: root shell compose only; MenuBar / WorkspaceCompose / StatusBar three-band layout and workspace body embedding +- `editor.shell.editor_shell_interaction` + Build target: `editor_ui_editor_shell_interaction_validation` + Executable: `XCUIEditorShellInteractionValidation.exe` + Scope: root shell interaction only; menu bar root switching, submenu hover chain, outside/Esc dismiss, command hook, and workspace input shielding + - `editor.shell.menu_bar_basic` Build target: `editor_ui_menu_bar_basic_validation` Executable: `XCUIEditorMenuBarBasicValidation.exe` @@ -120,6 +126,9 @@ Selected controls: - `shell/editor_shell_compose/` Click `切到 Scene / 切到 Document`, toggle `TopBar / BottomBar / Texture`, inspect `MenuBar Rect / Workspace Rect / StatusBar Rect / Selected Presentation / Request Size`, press `Reset`, press `截图` or `F12`. +- `shell/editor_shell_interaction/` + Click `File / Window`, hover `Workspace Tools`, click outside the menu or press `Esc`, trigger a menu command, inspect `Open Root / Popup Chain / Submenu Path / Result / Active Panel / Visible Panels`, press `Reset`, `Capture`, or `F12`. + - `shell/menu_bar_basic/` Click `File / Window / Layout`, move the mouse across menu items, click outside the menu or press `Esc`, press `F12`. diff --git a/tests/UI/Editor/integration/shell/CMakeLists.txt b/tests/UI/Editor/integration/shell/CMakeLists.txt index 2d54b929..49e54bdb 100644 --- a/tests/UI/Editor/integration/shell/CMakeLists.txt +++ b/tests/UI/Editor/integration/shell/CMakeLists.txt @@ -4,6 +4,9 @@ add_subdirectory(tab_strip_basic) if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/editor_shell_compose/CMakeLists.txt") add_subdirectory(editor_shell_compose) endif() +if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/editor_shell_interaction/CMakeLists.txt") + add_subdirectory(editor_shell_interaction editor_shell_interaction_build) +endif() if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/menu_bar_basic/CMakeLists.txt") add_subdirectory(menu_bar_basic) endif() diff --git a/tests/UI/Editor/integration/shell/editor_shell_interaction/CMakeLists.txt b/tests/UI/Editor/integration/shell/editor_shell_interaction/CMakeLists.txt new file mode 100644 index 00000000..0f7c3d23 --- /dev/null +++ b/tests/UI/Editor/integration/shell/editor_shell_interaction/CMakeLists.txt @@ -0,0 +1,31 @@ +add_executable(editor_ui_editor_shell_interaction_validation WIN32 + main.cpp +) + +target_include_directories(editor_ui_editor_shell_interaction_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_editor_shell_interaction_validation PRIVATE + UNICODE + _UNICODE + XCENGINE_EDITOR_UI_TESTS_REPO_ROOT="${XCENGINE_EDITOR_UI_TESTS_REPO_ROOT_PATH}" +) + +if(MSVC) + target_compile_options(editor_ui_editor_shell_interaction_validation PRIVATE /utf-8 /FS) + set_property(TARGET editor_ui_editor_shell_interaction_validation PROPERTY + MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>DLL") +endif() + +target_link_libraries(editor_ui_editor_shell_interaction_validation PRIVATE + XCUIEditorLib + XCUIEditorHost +) + +set_target_properties(editor_ui_editor_shell_interaction_validation PROPERTIES + OUTPUT_NAME "XCUIEditorShellInteractionValidation" +) diff --git a/tests/UI/Editor/integration/shell/editor_shell_interaction/captures/.gitkeep b/tests/UI/Editor/integration/shell/editor_shell_interaction/captures/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/tests/UI/Editor/integration/shell/editor_shell_interaction/captures/.gitkeep @@ -0,0 +1 @@ + diff --git a/tests/UI/Editor/integration/shell/editor_shell_interaction/main.cpp b/tests/UI/Editor/integration/shell/editor_shell_interaction/main.cpp new file mode 100644 index 00000000..bc8cf248 --- /dev/null +++ b/tests/UI/Editor/integration/shell/editor_shell_interaction/main.cpp @@ -0,0 +1,825 @@ +#ifndef NOMINMAX +#define NOMINMAX +#endif + +#include +#include +#include +#include +#include +#include "Host/AutoScreenshot.h" +#include "Host/NativeRenderer.h" + +#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::UIColor; +using XCEngine::UI::UIDrawData; +using XCEngine::UI::UIDrawList; +using XCEngine::UI::UIInputEvent; +using XCEngine::UI::UIInputEventType; +using XCEngine::UI::UIPoint; +using XCEngine::UI::UIPointerButton; +using XCEngine::UI::UIRect; +using XCEngine::UI::Editor::AppendUIEditorShellInteraction; +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::GetUIEditorCommandDispatchStatusName; +using XCEngine::UI::Editor::UpdateUIEditorShellInteraction; +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::UIEditorPanelPresentationKind; +using XCEngine::UI::Editor::UIEditorPanelRegistry; +using XCEngine::UI::Editor::UIEditorShellInteractionFrame; +using XCEngine::UI::Editor::UIEditorShellInteractionModel; +using XCEngine::UI::Editor::UIEditorShellInteractionResult; +using XCEngine::UI::Editor::UIEditorShellInteractionState; +using XCEngine::UI::Editor::UIEditorWorkspaceCommandKind; +using XCEngine::UI::Editor::UIEditorWorkspaceController; +using XCEngine::UI::Editor::UIEditorWorkspaceModel; +using XCEngine::UI::Editor::UIEditorWorkspacePanelPresentationModel; +using XCEngine::UI::Editor::UIEditorWorkspaceSession; +using XCEngine::UI::Editor::UIEditorWorkspaceSplitAxis; +using XCEngine::UI::Editor::Widgets::UIEditorStatusBarSegment; +using XCEngine::UI::Editor::Widgets::UIEditorStatusBarSlot; +using XCEngine::UI::Widgets::UIPopupDismissReason; +using XCEngine::UI::Editor::Host::AutoScreenshotController; +using XCEngine::UI::Editor::Host::NativeRenderer; + +constexpr const wchar_t* kWindowClassName = L"XCUIEditorShellInteractionValidation"; +constexpr const wchar_t* kWindowTitle = L"XCUI Editor | Shell Interaction"; + +constexpr UIColor kWindowBg(0.10f, 0.10f, 0.10f, 1.0f); +constexpr UIColor kCardBg(0.17f, 0.17f, 0.17f, 1.0f); +constexpr UIColor kCardBorder(0.28f, 0.28f, 0.28f, 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.24f, 0.24f, 0.24f, 1.0f); +constexpr UIColor kButtonHover(0.32f, 0.32f, 0.32f, 1.0f); +constexpr UIColor kButtonBorder(0.46f, 0.46f, 0.46f, 1.0f); +constexpr UIColor kSuccess(0.46f, 0.72f, 0.50f, 1.0f); +constexpr UIColor kWarning(0.82f, 0.67f, 0.35f, 1.0f); +constexpr UIColor kDanger(0.78f, 0.35f, 0.35f, 1.0f); + +enum class ActionId : unsigned char { + Reset = 0, + Capture +}; + +struct ButtonState { + ActionId action = ActionId::Reset; + std::string label = {}; + UIRect rect = {}; + bool hovered = 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; +} + +bool HasMeaningfulInteractionResult(const UIEditorShellInteractionResult& result) { + return result.consumed || + result.commandTriggered || + result.menuMutation.changed || + !result.menuId.empty() || + !result.popupId.empty() || + !result.itemId.empty() || + !result.commandId.empty(); +} + +std::string FormatBool(bool value) { + return value ? "true" : "false"; +} + +std::string FormatDismissReason(UIPopupDismissReason reason) { + switch (reason) { + case UIPopupDismissReason::None: + return "None"; + case UIPopupDismissReason::Programmatic: + return "Programmatic"; + case UIPopupDismissReason::EscapeKey: + return "EscapeKey"; + case UIPopupDismissReason::PointerOutside: + return "PointerOutside"; + case UIPopupDismissReason::FocusLoss: + return "FocusLoss"; + } + + return "Unknown"; +} + +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(); +} + +std::string JoinPopupChainIds(const UIEditorShellInteractionState& state) { + const auto& popupStates = state.menuSession.GetPopupStates(); + if (popupStates.empty()) { + return "(none)"; + } + + std::ostringstream stream = {}; + for (std::size_t index = 0; index < popupStates.size(); ++index) { + if (index > 0u) { + stream << " -> "; + } + stream << popupStates[index].popupId; + } + return stream.str(); +} + +std::string JoinSubmenuPathIds(const UIEditorShellInteractionState& state) { + const auto& itemIds = state.menuSession.GetOpenSubmenuItemIds(); + if (itemIds.empty()) { + return "(none)"; + } + + std::ostringstream stream = {}; + for (std::size_t index = 0; index < itemIds.size(); ++index) { + if (index > 0u) { + stream << " -> "; + } + stream << itemIds[index]; + } + return stream.str(); +} + +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.hovered ? kButtonHover : kButtonBg, 8.0f); + drawList.AddRectOutline(button.rect, kButtonBorder, 1.0f, 8.0f); + drawList.AddText(UIPoint(button.rect.x + 14.0f, button.rect.y + 10.0f), button.label, kTextPrimary, 12.0f); +} + +std::int32_t MapVirtualKeyToUIKeyCode(WPARAM wParam) { + return wParam == VK_ESCAPE + ? static_cast(KeyCode::Escape) + : static_cast(KeyCode::None); +} + +UIEditorPanelRegistry BuildPanelRegistry() { + UIEditorPanelRegistry registry = {}; + registry.panels = { + { "hierarchy", "Hierarchy", UIEditorPanelPresentationKind::Placeholder, true, true, false }, + { "scene", "Scene", UIEditorPanelPresentationKind::ViewportShell, false, true, false }, + { "document", "Document", UIEditorPanelPresentationKind::Placeholder, true, true, true }, + { "inspector", "Inspector", UIEditorPanelPresentationKind::Placeholder, true, true, true } + }; + return registry; +} + +UIEditorWorkspaceModel BuildWorkspace() { + UIEditorWorkspaceModel workspace = {}; + workspace.root = BuildUIEditorWorkspaceSplit( + "root", + UIEditorWorkspaceSplitAxis::Horizontal, + 0.24f, + BuildUIEditorWorkspacePanel("hierarchy-node", "hierarchy", "Hierarchy", true), + BuildUIEditorWorkspaceSplit( + "main", + UIEditorWorkspaceSplitAxis::Horizontal, + 0.72f, + BuildUIEditorWorkspaceTabStack( + "center-tabs", + { + BuildUIEditorWorkspacePanel("scene-node", "scene", "Scene"), + BuildUIEditorWorkspacePanel("document-node", "document", "Document", true) + }, + 0u), + BuildUIEditorWorkspacePanel("inspector-node", "inspector", "Inspector", true))); + workspace.activePanelId = "scene"; + return workspace; +} + +UIEditorCommandRegistry BuildCommandRegistry() { + UIEditorCommandRegistry registry = {}; + registry.commands = { + { + "workspace.show_inspector", + "Show Inspector", + { UIEditorWorkspaceCommandKind::ShowPanel, UIEditorCommandPanelSource::FixedPanelId, "inspector" } + }, + { + "workspace.hide_inspector", + "Hide Inspector", + { UIEditorWorkspaceCommandKind::HidePanel, UIEditorCommandPanelSource::FixedPanelId, "inspector" } + }, + { + "workspace.activate_scene", + "Activate Scene", + { UIEditorWorkspaceCommandKind::ActivatePanel, UIEditorCommandPanelSource::FixedPanelId, "scene" } + }, + { + "workspace.activate_document", + "Activate Document", + { UIEditorWorkspaceCommandKind::ActivatePanel, UIEditorCommandPanelSource::FixedPanelId, "document" } + }, + { + "workspace.reset", + "Reset Workspace", + { UIEditorWorkspaceCommandKind::ResetWorkspace, UIEditorCommandPanelSource::None, {} } + } + }; + return registry; +} + +UIEditorMenuModel BuildMenuModel() { + UIEditorMenuItemDescriptor showInspector = {}; + showInspector.kind = UIEditorMenuItemKind::Command; + showInspector.itemId = "file-show-inspector"; + showInspector.label = "Show Inspector"; + showInspector.commandId = "workspace.show_inspector"; + showInspector.checkedState = { UIEditorMenuCheckedStateSource::PanelVisible, "inspector" }; + + UIEditorMenuItemDescriptor resetWorkspace = {}; + resetWorkspace.kind = UIEditorMenuItemKind::Command; + resetWorkspace.itemId = "file-reset"; + resetWorkspace.label = "Reset Workspace"; + resetWorkspace.commandId = "workspace.reset"; + + UIEditorMenuItemDescriptor workspaceTools = {}; + workspaceTools.kind = UIEditorMenuItemKind::Submenu; + workspaceTools.itemId = "file-workspace-tools"; + workspaceTools.label = "Workspace Tools"; + workspaceTools.children = { showInspector, resetWorkspace }; + + UIEditorMenuItemDescriptor activateScene = {}; + activateScene.kind = UIEditorMenuItemKind::Command; + activateScene.itemId = "window-activate-scene"; + activateScene.label = "Activate Scene"; + activateScene.commandId = "workspace.activate_scene"; + activateScene.checkedState = { UIEditorMenuCheckedStateSource::PanelActive, "scene" }; + + UIEditorMenuItemDescriptor activateDocument = {}; + activateDocument.kind = UIEditorMenuItemKind::Command; + activateDocument.itemId = "window-activate-document"; + activateDocument.label = "Activate Document"; + activateDocument.commandId = "workspace.activate_document"; + activateDocument.checkedState = { UIEditorMenuCheckedStateSource::PanelActive, "document" }; + + UIEditorMenuItemDescriptor hideInspector = {}; + hideInspector.kind = UIEditorMenuItemKind::Command; + hideInspector.itemId = "window-hide-inspector"; + hideInspector.label = "Hide Inspector"; + hideInspector.commandId = "workspace.hide_inspector"; + + UIEditorMenuDescriptor fileMenu = {}; + fileMenu.menuId = "file"; + fileMenu.label = "File"; + fileMenu.items = { workspaceTools }; + + UIEditorMenuDescriptor windowMenu = {}; + windowMenu.menuId = "window"; + windowMenu.label = "Window"; + windowMenu.items = { activateScene, activateDocument, hideInspector }; + + UIEditorMenuModel model = {}; + model.menus = { fileMenu, windowMenu }; + return model; +} + +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 UpdateLayout(); + void HandleMouseMove(float x, float y); + void HandleLeftButtonDown(float x, float y); + void ExecuteAction(ActionId action); + UIEditorShellInteractionModel BuildInteractionModel() const; + void SetInteractionResult(const UIEditorShellInteractionResult& result); + void SetDispatchResult(const UIEditorCommandDispatchResult& result); + void RenderFrame(); + + HWND m_hwnd = nullptr; + ATOM m_windowClassAtom = 0; + NativeRenderer m_renderer = {}; + AutoScreenshotController m_autoScreenshot = {}; + std::filesystem::path m_captureRoot = {}; + UIEditorWorkspaceController m_controller = {}; + UIEditorCommandDispatcher m_commandDispatcher = {}; + UIEditorMenuModel m_menuModel = {}; + UIEditorShellInteractionState m_interactionState = {}; + UIEditorShellInteractionModel m_cachedModel = {}; + UIEditorShellInteractionFrame m_cachedFrame = {}; + std::vector m_pendingInputEvents = {}; + std::vector m_buttons = {}; + UIRect m_introRect = {}; + UIRect m_controlsRect = {}; + UIRect m_stateRect = {}; + UIRect m_previewRect = {}; + UIRect m_shellRect = {}; + bool m_trackingMouseLeave = false; + std::string m_lastStatus = {}; + std::string m_lastMessage = {}; + UIColor m_lastColor = kTextMuted; +}; + +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->m_renderer.Resize(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) { + if (!app->m_trackingMouseLeave) { + TRACKMOUSEEVENT trackMouseEvent = {}; + trackMouseEvent.cbSize = sizeof(trackMouseEvent); + trackMouseEvent.dwFlags = TME_LEAVE; + trackMouseEvent.hwndTrack = hwnd; + if (TrackMouseEvent(&trackMouseEvent)) { + app->m_trackingMouseLeave = true; + } + } + app->HandleMouseMove(static_cast(GET_X_LPARAM(lParam)), static_cast(GET_Y_LPARAM(lParam))); + return 0; + } + break; + case WM_MOUSELEAVE: + if (app != nullptr) { + app->m_trackingMouseLeave = false; + UIInputEvent event = {}; + event.type = UIInputEventType::PointerLeave; + app->m_pendingInputEvents.push_back(event); + return 0; + } + break; + case WM_LBUTTONDOWN: + if (app != nullptr) { + SetFocus(hwnd); + app->HandleLeftButtonDown(static_cast(GET_X_LPARAM(lParam)), static_cast(GET_Y_LPARAM(lParam))); + return 0; + } + break; + case WM_SETFOCUS: + if (app != nullptr) { + UIInputEvent event = {}; + event.type = UIInputEventType::FocusGained; + app->m_pendingInputEvents.push_back(event); + return 0; + } + break; + case WM_KILLFOCUS: + if (app != nullptr) { + UIInputEvent event = {}; + event.type = UIInputEventType::FocusLost; + app->m_pendingInputEvents.push_back(event); + return 0; + } + break; + case WM_KEYDOWN: + case WM_SYSKEYDOWN: + if (app != nullptr) { + if (wParam == VK_F12) { + app->m_autoScreenshot.RequestCapture("manual_f12"); + } else { + const std::int32_t keyCode = MapVirtualKeyToUIKeyCode(wParam); + if (keyCode != static_cast(KeyCode::None)) { + UIInputEvent event = {}; + event.type = UIInputEventType::KeyDown; + event.keyCode = keyCode; + app->m_pendingInputEvents.push_back(event); + } + } + return 0; + } + break; + case WM_ERASEBKGND: + return 1; + case WM_DESTROY: + PostQuitMessage(0); + return 0; + default: + break; + } + + return DefWindowProcW(hwnd, message, wParam, lParam); +} + +bool ScenarioApp::Initialize(HINSTANCE hInstance, int nCmdShow) { + m_captureRoot = + ResolveRepoRootPath() / "tests/UI/Editor/integration/shell/editor_shell_interaction/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, + 1540, + 940, + nullptr, + nullptr, + hInstance, + this); + if (m_hwnd == nullptr) { + return false; + } + + ShowWindow(m_hwnd, nCmdShow); + if (!m_renderer.Initialize(m_hwnd)) { + return false; + } + + ResetScenario(); + return true; +} + +void ScenarioApp::Shutdown() { + m_autoScreenshot.Shutdown(); + m_renderer.Shutdown(); + if (m_hwnd != nullptr && IsWindow(m_hwnd)) { + DestroyWindow(m_hwnd); + } + if (m_windowClassAtom != 0) { + UnregisterClassW(kWindowClassName, GetModuleHandleW(nullptr)); + } +} + +void ScenarioApp::ResetScenario() { + m_controller = BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace()); + m_commandDispatcher = UIEditorCommandDispatcher(BuildCommandRegistry()); + m_menuModel = BuildMenuModel(); + m_interactionState = {}; + m_cachedModel = {}; + m_cachedFrame = {}; + m_pendingInputEvents.clear(); + m_lastStatus = "Ready"; + m_lastMessage = "等待交互。这里只验证 Editor 根壳统一交互 contract,不接旧 editor 业务。"; + m_lastColor = kWarning; +} + +void ScenarioApp::UpdateLayout() { + 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 padding = 20.0f; + constexpr float leftWidth = 430.0f; + + m_introRect = UIRect(padding, padding, leftWidth, 214.0f); + m_controlsRect = UIRect(padding, 250.0f, leftWidth, 118.0f); + m_stateRect = UIRect(padding, 384.0f, leftWidth, height - 404.0f); + m_previewRect = UIRect(leftWidth + padding * 2.0f, padding, width - leftWidth - padding * 3.0f, height - padding * 2.0f); + m_shellRect = UIRect(m_previewRect.x + 18.0f, m_previewRect.y + 54.0f, m_previewRect.width - 36.0f, m_previewRect.height - 72.0f); + + const float buttonWidth = (m_controlsRect.width - 32.0f - 12.0f) * 0.5f; + const float left = m_controlsRect.x + 16.0f; + const float top = m_controlsRect.y + 62.0f; + m_buttons = { + { ActionId::Reset, "重置", UIRect(left, top, buttonWidth, 36.0f), false }, + { ActionId::Capture, "截图(F12)", UIRect(left + buttonWidth + 12.0f, top, buttonWidth, 36.0f), false } + }; +} + +void ScenarioApp::HandleMouseMove(float x, float y) { + UpdateLayout(); + for (ButtonState& button : m_buttons) { + button.hovered = ContainsPoint(button.rect, x, y); + } + + UIInputEvent event = {}; + event.type = UIInputEventType::PointerMove; + event.position = UIPoint(x, y); + m_pendingInputEvents.push_back(event); +} + +void ScenarioApp::HandleLeftButtonDown(float x, float y) { + UpdateLayout(); + for (const ButtonState& button : m_buttons) { + if (ContainsPoint(button.rect, x, y)) { + ExecuteAction(button.action); + return; + } + } + + UIInputEvent event = {}; + event.type = UIInputEventType::PointerButtonDown; + event.position = UIPoint(x, y); + event.pointerButton = UIPointerButton::Left; + m_pendingInputEvents.push_back(event); +} + +void ScenarioApp::ExecuteAction(ActionId action) { + if (action == ActionId::Reset) { + ResetScenario(); + m_lastStatus = "Ready"; + m_lastMessage = "场景状态已重置。请重新检查 root open / child popup / dismiss 行为。"; + m_lastColor = kWarning; + return; + } + + m_autoScreenshot.RequestCapture("manual_button"); + m_lastStatus = "Ready"; + m_lastMessage = "截图已排队,输出到 tests/UI/Editor/integration/shell/editor_shell_interaction/captures/。"; + m_lastColor = kWarning; +} + +UIEditorShellInteractionModel ScenarioApp::BuildInteractionModel() const { + UIEditorShellInteractionModel model = {}; + model.resolvedMenuModel = BuildUIEditorResolvedMenuModel( + m_menuModel, + m_commandDispatcher, + m_controller, + nullptr); + model.statusSegments = { + UIEditorStatusBarSegment{ + "mode", + "Shell Contract", + UIEditorStatusBarSlot::Leading, + {}, + true, + true, + 122.0f + }, + UIEditorStatusBarSegment{ + "active", + m_controller.GetWorkspace().activePanelId.empty() + ? std::string("(none)") + : m_controller.GetWorkspace().activePanelId, + UIEditorStatusBarSlot::Trailing, + {}, + true, + true, + 120.0f + } + }; + + UIEditorWorkspacePanelPresentationModel presentation = {}; + presentation.panelId = "scene"; + presentation.kind = UIEditorPanelPresentationKind::ViewportShell; + presentation.viewportShellModel.spec.chrome.title = "Scene"; + presentation.viewportShellModel.spec.chrome.subtitle = "Editor Shell Interaction"; + presentation.viewportShellModel.spec.chrome.showTopBar = true; + presentation.viewportShellModel.spec.chrome.showBottomBar = true; + presentation.viewportShellModel.frame.hasTexture = false; + presentation.viewportShellModel.frame.statusText = + "这里只验证 Editor 根壳交互,不接旧 editor 业务面板。"; + model.workspacePresentations = { presentation }; + return model; +} + +void ScenarioApp::SetInteractionResult(const UIEditorShellInteractionResult& result) { + if (!HasMeaningfulInteractionResult(result)) { + return; + } + + if (result.commandTriggered) { + const UIEditorCommandDispatchResult dispatchResult = + m_commandDispatcher.Dispatch(result.commandId, m_controller); + SetDispatchResult(dispatchResult); + return; + } + + if (result.menuMutation.changed) { + if (!result.itemId.empty() && !result.menuMutation.openedPopupId.empty()) { + m_lastStatus = "Changed"; + m_lastMessage = + "已展开子菜单 `" + result.itemId + "`。请检查 child popup 是否在 hover 时直接弹出。"; + m_lastColor = kSuccess; + } else if (!result.menuId.empty() && !result.menuMutation.openedPopupId.empty()) { + m_lastStatus = "Changed"; + m_lastMessage = + "当前根菜单为 `" + result.menuId + "`。请检查 root popup 是否在打开态下可直接切换。"; + m_lastColor = kSuccess; + } else { + m_lastStatus = "Dismissed"; + m_lastMessage = + "菜单链已收起。DismissReason = " + + FormatDismissReason(result.menuMutation.dismissReason) + + "。请检查 outside pointer down / Esc / focus loss 的收束是否正确。"; + m_lastColor = kWarning; + } + return; + } + + if (result.consumed) { + m_lastStatus = "NoOp"; + m_lastMessage = "这次输入被根壳交互层消费,但没有触发额外状态变化。"; + m_lastColor = kWarning; + } +} + +void ScenarioApp::SetDispatchResult(const UIEditorCommandDispatchResult& result) { + m_lastStatus = std::string(GetUIEditorCommandDispatchStatusName(result.status)); + m_lastMessage = result.message.empty() ? std::string("命令派发完成。") : result.message; + m_lastColor = m_lastStatus == "Dispatched" ? kSuccess : kDanger; +} + +void ScenarioApp::RenderFrame() { + UpdateLayout(); + m_cachedModel = BuildInteractionModel(); + m_cachedFrame = UpdateUIEditorShellInteraction( + m_interactionState, + m_shellRect, + m_controller.GetPanelRegistry(), + m_controller.GetWorkspace(), + m_controller.GetSession(), + m_cachedModel, + m_pendingInputEvents); + m_pendingInputEvents.clear(); + SetInteractionResult(m_cachedFrame.result); + + if (m_cachedFrame.result.commandTriggered) { + m_cachedModel = BuildInteractionModel(); + m_cachedFrame = UpdateUIEditorShellInteraction( + m_interactionState, + m_shellRect, + m_controller.GetPanelRegistry(), + m_controller.GetWorkspace(), + m_controller.GetSession(), + m_cachedModel, + {}); + } + + 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 = {}; + UIDrawList& drawList = drawData.EmplaceDrawList("EditorShellInteraction"); + drawList.AddFilledRect(UIRect(0.0f, 0.0f, width, height), kWindowBg); + + DrawCard(drawList, m_introRect, "这个测试验证什么功能", "只验证 Editor 根壳统一交互 contract,不做业务面板。"); + drawList.AddText(UIPoint(m_introRect.x + 16.0f, m_introRect.y + 70.0f), "1. 验证 MenuBar 的 root open / root switch 行为是否统一稳定。", kTextPrimary, 12.0f); + drawList.AddText(UIPoint(m_introRect.x + 16.0f, m_introRect.y + 92.0f), "2. 验证 hover 子菜单时,child popup 是否直接展开,不需要额外点击。", kTextPrimary, 12.0f); + drawList.AddText(UIPoint(m_introRect.x + 16.0f, m_introRect.y + 114.0f), "3. 验证 outside pointer down / Esc / focus loss 是否能正确收起 popup chain。", kTextPrimary, 12.0f); + drawList.AddText(UIPoint(m_introRect.x + 16.0f, m_introRect.y + 136.0f), "4. 验证预览区是真实 root shell:MenuBar + Workspace + StatusBar + popup overlay。", kTextPrimary, 12.0f); + drawList.AddText(UIPoint(m_introRect.x + 16.0f, m_introRect.y + 158.0f), "5. 验证 command 只通过最小 dispatch hook 回传,不接旧 editor 业务。", kTextPrimary, 12.0f); + drawList.AddText(UIPoint(m_introRect.x + 16.0f, m_introRect.y + 182.0f), "建议操作:点击 File,hover `Workspace Tools`,再按 Esc 或点预览区外空白处。", kTextWeak, 11.0f); + + DrawCard(drawList, m_controlsRect, "操作", "只保留这个场景必要的控制。"); + for (const ButtonState& button : m_buttons) { + DrawButton(drawList, button); + } + + DrawCard(drawList, m_stateRect, "状态", "重点检查根壳 contract 当前状态。"); + float stateY = m_stateRect.y + 66.0f; + auto addStateLine = [&](std::string label, std::string value, const UIColor& color, float fontSize = 12.0f) { + drawList.AddText(UIPoint(m_stateRect.x + 16.0f, stateY), std::move(label) + ": " + std::move(value), color, fontSize); + stateY += 20.0f; + }; + + addStateLine("Open Root", m_cachedFrame.openRootMenuId.empty() ? "(none)" : m_cachedFrame.openRootMenuId, kTextPrimary); + addStateLine("Popup Chain", JoinPopupChainIds(m_interactionState), kTextPrimary, 11.0f); + addStateLine("Submenu Path", JoinSubmenuPathIds(m_interactionState), kTextPrimary, 11.0f); + addStateLine("Focused", FormatBool(m_cachedFrame.focused), m_cachedFrame.focused ? kSuccess : kTextMuted); + addStateLine("Result", m_lastStatus, m_lastColor); + drawList.AddText(UIPoint(m_stateRect.x + 16.0f, stateY + 4.0f), m_lastMessage, kTextMuted, 11.0f); + stateY += 34.0f; + addStateLine("Visible Panels", JoinVisiblePanelIds(m_controller.GetWorkspace(), m_controller.GetSession()), kTextWeak, 11.0f); + addStateLine( + "Capture", + m_autoScreenshot.HasPendingCapture() + ? "截图排队中..." + : (m_autoScreenshot.GetLastCaptureSummary().empty() + ? std::string("F12 或 按钮 -> captures/") + : m_autoScreenshot.GetLastCaptureSummary()), + kTextWeak, + 11.0f); + + DrawCard(drawList, m_previewRect, "Preview", "真实 UIEditorShellInteraction 预览,不接旧 editor 业务。"); + AppendUIEditorShellInteraction(drawList, m_cachedFrame, m_cachedModel, m_interactionState); + + 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/unit/CMakeLists.txt b/tests/UI/Editor/unit/CMakeLists.txt index f97e89a7..cd6c4bc9 100644 --- a/tests/UI/Editor/unit/CMakeLists.txt +++ b/tests/UI/Editor/unit/CMakeLists.txt @@ -10,6 +10,7 @@ set(EDITOR_UI_UNIT_TEST_SOURCES test_ui_editor_menu_popup.cpp test_ui_editor_panel_registry.cpp test_ui_editor_shell_compose.cpp + test_ui_editor_shell_interaction.cpp test_ui_editor_collection_primitives.cpp test_ui_editor_dock_host.cpp test_ui_editor_panel_chrome.cpp diff --git a/tests/UI/Editor/unit/test_ui_editor_shell_interaction.cpp b/tests/UI/Editor/unit/test_ui_editor_shell_interaction.cpp new file mode 100644 index 00000000..f1061184 --- /dev/null +++ b/tests/UI/Editor/unit/test_ui_editor_shell_interaction.cpp @@ -0,0 +1,545 @@ +#include + +#include +#include +#include + +#include + +namespace { + +using XCEngine::Input::KeyCode; +using XCEngine::UI::UIInputEvent; +using XCEngine::UI::UIInputEventType; +using XCEngine::UI::UIPoint; +using XCEngine::UI::UIRect; +using XCEngine::UI::UIPointerButton; +using XCEngine::UI::Editor::BuildDefaultUIEditorWorkspaceSession; +using XCEngine::UI::Editor::BuildUIEditorWorkspacePanel; +using XCEngine::UI::Editor::BuildUIEditorWorkspaceSplit; +using XCEngine::UI::Editor::BuildUIEditorWorkspaceTabStack; +using XCEngine::UI::Editor::ResolveUIEditorShellInteractionRequest; +using XCEngine::UI::Editor::UIEditorMenuItemKind; +using XCEngine::UI::Editor::UIEditorPanelPresentationKind; +using XCEngine::UI::Editor::UIEditorPanelRegistry; +using XCEngine::UI::Editor::UIEditorResolvedMenuDescriptor; +using XCEngine::UI::Editor::UIEditorResolvedMenuItem; +using XCEngine::UI::Editor::UIEditorResolvedMenuModel; +using XCEngine::UI::Editor::UIEditorShellInteractionFrame; +using XCEngine::UI::Editor::UIEditorShellInteractionMenuButtonRequest; +using XCEngine::UI::Editor::UIEditorShellInteractionModel; +using XCEngine::UI::Editor::UIEditorShellInteractionPopupItemRequest; +using XCEngine::UI::Editor::UIEditorShellInteractionState; +using XCEngine::UI::Editor::UIEditorWorkspaceModel; +using XCEngine::UI::Editor::UIEditorWorkspacePanelPresentationModel; +using XCEngine::UI::Editor::UIEditorWorkspaceSplitAxis; +using XCEngine::UI::Editor::UpdateUIEditorShellInteraction; +using XCEngine::UI::Editor::Widgets::UIEditorMenuPopupInvalidIndex; +using XCEngine::UI::Editor::Widgets::UIEditorStatusBarSlot; +using XCEngine::UI::Widgets::UIPopupDismissReason; + +UIEditorPanelRegistry BuildPanelRegistry() { + UIEditorPanelRegistry registry = {}; + registry.panels = { + { "hierarchy", "Hierarchy", UIEditorPanelPresentationKind::Placeholder, true, true, false }, + { "scene", "Scene", UIEditorPanelPresentationKind::ViewportShell, false, true, false }, + { "inspector", "Inspector", UIEditorPanelPresentationKind::Placeholder, true, true, true } + }; + return registry; +} + +UIEditorWorkspaceModel BuildWorkspace() { + UIEditorWorkspaceModel workspace = {}; + workspace.root = BuildUIEditorWorkspaceSplit( + "root", + UIEditorWorkspaceSplitAxis::Horizontal, + 0.28f, + BuildUIEditorWorkspacePanel("hierarchy-node", "hierarchy", "Hierarchy", true), + BuildUIEditorWorkspaceSplit( + "main", + UIEditorWorkspaceSplitAxis::Horizontal, + 0.72f, + BuildUIEditorWorkspacePanel("scene-node", "scene", "Scene"), + BuildUIEditorWorkspacePanel("inspector-node", "inspector", "Inspector", true))); + workspace.activePanelId = "scene"; + return workspace; +} + +UIEditorResolvedMenuModel BuildResolvedMenuModel() { + UIEditorResolvedMenuModel model = {}; + + UIEditorResolvedMenuItem fileWorkspaceTools = {}; + fileWorkspaceTools.kind = UIEditorMenuItemKind::Submenu; + fileWorkspaceTools.itemId = "file-workspace-tools"; + fileWorkspaceTools.label = "Workspace Tools"; + fileWorkspaceTools.enabled = true; + fileWorkspaceTools.children = { + UIEditorResolvedMenuItem{ + UIEditorMenuItemKind::Command, + "file-show-inspector", + "Show Inspector", + "workspace.show_inspector", + "Show Inspector", + "Ctrl+I", + true, + false + }, + UIEditorResolvedMenuItem{ + UIEditorMenuItemKind::Command, + "file-reset-layout", + "Reset Layout", + "workspace.reset_layout", + "Reset Layout", + "Ctrl+R", + true, + false + } + }; + + UIEditorResolvedMenuDescriptor fileMenu = {}; + fileMenu.menuId = "file"; + fileMenu.label = "File"; + fileMenu.items = { + fileWorkspaceTools, + UIEditorResolvedMenuItem{ + UIEditorMenuItemKind::Command, + "file-close", + "Close", + "workspace.close", + "Close", + "Ctrl+W", + true, + false + } + }; + + UIEditorResolvedMenuDescriptor windowMenu = {}; + windowMenu.menuId = "window"; + windowMenu.label = "Window"; + windowMenu.items = { + UIEditorResolvedMenuItem{ + UIEditorMenuItemKind::Command, + "window-focus-inspector", + "Focus Inspector", + "workspace.focus_inspector", + "Focus Inspector", + "Ctrl+P", + true, + false + } + }; + + model.menus = { fileMenu, windowMenu }; + return model; +} + +UIEditorShellInteractionModel BuildInteractionModel() { + UIEditorShellInteractionModel model = {}; + model.resolvedMenuModel = BuildResolvedMenuModel(); + model.statusSegments = { + { "mode", "Scene", UIEditorStatusBarSlot::Leading, {}, true, true, 78.0f } + }; + + UIEditorWorkspacePanelPresentationModel presentation = {}; + presentation.panelId = "scene"; + presentation.kind = UIEditorPanelPresentationKind::ViewportShell; + presentation.viewportShellModel.spec.chrome.title = "Scene"; + presentation.viewportShellModel.frame.statusText = "Viewport"; + model.workspacePresentations = { presentation }; + return model; +} + +const UIEditorShellInteractionMenuButtonRequest* FindMenuButton( + const UIEditorShellInteractionFrame& frame, + std::string_view menuId) { + for (const auto& button : frame.request.menuButtons) { + if (button.menuId == menuId) { + return &button; + } + } + + return nullptr; +} + +const UIEditorShellInteractionPopupItemRequest* FindPopupItem( + const UIEditorShellInteractionFrame& frame, + std::string_view itemId) { + for (const auto& popup : frame.request.popupRequests) { + for (const auto& item : popup.itemRequests) { + if (item.itemId == itemId) { + return &item; + } + } + } + + return nullptr; +} + +UIPoint RectCenter(const UIRect& rect) { + return UIPoint(rect.x + rect.width * 0.5f, rect.y + rect.height * 0.5f); +} + +UIInputEvent MakePointerMove(const UIPoint& position) { + UIInputEvent event = {}; + event.type = UIInputEventType::PointerMove; + event.position = position; + return event; +} + +UIInputEvent MakeLeftPointerDown(const UIPoint& position) { + UIInputEvent event = {}; + event.type = UIInputEventType::PointerButtonDown; + event.position = position; + event.pointerButton = UIPointerButton::Left; + return event; +} + +UIInputEvent MakeKeyDown(KeyCode keyCode) { + UIInputEvent event = {}; + event.type = UIInputEventType::KeyDown; + event.keyCode = static_cast(keyCode); + return event; +} + +UIInputEvent MakeFocusLost() { + UIInputEvent event = {}; + event.type = UIInputEventType::FocusLost; + return event; +} + +} // namespace + +TEST(UIEditorShellInteractionTest, ClickMenuBarRootOpensSingleRootPopup) { + const auto registry = BuildPanelRegistry(); + const auto workspace = BuildWorkspace(); + const auto session = BuildDefaultUIEditorWorkspaceSession(registry, workspace); + const auto model = BuildInteractionModel(); + + UIEditorShellInteractionState state = {}; + const auto request = ResolveUIEditorShellInteractionRequest( + UIRect(0.0f, 0.0f, 1280.0f, 720.0f), + registry, + workspace, + session, + model, + {}, + state); + ASSERT_EQ(request.menuButtons.size(), 2u); + + const auto frame = UpdateUIEditorShellInteraction( + state, + UIRect(0.0f, 0.0f, 1280.0f, 720.0f), + registry, + workspace, + session, + model, + { MakeLeftPointerDown(RectCenter(request.menuButtons.front().rect)) }); + + EXPECT_TRUE(state.menuSession.HasOpenMenu()); + EXPECT_EQ(frame.openRootMenuId, "file"); + ASSERT_EQ(frame.request.popupRequests.size(), 1u); + EXPECT_EQ(frame.request.popupRequests.front().menuId, "file"); + EXPECT_EQ(state.composeState.menuBarState.openIndex, 0u); +} + +TEST(UIEditorShellInteractionTest, HoverOtherRootSwitchesRootPopup) { + const auto registry = BuildPanelRegistry(); + const auto workspace = BuildWorkspace(); + const auto session = BuildDefaultUIEditorWorkspaceSession(registry, workspace); + const auto model = BuildInteractionModel(); + + UIEditorShellInteractionState state = {}; + auto frame = UpdateUIEditorShellInteraction( + state, + UIRect(0.0f, 0.0f, 1280.0f, 720.0f), + registry, + workspace, + session, + model, + { MakeLeftPointerDown(UIPoint(40.0f, 30.0f)) }); + const auto* windowButton = FindMenuButton(frame, "window"); + ASSERT_NE(windowButton, nullptr); + + frame = UpdateUIEditorShellInteraction( + state, + UIRect(0.0f, 0.0f, 1280.0f, 720.0f), + registry, + workspace, + session, + model, + { MakePointerMove(RectCenter(windowButton->rect)) }); + + EXPECT_TRUE(state.menuSession.IsMenuOpen("window")); + EXPECT_EQ(frame.openRootMenuId, "window"); + ASSERT_EQ(frame.request.popupRequests.size(), 1u); + EXPECT_EQ(frame.request.popupRequests.front().menuId, "window"); +} + +TEST(UIEditorShellInteractionTest, HoverSubmenuOpensChildPopupAndEscapeCollapsesChain) { + const auto registry = BuildPanelRegistry(); + const auto workspace = BuildWorkspace(); + const auto session = BuildDefaultUIEditorWorkspaceSession(registry, workspace); + const auto model = BuildInteractionModel(); + + UIEditorShellInteractionState state = {}; + auto frame = UpdateUIEditorShellInteraction( + state, + UIRect(0.0f, 0.0f, 1280.0f, 720.0f), + registry, + workspace, + session, + model, + { MakeLeftPointerDown(UIPoint(40.0f, 30.0f)) }); + const auto* submenuItem = FindPopupItem(frame, "file-workspace-tools"); + ASSERT_NE(submenuItem, nullptr); + ASSERT_TRUE(submenuItem->hasSubmenu); + + frame = UpdateUIEditorShellInteraction( + state, + UIRect(0.0f, 0.0f, 1280.0f, 720.0f), + registry, + workspace, + session, + model, + { MakePointerMove(RectCenter(submenuItem->rect)) }); + + ASSERT_EQ(frame.request.popupRequests.size(), 2u); + EXPECT_EQ(frame.request.popupRequests[1].sourceItemId, "file-workspace-tools"); + EXPECT_NE(frame.popupFrames.front().popupState.submenuOpenIndex, UIEditorMenuPopupInvalidIndex); + + frame = UpdateUIEditorShellInteraction( + state, + UIRect(0.0f, 0.0f, 1280.0f, 720.0f), + registry, + workspace, + session, + model, + { MakeKeyDown(KeyCode::Escape) }); + EXPECT_EQ(frame.openRootMenuId, "file"); + ASSERT_EQ(frame.request.popupRequests.size(), 1u); + + frame = UpdateUIEditorShellInteraction( + state, + UIRect(0.0f, 0.0f, 1280.0f, 720.0f), + registry, + workspace, + session, + model, + { MakeKeyDown(KeyCode::Escape) }); + EXPECT_FALSE(state.menuSession.HasOpenMenu()); + EXPECT_TRUE(frame.request.popupRequests.empty()); +} + +TEST(UIEditorShellInteractionTest, ClickCommandReturnsDispatchHookAndClosesMenu) { + const auto registry = BuildPanelRegistry(); + const auto workspace = BuildWorkspace(); + const auto session = BuildDefaultUIEditorWorkspaceSession(registry, workspace); + const auto model = BuildInteractionModel(); + + UIEditorShellInteractionState state = {}; + auto frame = UpdateUIEditorShellInteraction( + state, + UIRect(0.0f, 0.0f, 1280.0f, 720.0f), + registry, + workspace, + session, + model, + { MakeLeftPointerDown(UIPoint(40.0f, 30.0f)) }); + const auto* commandItem = FindPopupItem(frame, "file-close"); + ASSERT_NE(commandItem, nullptr); + + frame = UpdateUIEditorShellInteraction( + state, + UIRect(0.0f, 0.0f, 1280.0f, 720.0f), + registry, + workspace, + session, + model, + { MakeLeftPointerDown(RectCenter(commandItem->rect)) }); + + EXPECT_TRUE(frame.result.commandTriggered); + EXPECT_EQ(frame.result.commandId, "workspace.close"); + EXPECT_FALSE(state.menuSession.HasOpenMenu()); + EXPECT_TRUE(frame.request.popupRequests.empty()); +} + +TEST(UIEditorShellInteractionTest, PointerDownOutsideDismissesWholeMenuChain) { + const auto registry = BuildPanelRegistry(); + const auto workspace = BuildWorkspace(); + const auto session = BuildDefaultUIEditorWorkspaceSession(registry, workspace); + const auto model = BuildInteractionModel(); + + UIEditorShellInteractionState state = {}; + auto frame = UpdateUIEditorShellInteraction( + state, + UIRect(0.0f, 0.0f, 1280.0f, 720.0f), + registry, + workspace, + session, + model, + { MakeLeftPointerDown(UIPoint(40.0f, 30.0f)) }); + ASSERT_TRUE(state.menuSession.HasOpenMenu()); + + frame = UpdateUIEditorShellInteraction( + state, + UIRect(0.0f, 0.0f, 1280.0f, 720.0f), + registry, + workspace, + session, + model, + { MakeLeftPointerDown(UIPoint(900.0f, 500.0f)) }); + + EXPECT_TRUE(frame.result.consumed); + EXPECT_FALSE(state.menuSession.HasOpenMenu()); + EXPECT_TRUE(frame.request.popupRequests.empty()); +} + +TEST(UIEditorShellInteractionTest, DisabledSubmenuDoesNotOpenChildPopup) { + const auto registry = BuildPanelRegistry(); + const auto workspace = BuildWorkspace(); + const auto session = BuildDefaultUIEditorWorkspaceSession(registry, workspace); + auto model = BuildInteractionModel(); + model.resolvedMenuModel.menus[0].items[0].enabled = false; + + UIEditorShellInteractionState state = {}; + auto frame = UpdateUIEditorShellInteraction( + state, + UIRect(0.0f, 0.0f, 1280.0f, 720.0f), + registry, + workspace, + session, + model, + { MakeLeftPointerDown(UIPoint(40.0f, 30.0f)) }); + const auto* submenuItem = FindPopupItem(frame, "file-workspace-tools"); + ASSERT_NE(submenuItem, nullptr); + ASSERT_TRUE(submenuItem->hasSubmenu); + EXPECT_FALSE(submenuItem->enabled); + + frame = UpdateUIEditorShellInteraction( + state, + UIRect(0.0f, 0.0f, 1280.0f, 720.0f), + registry, + workspace, + session, + model, + { MakePointerMove(RectCenter(submenuItem->rect)) }); + + ASSERT_EQ(frame.request.popupRequests.size(), 1u); + EXPECT_EQ(frame.request.popupRequests.front().menuId, "file"); + EXPECT_TRUE(state.menuSession.HasOpenMenu()); +} + +TEST(UIEditorShellInteractionTest, FocusLostDismissesWholeMenuChain) { + const auto registry = BuildPanelRegistry(); + const auto workspace = BuildWorkspace(); + const auto session = BuildDefaultUIEditorWorkspaceSession(registry, workspace); + const auto model = BuildInteractionModel(); + + UIEditorShellInteractionState state = {}; + auto frame = UpdateUIEditorShellInteraction( + state, + UIRect(0.0f, 0.0f, 1280.0f, 720.0f), + registry, + workspace, + session, + model, + { MakeLeftPointerDown(UIPoint(40.0f, 30.0f)) }); + const auto* submenuItem = FindPopupItem(frame, "file-workspace-tools"); + ASSERT_NE(submenuItem, nullptr); + + frame = UpdateUIEditorShellInteraction( + state, + UIRect(0.0f, 0.0f, 1280.0f, 720.0f), + registry, + workspace, + session, + model, + { MakePointerMove(RectCenter(submenuItem->rect)) }); + ASSERT_EQ(frame.request.popupRequests.size(), 2u); + + frame = UpdateUIEditorShellInteraction( + state, + UIRect(0.0f, 0.0f, 1280.0f, 720.0f), + registry, + workspace, + session, + model, + { MakeFocusLost() }); + + EXPECT_TRUE(frame.result.consumed); + EXPECT_TRUE(frame.result.menuMutation.changed); + EXPECT_EQ(frame.result.menuMutation.dismissReason, UIPopupDismissReason::FocusLoss); + EXPECT_FALSE(state.menuSession.HasOpenMenu()); + EXPECT_TRUE(frame.request.popupRequests.empty()); +} + +TEST(UIEditorShellInteractionTest, OpenMenuConsumesWorkspacePointerDownForThatFrame) { + const auto registry = BuildPanelRegistry(); + const auto workspace = BuildWorkspace(); + const auto session = BuildDefaultUIEditorWorkspaceSession(registry, workspace); + const auto model = BuildInteractionModel(); + + UIEditorShellInteractionState state = {}; + auto frame = UpdateUIEditorShellInteraction( + state, + UIRect(0.0f, 0.0f, 1280.0f, 720.0f), + registry, + workspace, + session, + model, + { MakeLeftPointerDown(UIPoint(40.0f, 30.0f)) }); + ASSERT_TRUE(state.menuSession.HasOpenMenu()); + ASSERT_FALSE(frame.shellFrame.workspaceFrame.viewportFrames.empty()); + + const UIRect inputRect = + frame.shellFrame.workspaceFrame.viewportFrames.front().viewportShellFrame.slotLayout.inputRect; + frame = UpdateUIEditorShellInteraction( + state, + UIRect(0.0f, 0.0f, 1280.0f, 720.0f), + registry, + workspace, + session, + model, + { MakeLeftPointerDown(RectCenter(inputRect)) }); + + EXPECT_FALSE(frame.shellFrame.workspaceFrame.viewportFrames.front() + .viewportShellFrame.inputFrame.pointerPressedInside); + EXPECT_FALSE(state.menuSession.HasOpenMenu()); +} + +TEST(UIEditorShellInteractionTest, InvalidResolvedMenuStateClosesInvisibleModalChain) { + const auto registry = BuildPanelRegistry(); + const auto workspace = BuildWorkspace(); + const auto session = BuildDefaultUIEditorWorkspaceSession(registry, workspace); + const auto model = BuildInteractionModel(); + + UIEditorShellInteractionState state = {}; + auto frame = UpdateUIEditorShellInteraction( + state, + UIRect(0.0f, 0.0f, 1280.0f, 720.0f), + registry, + workspace, + session, + model, + { MakeLeftPointerDown(UIPoint(40.0f, 30.0f)) }); + ASSERT_TRUE(state.menuSession.HasOpenMenu()); + ASSERT_EQ(frame.request.popupRequests.size(), 1u); + + auto updatedModel = model; + updatedModel.resolvedMenuModel.menus.erase(updatedModel.resolvedMenuModel.menus.begin()); + + frame = UpdateUIEditorShellInteraction( + state, + UIRect(0.0f, 0.0f, 1280.0f, 720.0f), + registry, + workspace, + session, + updatedModel, + {}); + + EXPECT_TRUE(frame.result.consumed); + EXPECT_TRUE(frame.result.menuMutation.changed); + EXPECT_EQ(frame.result.menuMutation.dismissReason, UIPopupDismissReason::Programmatic); + EXPECT_FALSE(state.menuSession.HasOpenMenu()); + EXPECT_TRUE(frame.request.popupRequests.empty()); +}