From ce1995659aebf5c4a13f17281e912ac6f700cbc9 Mon Sep 17 00:00:00 2001 From: ssdfasd <2156608475@qq.com> Date: Tue, 7 Apr 2026 10:41:39 +0800 Subject: [PATCH] Add dock host interaction contract validation --- .../Core/UIEditorDockHostInteraction.h | 46 ++ .../Core/UIEditorWorkspaceController.h | 3 + .../src/Core/UIEditorDockHostInteraction.cpp | 304 ++++++++ .../src/Core/UIEditorWorkspaceController.cpp | 50 ++ tests/UI/Editor/integration/CMakeLists.txt | 1 + tests/UI/Editor/integration/README.md | 9 + .../Editor/integration/shell/CMakeLists.txt | 3 + .../shell/dock_host_basic/CMakeLists.txt | 30 + .../shell/dock_host_basic/captures/.gitkeep | 1 + .../shell/dock_host_basic/main.cpp | 680 ++++++++++++++++++ tests/UI/Editor/unit/CMakeLists.txt | 1 + .../test_ui_editor_dock_host_interaction.cpp | 302 ++++++++ 12 files changed, 1430 insertions(+) create mode 100644 new_editor/include/XCEditor/Core/UIEditorDockHostInteraction.h create mode 100644 new_editor/src/Core/UIEditorDockHostInteraction.cpp create mode 100644 tests/UI/Editor/integration/shell/dock_host_basic/CMakeLists.txt create mode 100644 tests/UI/Editor/integration/shell/dock_host_basic/captures/.gitkeep create mode 100644 tests/UI/Editor/integration/shell/dock_host_basic/main.cpp create mode 100644 tests/UI/Editor/unit/test_ui_editor_dock_host_interaction.cpp diff --git a/new_editor/include/XCEditor/Core/UIEditorDockHostInteraction.h b/new_editor/include/XCEditor/Core/UIEditorDockHostInteraction.h new file mode 100644 index 00000000..59a6d23f --- /dev/null +++ b/new_editor/include/XCEditor/Core/UIEditorDockHostInteraction.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include + +#include +#include + +#include +#include + +namespace XCEngine::UI::Editor { + +struct UIEditorDockHostInteractionState { + Widgets::UIEditorDockHostState dockHostState = {}; + ::XCEngine::UI::Widgets::UISplitterDragState splitterDragState = {}; + ::XCEngine::UI::UIPoint pointerPosition = {}; + bool hasPointerPosition = false; +}; + +struct UIEditorDockHostInteractionResult { + bool consumed = false; + bool commandExecuted = false; + bool layoutChanged = false; + bool requestPointerCapture = false; + bool releasePointerCapture = false; + Widgets::UIEditorDockHostHitTarget hitTarget = {}; + std::string activeSplitterNodeId = {}; + UIEditorWorkspaceCommandResult commandResult = {}; + UIEditorWorkspaceLayoutOperationResult layoutResult = {}; +}; + +struct UIEditorDockHostInteractionFrame { + Widgets::UIEditorDockHostLayout layout = {}; + UIEditorDockHostInteractionResult result = {}; + bool focused = false; +}; + +UIEditorDockHostInteractionFrame UpdateUIEditorDockHostInteraction( + UIEditorDockHostInteractionState& state, + UIEditorWorkspaceController& controller, + const ::XCEngine::UI::UIRect& bounds, + const std::vector<::XCEngine::UI::UIInputEvent>& inputEvents, + const Widgets::UIEditorDockHostMetrics& metrics = {}); + +} // namespace XCEngine::UI::Editor diff --git a/new_editor/include/XCEditor/Core/UIEditorWorkspaceController.h b/new_editor/include/XCEditor/Core/UIEditorWorkspaceController.h index 94806b5c..01200302 100644 --- a/new_editor/include/XCEditor/Core/UIEditorWorkspaceController.h +++ b/new_editor/include/XCEditor/Core/UIEditorWorkspaceController.h @@ -102,6 +102,9 @@ public: const UIEditorWorkspaceLayoutSnapshot& snapshot); UIEditorWorkspaceLayoutOperationResult RestoreSerializedLayout( std::string_view serializedLayout); + UIEditorWorkspaceLayoutOperationResult SetSplitRatio( + std::string_view nodeId, + float splitRatio); UIEditorWorkspaceCommandResult Dispatch(const UIEditorWorkspaceCommand& command); private: diff --git a/new_editor/src/Core/UIEditorDockHostInteraction.cpp b/new_editor/src/Core/UIEditorDockHostInteraction.cpp new file mode 100644 index 00000000..67eb4478 --- /dev/null +++ b/new_editor/src/Core/UIEditorDockHostInteraction.cpp @@ -0,0 +1,304 @@ +#include + +#include + +#include + +namespace XCEngine::UI::Editor { + +namespace { + +using ::XCEngine::UI::UIInputEvent; +using ::XCEngine::UI::UIInputEventType; +using ::XCEngine::UI::UIRect; +using ::XCEngine::UI::Widgets::BeginUISplitterDrag; +using ::XCEngine::UI::Widgets::EndUISplitterDrag; +using ::XCEngine::UI::Widgets::UpdateUISplitterDrag; +using Widgets::BuildUIEditorDockHostLayout; +using Widgets::FindUIEditorDockHostSplitterLayout; +using Widgets::HitTestUIEditorDockHost; +using Widgets::UIEditorDockHostHitTarget; +using Widgets::UIEditorDockHostHitTargetKind; + +bool ShouldUsePointerPosition(const UIInputEvent& event) { + switch (event.type) { + case UIInputEventType::PointerMove: + case UIInputEventType::PointerEnter: + case UIInputEventType::PointerButtonDown: + case UIInputEventType::PointerButtonUp: + return true; + default: + return false; + } +} + +UIEditorWorkspaceLayoutOperationResult ApplySplitRatio( + UIEditorWorkspaceController& controller, + std::string_view nodeId, + float splitRatio) { + return controller.SetSplitRatio(nodeId, splitRatio); +} + +void SyncHoverTarget( + UIEditorDockHostInteractionState& state, + const Widgets::UIEditorDockHostLayout& layout) { + if (state.splitterDragState.active) { + state.dockHostState.hoveredTarget = { + UIEditorDockHostHitTargetKind::SplitterHandle, + state.dockHostState.activeSplitterNodeId, + {}, + Widgets::UIEditorTabStripInvalidIndex + }; + return; + } + + if (!state.hasPointerPosition) { + state.dockHostState.hoveredTarget = {}; + return; + } + + state.dockHostState.hoveredTarget = + HitTestUIEditorDockHost(layout, state.pointerPosition); +} + +UIEditorWorkspaceCommandResult DispatchPanelCommand( + UIEditorWorkspaceController& controller, + UIEditorWorkspaceCommandKind kind, + std::string panelId) { + UIEditorWorkspaceCommand command = {}; + command.kind = kind; + command.panelId = std::move(panelId); + return controller.Dispatch(command); +} + +} // namespace + +UIEditorDockHostInteractionFrame UpdateUIEditorDockHostInteraction( + UIEditorDockHostInteractionState& state, + UIEditorWorkspaceController& controller, + const UIRect& bounds, + const std::vector& inputEvents, + const Widgets::UIEditorDockHostMetrics& metrics) { + UIEditorDockHostInteractionResult interactionResult = {}; + Widgets::UIEditorDockHostLayout layout = BuildUIEditorDockHostLayout( + bounds, + controller.GetPanelRegistry(), + controller.GetWorkspace(), + controller.GetSession(), + state.dockHostState, + metrics); + SyncHoverTarget(state, layout); + + for (const UIInputEvent& event : inputEvents) { + if (ShouldUsePointerPosition(event)) { + state.pointerPosition = event.position; + state.hasPointerPosition = true; + } else if (event.type == UIInputEventType::PointerLeave) { + state.hasPointerPosition = false; + } + + UIEditorDockHostInteractionResult eventResult = {}; + + switch (event.type) { + case UIInputEventType::FocusGained: + state.dockHostState.focused = true; + break; + + case UIInputEventType::FocusLost: + state.dockHostState.focused = false; + state.dockHostState.hoveredTarget = {}; + if (state.splitterDragState.active) { + EndUISplitterDrag(state.splitterDragState); + state.dockHostState.activeSplitterNodeId.clear(); + eventResult.consumed = true; + eventResult.releasePointerCapture = true; + } + break; + + case UIInputEventType::PointerMove: + case UIInputEventType::PointerEnter: + if (state.splitterDragState.active) { + const auto* splitter = FindUIEditorDockHostSplitterLayout( + layout, + state.dockHostState.activeSplitterNodeId); + if (splitter != nullptr) { + ::XCEngine::UI::Layout::UISplitterLayoutResult draggedLayout = {}; + if (UpdateUISplitterDrag( + state.splitterDragState, + state.pointerPosition, + draggedLayout)) { + eventResult.layoutResult = ApplySplitRatio( + controller, + state.dockHostState.activeSplitterNodeId, + draggedLayout.splitRatio); + eventResult.layoutChanged = + eventResult.layoutResult.status == + UIEditorWorkspaceLayoutOperationStatus::Changed; + } + eventResult.consumed = true; + eventResult.hitTarget.kind = UIEditorDockHostHitTargetKind::SplitterHandle; + eventResult.hitTarget.nodeId = state.dockHostState.activeSplitterNodeId; + } + } + break; + + case UIInputEventType::PointerLeave: + if (!state.splitterDragState.active) { + state.dockHostState.hoveredTarget = {}; + } + break; + + case UIInputEventType::PointerButtonDown: + if (event.pointerButton != ::XCEngine::UI::UIPointerButton::Left) { + break; + } + + if (state.dockHostState.hoveredTarget.kind == + UIEditorDockHostHitTargetKind::SplitterHandle) { + const auto* splitter = FindUIEditorDockHostSplitterLayout( + layout, + state.dockHostState.hoveredTarget.nodeId); + if (splitter != nullptr && + BeginUISplitterDrag( + 1u, + splitter->axis == UIEditorWorkspaceSplitAxis::Horizontal + ? ::XCEngine::UI::Layout::UILayoutAxis::Horizontal + : ::XCEngine::UI::Layout::UILayoutAxis::Vertical, + splitter->bounds, + splitter->splitterLayout, + splitter->constraints, + splitter->metrics, + state.pointerPosition, + state.splitterDragState)) { + state.dockHostState.activeSplitterNodeId = splitter->nodeId; + state.dockHostState.focused = true; + eventResult.consumed = true; + eventResult.requestPointerCapture = true; + eventResult.hitTarget = state.dockHostState.hoveredTarget; + eventResult.activeSplitterNodeId = splitter->nodeId; + } + } else { + state.dockHostState.focused = + state.dockHostState.hoveredTarget.kind != + UIEditorDockHostHitTargetKind::None; + eventResult.consumed = state.dockHostState.focused; + eventResult.hitTarget = state.dockHostState.hoveredTarget; + } + break; + + case UIInputEventType::PointerButtonUp: + if (event.pointerButton != ::XCEngine::UI::UIPointerButton::Left) { + break; + } + + if (state.splitterDragState.active) { + ::XCEngine::UI::Layout::UISplitterLayoutResult draggedLayout = {}; + if (UpdateUISplitterDrag( + state.splitterDragState, + state.pointerPosition, + draggedLayout)) { + eventResult.layoutResult = ApplySplitRatio( + controller, + state.dockHostState.activeSplitterNodeId, + draggedLayout.splitRatio); + eventResult.layoutChanged = + eventResult.layoutResult.status == + UIEditorWorkspaceLayoutOperationStatus::Changed; + } + EndUISplitterDrag(state.splitterDragState); + eventResult.consumed = true; + eventResult.releasePointerCapture = true; + eventResult.activeSplitterNodeId = state.dockHostState.activeSplitterNodeId; + state.dockHostState.activeSplitterNodeId.clear(); + break; + } + + eventResult.hitTarget = state.dockHostState.hoveredTarget; + switch (state.dockHostState.hoveredTarget.kind) { + case UIEditorDockHostHitTargetKind::Tab: + case UIEditorDockHostHitTargetKind::PanelHeader: + case UIEditorDockHostHitTargetKind::PanelBody: + case UIEditorDockHostHitTargetKind::PanelFooter: + eventResult.commandResult = DispatchPanelCommand( + controller, + UIEditorWorkspaceCommandKind::ActivatePanel, + state.dockHostState.hoveredTarget.panelId); + eventResult.commandExecuted = + eventResult.commandResult.status != + UIEditorWorkspaceCommandStatus::Rejected; + eventResult.consumed = true; + state.dockHostState.focused = true; + break; + + case UIEditorDockHostHitTargetKind::TabCloseButton: + case UIEditorDockHostHitTargetKind::PanelCloseButton: + eventResult.commandResult = DispatchPanelCommand( + controller, + UIEditorWorkspaceCommandKind::ClosePanel, + state.dockHostState.hoveredTarget.panelId); + eventResult.commandExecuted = + eventResult.commandResult.status != + UIEditorWorkspaceCommandStatus::Rejected; + eventResult.consumed = true; + state.dockHostState.focused = true; + break; + + case UIEditorDockHostHitTargetKind::TabStripBackground: + state.dockHostState.focused = true; + eventResult.consumed = true; + break; + + case UIEditorDockHostHitTargetKind::None: + default: + state.dockHostState.focused = false; + break; + } + break; + + default: + break; + } + + layout = BuildUIEditorDockHostLayout( + bounds, + controller.GetPanelRegistry(), + controller.GetWorkspace(), + controller.GetSession(), + state.dockHostState, + metrics); + SyncHoverTarget(state, layout); + if (eventResult.hitTarget.kind == UIEditorDockHostHitTargetKind::None) { + eventResult.hitTarget = state.dockHostState.hoveredTarget; + } + + if (eventResult.consumed || + eventResult.commandExecuted || + eventResult.layoutChanged || + eventResult.requestPointerCapture || + eventResult.releasePointerCapture || + eventResult.layoutResult.status != UIEditorWorkspaceLayoutOperationStatus::Rejected || + eventResult.hitTarget.kind != UIEditorDockHostHitTargetKind::None || + !eventResult.activeSplitterNodeId.empty()) { + interactionResult = std::move(eventResult); + } + } + + layout = BuildUIEditorDockHostLayout( + bounds, + controller.GetPanelRegistry(), + controller.GetWorkspace(), + controller.GetSession(), + state.dockHostState, + metrics); + SyncHoverTarget(state, layout); + if (interactionResult.hitTarget.kind == UIEditorDockHostHitTargetKind::None) { + interactionResult.hitTarget = state.dockHostState.hoveredTarget; + } + return { + std::move(layout), + std::move(interactionResult), + state.dockHostState.focused + }; +} + +} // namespace XCEngine::UI::Editor diff --git a/new_editor/src/Core/UIEditorWorkspaceController.cpp b/new_editor/src/Core/UIEditorWorkspaceController.cpp index 8743babd..71ece1e4 100644 --- a/new_editor/src/Core/UIEditorWorkspaceController.cpp +++ b/new_editor/src/Core/UIEditorWorkspaceController.cpp @@ -1,5 +1,6 @@ #include +#include #include namespace XCEngine::UI::Editor { @@ -240,6 +241,55 @@ UIEditorWorkspaceLayoutOperationResult UIEditorWorkspaceController::RestoreSeria return RestoreLayoutSnapshot(loadResult.snapshot); } +UIEditorWorkspaceLayoutOperationResult UIEditorWorkspaceController::SetSplitRatio( + std::string_view nodeId, + float splitRatio) { + const UIEditorWorkspaceControllerValidationResult validation = ValidateState(); + if (!validation.IsValid()) { + return BuildLayoutOperationResult( + UIEditorWorkspaceLayoutOperationStatus::Rejected, + "Controller state invalid: " + validation.message); + } + + if (nodeId.empty()) { + return BuildLayoutOperationResult( + UIEditorWorkspaceLayoutOperationStatus::Rejected, + "SetSplitRatio requires a split node id."); + } + + const UIEditorWorkspaceNode* splitNode = FindUIEditorWorkspaceNode(m_workspace, nodeId); + if (splitNode == nullptr || splitNode->kind != UIEditorWorkspaceNodeKind::Split) { + return BuildLayoutOperationResult( + UIEditorWorkspaceLayoutOperationStatus::Rejected, + "SetSplitRatio target split node is missing."); + } + + if (std::fabs(splitNode->splitRatio - splitRatio) <= 0.0001f) { + return BuildLayoutOperationResult( + UIEditorWorkspaceLayoutOperationStatus::NoOp, + "Split ratio already matches the requested value."); + } + + const UIEditorWorkspaceModel previousWorkspace = m_workspace; + if (!TrySetUIEditorWorkspaceSplitRatio(m_workspace, nodeId, splitRatio)) { + return BuildLayoutOperationResult( + UIEditorWorkspaceLayoutOperationStatus::Rejected, + "Split ratio update rejected."); + } + + const UIEditorWorkspaceControllerValidationResult postValidation = ValidateState(); + if (!postValidation.IsValid()) { + m_workspace = previousWorkspace; + return BuildLayoutOperationResult( + UIEditorWorkspaceLayoutOperationStatus::Rejected, + "Split ratio update produced invalid controller state: " + postValidation.message); + } + + return BuildLayoutOperationResult( + UIEditorWorkspaceLayoutOperationStatus::Changed, + "Split ratio updated."); +} + UIEditorWorkspaceCommandResult UIEditorWorkspaceController::Dispatch( const UIEditorWorkspaceCommand& command) { const UIEditorWorkspaceControllerValidationResult validation = ValidateState(); diff --git a/tests/UI/Editor/integration/CMakeLists.txt b/tests/UI/Editor/integration/CMakeLists.txt index 6b16377e..96fec18a 100644 --- a/tests/UI/Editor/integration/CMakeLists.txt +++ b/tests/UI/Editor/integration/CMakeLists.txt @@ -8,6 +8,7 @@ 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_dock_host_basic_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 f401aa8c..510b29c3 100644 --- a/tests/UI/Editor/integration/README.md +++ b/tests/UI/Editor/integration/README.md @@ -17,6 +17,7 @@ Layout: - `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/dock_host_basic/`: DockHost interaction contract 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 @@ -47,6 +48,11 @@ Scenarios: 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.dock_host_basic` + Build target: `editor_ui_dock_host_basic_validation` + Executable: `XCUIEditorDockHostBasicValidation.exe` + Scope: `UpdateUIEditorDockHostInteraction(...)` basic contract only; splitter drag, tab activate/close, standalone panel activate/close, pointer capture/release request, workspace active-panel sync + - `editor.shell.menu_bar_basic` Build target: `editor_ui_menu_bar_basic_validation` Executable: `XCUIEditorMenuBarBasicValidation.exe` @@ -129,6 +135,9 @@ Selected controls: - `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/dock_host_basic/` + Drag `root-split`, click `Document A`, close `Document B`, click `Details`, close `Console`, inspect `Hover / Result / Active Panel / Visible Panels / Capture / split ratio`, 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 49e54bdb..fe4152d2 100644 --- a/tests/UI/Editor/integration/shell/CMakeLists.txt +++ b/tests/UI/Editor/integration/shell/CMakeLists.txt @@ -10,6 +10,9 @@ endif() if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/menu_bar_basic/CMakeLists.txt") add_subdirectory(menu_bar_basic) endif() +if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/dock_host_basic/CMakeLists.txt") + add_subdirectory(dock_host_basic) +endif() if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/status_bar_basic/CMakeLists.txt") add_subdirectory(status_bar_basic) endif() diff --git a/tests/UI/Editor/integration/shell/dock_host_basic/CMakeLists.txt b/tests/UI/Editor/integration/shell/dock_host_basic/CMakeLists.txt new file mode 100644 index 00000000..148840c9 --- /dev/null +++ b/tests/UI/Editor/integration/shell/dock_host_basic/CMakeLists.txt @@ -0,0 +1,30 @@ +add_executable(editor_ui_dock_host_basic_validation WIN32 + main.cpp +) + +target_include_directories(editor_ui_dock_host_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_dock_host_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_dock_host_basic_validation PRIVATE /utf-8 /FS) + set_property(TARGET editor_ui_dock_host_basic_validation PROPERTY + MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>DLL") +endif() + +target_link_libraries(editor_ui_dock_host_basic_validation PRIVATE + XCUIEditorLib + XCUIEditorHost +) + +set_target_properties(editor_ui_dock_host_basic_validation PROPERTIES + OUTPUT_NAME "XCUIEditorDockHostBasicValidation" +) diff --git a/tests/UI/Editor/integration/shell/dock_host_basic/captures/.gitkeep b/tests/UI/Editor/integration/shell/dock_host_basic/captures/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/tests/UI/Editor/integration/shell/dock_host_basic/captures/.gitkeep @@ -0,0 +1 @@ + diff --git a/tests/UI/Editor/integration/shell/dock_host_basic/main.cpp b/tests/UI/Editor/integration/shell/dock_host_basic/main.cpp new file mode 100644 index 00000000..682f426e --- /dev/null +++ b/tests/UI/Editor/integration/shell/dock_host_basic/main.cpp @@ -0,0 +1,680 @@ +#ifndef NOMINMAX +#define NOMINMAX +#endif + +#include +#include +#include +#include +#include "Host/AutoScreenshot.h" +#include "Host/NativeRenderer.h" + +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +#ifndef XCENGINE_EDITOR_UI_TESTS_REPO_ROOT +#define XCENGINE_EDITOR_UI_TESTS_REPO_ROOT "." +#endif + +namespace { + +using XCEngine::UI::UIColor; +using XCEngine::UI::UIDrawData; +using XCEngine::UI::UIDrawList; +using XCEngine::UI::UIInputEvent; +using XCEngine::UI::UIInputEventType; +using XCEngine::UI::UIPoint; +using XCEngine::UI::UIPointerButton; +using XCEngine::UI::UIRect; +using XCEngine::UI::Editor::BuildDefaultUIEditorWorkspaceController; +using XCEngine::UI::Editor::BuildUIEditorWorkspacePanel; +using XCEngine::UI::Editor::BuildUIEditorWorkspaceSplit; +using XCEngine::UI::Editor::BuildUIEditorWorkspaceTabStack; +using XCEngine::UI::Editor::CollectUIEditorWorkspaceVisiblePanels; +using XCEngine::UI::Editor::GetUIEditorWorkspaceCommandStatusName; +using XCEngine::UI::Editor::GetUIEditorWorkspaceLayoutOperationStatusName; +using XCEngine::UI::Editor::Host::AutoScreenshotController; +using XCEngine::UI::Editor::Host::NativeRenderer; +using XCEngine::UI::Editor::UIEditorDockHostInteractionFrame; +using XCEngine::UI::Editor::UIEditorDockHostInteractionResult; +using XCEngine::UI::Editor::UIEditorDockHostInteractionState; +using XCEngine::UI::Editor::UIEditorPanelRegistry; +using XCEngine::UI::Editor::UIEditorWorkspaceController; +using XCEngine::UI::Editor::UIEditorWorkspaceLayoutOperationStatus; +using XCEngine::UI::Editor::UIEditorWorkspaceModel; +using XCEngine::UI::Editor::UIEditorWorkspaceSplitAxis; +using XCEngine::UI::Editor::UIEditorWorkspaceCommandStatus; +using XCEngine::UI::Editor::UpdateUIEditorDockHostInteraction; +using XCEngine::UI::Editor::Widgets::UIEditorDockHostHitTarget; +using XCEngine::UI::Editor::Widgets::UIEditorDockHostHitTargetKind; +using XCEngine::UI::Editor::Widgets::AppendUIEditorDockHostBackground; +using XCEngine::UI::Editor::Widgets::AppendUIEditorDockHostForeground; + +constexpr const wchar_t* kWindowClassName = L"XCUIEditorDockHostBasicValidation"; +constexpr const wchar_t* kWindowTitle = L"XCUI Editor | DockHost Basic"; + +constexpr UIColor kWindowBg(0.11f, 0.11f, 0.11f, 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 kPreviewBg(0.13f, 0.13f, 0.13f, 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.47f, 0.47f, 0.47f, 1.0f); +constexpr UIColor kSuccess(0.48f, 0.72f, 0.52f, 1.0f); +constexpr UIColor kWarning(0.82f, 0.67f, 0.35f, 1.0f); +constexpr UIColor kDanger(0.78f, 0.36f, 0.36f, 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; +} + +std::string FormatBool(bool value) { + return value ? "true" : "false"; +} + +std::string FormatFloat(float value, int precision = 2) { + std::ostringstream stream = {}; + stream.setf(std::ios::fixed, std::ios::floatfield); + stream.precision(precision); + stream << value; + return stream.str(); +} + +std::string DescribeHitTarget(const UIEditorDockHostHitTarget& target) { + switch (target.kind) { + case UIEditorDockHostHitTargetKind::SplitterHandle: + return "Splitter: " + target.nodeId; + case UIEditorDockHostHitTargetKind::TabStripBackground: + return "TabStripBackground: " + target.nodeId; + case UIEditorDockHostHitTargetKind::Tab: + return "Tab: " + target.panelId; + case UIEditorDockHostHitTargetKind::TabCloseButton: + return "TabClose: " + target.panelId; + case UIEditorDockHostHitTargetKind::PanelHeader: + return "PanelHeader: " + target.panelId; + case UIEditorDockHostHitTargetKind::PanelBody: + return "PanelBody: " + target.panelId; + case UIEditorDockHostHitTargetKind::PanelFooter: + return "PanelFooter: " + target.panelId; + case UIEditorDockHostHitTargetKind::PanelCloseButton: + return "PanelClose: " + target.panelId; + case UIEditorDockHostHitTargetKind::None: + default: + return "None"; + } +} + +std::string JoinVisiblePanelIds(const UIEditorWorkspaceController& controller) { + const auto panels = CollectUIEditorWorkspaceVisiblePanels( + controller.GetWorkspace(), + controller.GetSession()); + if (panels.empty()) { + return "(none)"; + } + + std::ostringstream stream = {}; + for (std::size_t index = 0; index < panels.size(); ++index) { + if (index > 0u) { + stream << ", "; + } + stream << panels[index].panelId; + } + 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); +} + +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 }, + { "console", "Console", {}, true, true, true } + }; + return registry; +} + +UIEditorWorkspaceModel BuildWorkspace() { + UIEditorWorkspaceModel workspace = {}; + workspace.root = BuildUIEditorWorkspaceSplit( + "root-split", + UIEditorWorkspaceSplitAxis::Horizontal, + 0.5f, + BuildUIEditorWorkspaceTabStack( + "document-tabs", + { + BuildUIEditorWorkspacePanel("doc-a-node", "doc-a", "Document A", true), + BuildUIEditorWorkspacePanel("doc-b-node", "doc-b", "Document B", true) + }, + 1u), + BuildUIEditorWorkspaceSplit( + "right-split", + UIEditorWorkspaceSplitAxis::Vertical, + 0.6f, + BuildUIEditorWorkspacePanel("details-node", "details", "Details", true), + BuildUIEditorWorkspacePanel("console-node", "console", "Console", true))); + workspace.activePanelId = "doc-b"; + return workspace; +} + +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->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_LBUTTONUP: + if (app != nullptr) { + app->HandleLeftButtonUp( + 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_CAPTURECHANGED: + if (app != nullptr && + app->m_interactionState.splitterDragState.active && + reinterpret_cast(lParam) != hwnd) { + 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"); + 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/dock_host_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, + 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 Shutdown() { + if (GetCapture() == m_hwnd) { + ReleaseCapture(); + } + 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 ResetScenario() { + if (GetCapture() == m_hwnd) { + ReleaseCapture(); + } + m_controller = BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace()); + m_interactionState = {}; + m_cachedFrame = {}; + m_pendingInputEvents.clear(); + m_lastStatus = "Ready"; + m_lastMessage = "等待交互。这里只验证 DockHost 基础交互 contract,不接旧 editor 业务。"; + m_lastColor = kWarning; + } + + void 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, 236.0f); + m_controlsRect = UIRect(padding, 272.0f, leftWidth, 116.0f); + m_stateRect = UIRect(padding, 404.0f, leftWidth, height - 424.0f); + m_previewRect = UIRect( + leftWidth + padding * 2.0f, + padding, + width - leftWidth - padding * 3.0f, + height - padding * 2.0f); + m_dockHostRect = 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 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 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 HandleLeftButtonUp(float x, float y) { + UIInputEvent event = {}; + event.type = UIInputEventType::PointerButtonUp; + event.position = UIPoint(x, y); + event.pointerButton = UIPointerButton::Left; + m_pendingInputEvents.push_back(event); + } + + void ExecuteAction(ActionId action) { + if (action == ActionId::Reset) { + ResetScenario(); + m_lastStatus = "Ready"; + m_lastMessage = "场景状态已重置。请重新检查 splitter drag / tab close / panel close / active panel sync。"; + m_lastColor = kWarning; + return; + } + + m_autoScreenshot.RequestCapture("manual_button"); + m_lastStatus = "Ready"; + m_lastMessage = "截图已排队,输出到 tests/UI/Editor/integration/shell/dock_host_basic/captures/。"; + m_lastColor = kWarning; + } + + void ApplyHostCaptureRequests(const UIEditorDockHostInteractionResult& result) { + if (result.requestPointerCapture && GetCapture() != m_hwnd) { + SetCapture(m_hwnd); + } + if (result.releasePointerCapture && GetCapture() == m_hwnd) { + ReleaseCapture(); + } + } + + void SetInteractionResult(const UIEditorDockHostInteractionResult& result) { + if (result.layoutResult.status != UIEditorWorkspaceLayoutOperationStatus::Rejected) { + m_lastStatus = std::string(GetUIEditorWorkspaceLayoutOperationStatusName(result.layoutResult.status)); + m_lastMessage = result.layoutResult.message.empty() + ? std::string("Layout 操作已完成。") + : result.layoutResult.message; + m_lastColor = + result.layoutResult.status == UIEditorWorkspaceLayoutOperationStatus::Changed + ? kSuccess + : kWarning; + return; + } + + if (result.commandResult.status != UIEditorWorkspaceCommandStatus::Rejected) { + m_lastStatus = std::string(GetUIEditorWorkspaceCommandStatusName(result.commandResult.status)); + m_lastMessage = result.commandResult.message.empty() + ? std::string("Workspace 命令已完成。") + : result.commandResult.message; + m_lastColor = + result.commandResult.status == UIEditorWorkspaceCommandStatus::Changed + ? kSuccess + : kWarning; + return; + } + + if (result.requestPointerCapture) { + m_lastStatus = "Capture"; + m_lastMessage = "Splitter drag 已开始,宿主已收到 pointer capture 请求。"; + m_lastColor = kSuccess; + return; + } + + if (result.releasePointerCapture) { + m_lastStatus = "Release"; + m_lastMessage = "Splitter drag 已结束,宿主已执行 pointer release。"; + m_lastColor = kWarning; + return; + } + + if (result.hitTarget.kind != UIEditorDockHostHitTargetKind::None) { + m_lastStatus = "Hover"; + m_lastMessage = "当前 hover 命中: " + DescribeHitTarget(result.hitTarget); + m_lastColor = kTextMuted; + return; + } + + if (result.consumed) { + m_lastStatus = "Consumed"; + m_lastMessage = "这次输入被 DockHost 基础交互层消费。"; + m_lastColor = kWarning; + } + } + + void RenderFrame() { + UpdateLayout(); + m_cachedFrame = UpdateUIEditorDockHostInteraction( + m_interactionState, + m_controller, + m_dockHostRect, + m_pendingInputEvents); + m_pendingInputEvents.clear(); + ApplyHostCaptureRequests(m_cachedFrame.result); + SetInteractionResult(m_cachedFrame.result); + + 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("DockHostBasic"); + drawList.AddFilledRect(UIRect(0.0f, 0.0f, width, height), kWindowBg); + + DrawCard(drawList, m_introRect, "这个测试验证什么功能", "只验证 DockHost 基础交互 contract,不做 editor 业务面板。"); + drawList.AddText(UIPoint(m_introRect.x + 16.0f, m_introRect.y + 72.0f), "1. 验证 splitter drag 是否只通过 DockHostInteraction + WorkspaceController 完成。", kTextPrimary, 12.0f); + drawList.AddText(UIPoint(m_introRect.x + 16.0f, m_introRect.y + 94.0f), "2. 验证 tab activate / tab close / standalone panel activate / panel close。", kTextPrimary, 12.0f); + drawList.AddText(UIPoint(m_introRect.x + 16.0f, m_introRect.y + 116.0f), "3. 验证 active panel、visible panels、split ratio 是否统一收口到 controller。", kTextPrimary, 12.0f); + drawList.AddText(UIPoint(m_introRect.x + 16.0f, m_introRect.y + 138.0f), "4. 验证 pointer capture / release 请求是否由 contract 明确返回。", kTextPrimary, 12.0f); + drawList.AddText(UIPoint(m_introRect.x + 16.0f, m_introRect.y + 162.0f), "建议操作: 先拖中间 splitter,再点 Document A。", kTextWeak, 11.0f); + drawList.AddText(UIPoint(m_introRect.x + 16.0f, m_introRect.y + 180.0f), "然后关闭 Document B,最后点 Details 与 Console 的 X。", kTextWeak, 11.0f); + + DrawCard(drawList, m_controlsRect, "操作", "只保留当前场景必要按钮。"); + for (const ButtonState& button : m_buttons) { + DrawButton(drawList, button); + } + + DrawCard(drawList, m_stateRect, "状态", "重点检查 DockHost 基础交互当前状态。"); + float stateY = m_stateRect.y + 66.0f; + auto addStateLine = [&](std::string text, const UIColor& color = kTextPrimary, float fontSize = 12.0f) { + drawList.AddText(UIPoint(m_stateRect.x + 16.0f, stateY), std::move(text), color, fontSize); + stateY += 20.0f; + }; + + addStateLine("Hover: " + DescribeHitTarget(m_interactionState.dockHostState.hoveredTarget), kTextPrimary, 11.0f); + 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("Active Panel: " + (m_controller.GetWorkspace().activePanelId.empty() ? std::string("(none)") : m_controller.GetWorkspace().activePanelId)); + addStateLine("Visible Panels: " + JoinVisiblePanelIds(m_controller), kTextWeak, 11.0f); + addStateLine("Focused: " + FormatBool(m_cachedFrame.focused), m_cachedFrame.focused ? kSuccess : kTextMuted); + addStateLine("Capture: " + FormatBool(GetCapture() == m_hwnd), GetCapture() == m_hwnd ? kSuccess : kTextMuted); + addStateLine( + "Dragging Splitter: " + + (m_interactionState.dockHostState.activeSplitterNodeId.empty() + ? std::string("(none)") + : m_interactionState.dockHostState.activeSplitterNodeId), + kTextWeak, + 11.0f); + addStateLine("root-split ratio: " + FormatFloat(m_controller.GetWorkspace().root.splitRatio), kTextWeak, 11.0f); + if (m_controller.GetWorkspace().root.children.size() > 1u && + m_controller.GetWorkspace().root.children[1].children.size() > 1u) { + addStateLine( + "right-split ratio: " + + FormatFloat(m_controller.GetWorkspace().root.children[1].splitRatio), + kTextWeak, + 11.0f); + } + addStateLine( + "截图: " + + (m_autoScreenshot.HasPendingCapture() + ? std::string("截图排队中...") + : (m_autoScreenshot.GetLastCaptureSummary().empty() + ? std::string("F12 或 按钮 -> captures/") + : m_autoScreenshot.GetLastCaptureSummary())), + kTextWeak, + 11.0f); + + DrawCard(drawList, m_previewRect, "Preview", "真实 DockHost 交互预览,只看基础层,不接旧 editor。"); + drawList.AddFilledRect(m_dockHostRect, kPreviewBg, 8.0f); + AppendUIEditorDockHostBackground(drawList, m_cachedFrame.layout); + AppendUIEditorDockHostForeground(drawList, m_cachedFrame.layout); + + 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 = {}; + UIEditorWorkspaceController m_controller = {}; + UIEditorDockHostInteractionState m_interactionState = {}; + UIEditorDockHostInteractionFrame m_cachedFrame = {}; + std::vector m_pendingInputEvents = {}; + std::vector m_buttons = {}; + UIRect m_introRect = {}; + UIRect m_controlsRect = {}; + UIRect m_stateRect = {}; + UIRect m_previewRect = {}; + UIRect m_dockHostRect = {}; + bool m_trackingMouseLeave = false; + std::string m_lastStatus = {}; + std::string m_lastMessage = {}; + UIColor m_lastColor = kTextMuted; +}; + +} // 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 cd6c4bc9..9ac88a32 100644 --- a/tests/UI/Editor/unit/CMakeLists.txt +++ b/tests/UI/Editor/unit/CMakeLists.txt @@ -4,6 +4,7 @@ set(EDITOR_UI_UNIT_TEST_SOURCES test_structured_editor_shell.cpp test_ui_editor_command_dispatcher.cpp test_ui_editor_command_registry.cpp + test_ui_editor_dock_host_interaction.cpp test_ui_editor_menu_model.cpp test_ui_editor_menu_session.cpp test_ui_editor_menu_bar.cpp diff --git a/tests/UI/Editor/unit/test_ui_editor_dock_host_interaction.cpp b/tests/UI/Editor/unit/test_ui_editor_dock_host_interaction.cpp new file mode 100644 index 00000000..af494701 --- /dev/null +++ b/tests/UI/Editor/unit/test_ui_editor_dock_host_interaction.cpp @@ -0,0 +1,302 @@ +#include + +#include +#include +#include + +namespace { + +using XCEngine::UI::UIInputEvent; +using XCEngine::UI::UIInputEventType; +using XCEngine::UI::UIPoint; +using XCEngine::UI::UIRect; +using XCEngine::UI::UIPointerButton; +using XCEngine::UI::Editor::BuildDefaultUIEditorWorkspaceController; +using XCEngine::UI::Editor::BuildUIEditorWorkspacePanel; +using XCEngine::UI::Editor::BuildUIEditorWorkspaceSplit; +using XCEngine::UI::Editor::BuildUIEditorWorkspaceTabStack; +using XCEngine::UI::Editor::FindUIEditorPanelSessionState; +using XCEngine::UI::Editor::UIEditorDockHostInteractionState; +using XCEngine::UI::Editor::UIEditorPanelRegistry; +using XCEngine::UI::Editor::UIEditorWorkspaceModel; +using XCEngine::UI::Editor::UIEditorWorkspaceSplitAxis; +using XCEngine::UI::Editor::UpdateUIEditorDockHostInteraction; +using XCEngine::UI::Editor::Widgets::UIEditorDockHostHitTargetKind; + +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 }, + { "console", "Console", {}, true, true, true } + }; + return registry; +} + +UIEditorWorkspaceModel BuildWorkspace() { + UIEditorWorkspaceModel workspace = {}; + workspace.root = BuildUIEditorWorkspaceSplit( + "root-split", + UIEditorWorkspaceSplitAxis::Horizontal, + 0.5f, + BuildUIEditorWorkspaceTabStack( + "document-tabs", + { + BuildUIEditorWorkspacePanel("doc-a-node", "doc-a", "Document A", true), + BuildUIEditorWorkspacePanel("doc-b-node", "doc-b", "Document B", true) + }, + 1u), + BuildUIEditorWorkspaceSplit( + "right-split", + UIEditorWorkspaceSplitAxis::Vertical, + 0.6f, + BuildUIEditorWorkspacePanel("details-node", "details", "Details", true), + BuildUIEditorWorkspacePanel("console-node", "console", "Console", true))); + workspace.activePanelId = "doc-b"; + return workspace; +} + +UIInputEvent MakePointerMove(float x, float y) { + UIInputEvent event = {}; + event.type = UIInputEventType::PointerMove; + event.position = UIPoint(x, y); + return event; +} + +UIInputEvent MakePointerDown(float x, float y) { + UIInputEvent event = {}; + event.type = UIInputEventType::PointerButtonDown; + event.position = UIPoint(x, y); + event.pointerButton = UIPointerButton::Left; + return event; +} + +UIInputEvent MakePointerUp(float x, float y) { + UIInputEvent event = {}; + event.type = UIInputEventType::PointerButtonUp; + event.position = UIPoint(x, y); + event.pointerButton = UIPointerButton::Left; + return event; +} + +UIInputEvent MakeFocusLost() { + UIInputEvent event = {}; + event.type = UIInputEventType::FocusLost; + return event; +} + +UIPoint RectCenter(const UIRect& rect) { + return UIPoint(rect.x + rect.width * 0.5f, rect.y + rect.height * 0.5f); +} + +} // namespace + +TEST(UIEditorDockHostInteractionTest, SplitterDragUpdatesWorkspaceSplitRatio) { + auto controller = + BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace()); + UIEditorDockHostInteractionState state = {}; + + auto frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + { MakePointerMove(396.0f, 120.0f) }); + EXPECT_EQ(frame.result.hitTarget.kind, UIEditorDockHostHitTargetKind::SplitterHandle); + EXPECT_EQ(frame.result.hitTarget.nodeId, "root-split"); + + frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + { MakePointerDown(396.0f, 120.0f) }); + EXPECT_TRUE(frame.result.consumed); + EXPECT_TRUE(frame.result.requestPointerCapture); + EXPECT_EQ(frame.result.activeSplitterNodeId, "root-split"); + + frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + { MakePointerMove(520.0f, 120.0f) }); + EXPECT_TRUE(frame.result.consumed); + EXPECT_TRUE(frame.result.layoutChanged); + EXPECT_GT(controller.GetWorkspace().root.splitRatio, 0.5f); + + frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + { MakePointerUp(520.0f, 120.0f) }); + EXPECT_TRUE(frame.result.consumed); + EXPECT_TRUE(frame.result.releasePointerCapture); + EXPECT_TRUE(state.dockHostState.activeSplitterNodeId.empty()); +} + +TEST(UIEditorDockHostInteractionTest, FocusLostWhileDraggingSplitterRequestsPointerRelease) { + auto controller = + BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace()); + UIEditorDockHostInteractionState state = {}; + + auto frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + { MakePointerMove(396.0f, 120.0f) }); + EXPECT_EQ(frame.result.hitTarget.kind, UIEditorDockHostHitTargetKind::SplitterHandle); + + frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + { MakePointerDown(396.0f, 120.0f) }); + EXPECT_TRUE(frame.result.requestPointerCapture); + EXPECT_FALSE(state.dockHostState.activeSplitterNodeId.empty()); + + frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + { MakeFocusLost() }); + EXPECT_TRUE(frame.result.consumed); + EXPECT_TRUE(frame.result.releasePointerCapture); + EXPECT_TRUE(state.dockHostState.activeSplitterNodeId.empty()); +} + +TEST(UIEditorDockHostInteractionTest, ClickingTabActivatesTargetPanel) { + auto controller = + BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace()); + UIEditorDockHostInteractionState state = {}; + + auto frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + {}); + ASSERT_EQ(frame.layout.tabStacks.size(), 1u); + const UIRect docARect = frame.layout.tabStacks.front().tabStripLayout.tabHeaderRects[0]; + + frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + { MakePointerMove(RectCenter(docARect).x, RectCenter(docARect).y) }); + EXPECT_EQ(frame.result.hitTarget.kind, UIEditorDockHostHitTargetKind::Tab); + EXPECT_EQ(frame.result.hitTarget.panelId, "doc-a"); + + frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + { MakePointerUp(RectCenter(docARect).x, RectCenter(docARect).y) }); + EXPECT_TRUE(frame.result.consumed); + EXPECT_TRUE(frame.result.commandExecuted); + EXPECT_EQ(controller.GetWorkspace().activePanelId, "doc-a"); + ASSERT_EQ(frame.layout.tabStacks.size(), 1u); + EXPECT_EQ(frame.layout.tabStacks.front().selectedPanelId, "doc-a"); +} + +TEST(UIEditorDockHostInteractionTest, ClickingTabCloseClosesPanelThroughController) { + auto controller = + BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace()); + UIEditorDockHostInteractionState state = {}; + + auto frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + {}); + ASSERT_EQ(frame.layout.tabStacks.size(), 1u); + const UIRect closeRect = frame.layout.tabStacks.front().tabStripLayout.closeButtonRects[1]; + const UIPoint closeCenter = RectCenter(closeRect); + + frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + { MakePointerMove(closeCenter.x, closeCenter.y) }); + EXPECT_EQ(frame.result.hitTarget.kind, UIEditorDockHostHitTargetKind::TabCloseButton); + EXPECT_EQ(frame.result.hitTarget.panelId, "doc-b"); + + frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + { MakePointerUp(closeCenter.x, closeCenter.y) }); + EXPECT_TRUE(frame.result.consumed); + EXPECT_TRUE(frame.result.commandExecuted); + + const auto* panelState = FindUIEditorPanelSessionState(controller.GetSession(), "doc-b"); + ASSERT_NE(panelState, nullptr); + EXPECT_FALSE(panelState->open); + EXPECT_FALSE(panelState->visible); + EXPECT_EQ(controller.GetWorkspace().activePanelId, "doc-a"); +} + +TEST(UIEditorDockHostInteractionTest, ClickingStandalonePanelBodyActivatesTargetPanel) { + auto controller = + BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace()); + UIEditorDockHostInteractionState state = {}; + + auto frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + {}); + ASSERT_EQ(frame.layout.panels.size(), 2u); + const UIRect detailsBodyRect = frame.layout.panels[0].frameLayout.bodyRect; + const UIPoint detailsBodyCenter = RectCenter(detailsBodyRect); + + frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + { MakePointerMove(detailsBodyCenter.x, detailsBodyCenter.y) }); + EXPECT_EQ(frame.result.hitTarget.kind, UIEditorDockHostHitTargetKind::PanelBody); + EXPECT_EQ(frame.result.hitTarget.panelId, "details"); + + frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + { MakePointerUp(detailsBodyCenter.x, detailsBodyCenter.y) }); + EXPECT_TRUE(frame.result.consumed); + EXPECT_TRUE(frame.result.commandExecuted); + EXPECT_EQ(controller.GetWorkspace().activePanelId, "details"); +} + +TEST(UIEditorDockHostInteractionTest, ClickingStandalonePanelCloseClosesPanelThroughController) { + auto controller = + BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace()); + UIEditorDockHostInteractionState state = {}; + + auto frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + {}); + ASSERT_EQ(frame.layout.panels.size(), 2u); + const UIRect closeRect = frame.layout.panels[1].frameLayout.closeButtonRect; + const UIPoint closeCenter = RectCenter(closeRect); + + frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + { MakePointerMove(closeCenter.x, closeCenter.y) }); + EXPECT_EQ(frame.result.hitTarget.kind, UIEditorDockHostHitTargetKind::PanelCloseButton); + EXPECT_EQ(frame.result.hitTarget.panelId, "console"); + + frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + { MakePointerUp(closeCenter.x, closeCenter.y) }); + EXPECT_TRUE(frame.result.consumed); + EXPECT_TRUE(frame.result.commandExecuted); + + const auto* panelState = FindUIEditorPanelSessionState(controller.GetSession(), "console"); + ASSERT_NE(panelState, nullptr); + EXPECT_FALSE(panelState->open); + EXPECT_FALSE(panelState->visible); +}