From 0d6a4113e730e88216e83bce31d18ee8b1391400 Mon Sep 17 00:00:00 2001 From: ssdfasd <2156608475@qq.com> Date: Tue, 7 Apr 2026 11:12:18 +0800 Subject: [PATCH] Add workspace interaction coordination contract --- new_editor/CMakeLists.txt | 2 + .../Core/UIEditorWorkspaceInteraction.h | 46 ++ .../src/Core/UIEditorWorkspaceInteraction.cpp | 86 +++ tests/UI/Editor/integration/CMakeLists.txt | 1 + tests/UI/Editor/integration/README.md | 9 + .../Editor/integration/shell/CMakeLists.txt | 3 + .../CMakeLists.txt | 30 + .../captures/.gitkeep | 0 .../workspace_interaction_basic/main.cpp | 714 ++++++++++++++++++ tests/UI/Editor/unit/CMakeLists.txt | 1 + .../test_ui_editor_workspace_interaction.cpp | 248 ++++++ 11 files changed, 1140 insertions(+) create mode 100644 new_editor/include/XCEditor/Core/UIEditorWorkspaceInteraction.h create mode 100644 new_editor/src/Core/UIEditorWorkspaceInteraction.cpp create mode 100644 tests/UI/Editor/integration/shell/workspace_interaction_basic/CMakeLists.txt create mode 100644 tests/UI/Editor/integration/shell/workspace_interaction_basic/captures/.gitkeep create mode 100644 tests/UI/Editor/integration/shell/workspace_interaction_basic/main.cpp create mode 100644 tests/UI/Editor/unit/test_ui_editor_workspace_interaction.cpp diff --git a/new_editor/CMakeLists.txt b/new_editor/CMakeLists.txt index 26b4a9ee..e7121cdb 100644 --- a/new_editor/CMakeLists.txt +++ b/new_editor/CMakeLists.txt @@ -15,6 +15,7 @@ add_library(XCUIEditorLib STATIC src/Core/EditorShellAsset.cpp src/Core/UIEditorCommandDispatcher.cpp src/Core/UIEditorCommandRegistry.cpp + src/Core/UIEditorDockHostInteraction.cpp src/Core/UIEditorMenuModel.cpp src/Core/UIEditorMenuSession.cpp src/Core/UIEditorPanelRegistry.cpp @@ -24,6 +25,7 @@ add_library(XCUIEditorLib STATIC src/Core/UIEditorViewportInputBridge.cpp src/Core/UIEditorViewportShell.cpp src/Core/UIEditorWorkspaceCompose.cpp + src/Core/UIEditorWorkspaceInteraction.cpp src/Core/UIEditorWorkspaceLayoutPersistence.cpp src/Core/UIEditorWorkspaceController.cpp src/Core/UIEditorWorkspaceModel.cpp diff --git a/new_editor/include/XCEditor/Core/UIEditorWorkspaceInteraction.h b/new_editor/include/XCEditor/Core/UIEditorWorkspaceInteraction.h new file mode 100644 index 00000000..5ad9a032 --- /dev/null +++ b/new_editor/include/XCEditor/Core/UIEditorWorkspaceInteraction.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include + +#include + +#include +#include + +namespace XCEngine::UI::Editor { + +struct UIEditorWorkspaceInteractionModel { + std::vector workspacePresentations = {}; +}; + +struct UIEditorWorkspaceInteractionState { + UIEditorDockHostInteractionState dockHostInteractionState = {}; + UIEditorWorkspaceComposeState composeState = {}; +}; + +struct UIEditorWorkspaceInteractionResult { + bool consumed = false; + bool requestPointerCapture = false; + bool releasePointerCapture = false; + bool viewportInteractionChanged = false; + std::string viewportPanelId = {}; + UIEditorViewportInputBridgeFrame viewportInputFrame = {}; + UIEditorDockHostInteractionResult dockHostResult = {}; +}; + +struct UIEditorWorkspaceInteractionFrame { + UIEditorDockHostInteractionFrame dockHostFrame = {}; + UIEditorWorkspaceComposeFrame composeFrame = {}; + UIEditorWorkspaceInteractionResult result = {}; +}; + +UIEditorWorkspaceInteractionFrame UpdateUIEditorWorkspaceInteraction( + UIEditorWorkspaceInteractionState& state, + UIEditorWorkspaceController& controller, + const ::XCEngine::UI::UIRect& bounds, + const UIEditorWorkspaceInteractionModel& model, + const std::vector<::XCEngine::UI::UIInputEvent>& inputEvents, + const Widgets::UIEditorDockHostMetrics& dockHostMetrics = {}); + +} // namespace XCEngine::UI::Editor diff --git a/new_editor/src/Core/UIEditorWorkspaceInteraction.cpp b/new_editor/src/Core/UIEditorWorkspaceInteraction.cpp new file mode 100644 index 00000000..df282b2c --- /dev/null +++ b/new_editor/src/Core/UIEditorWorkspaceInteraction.cpp @@ -0,0 +1,86 @@ +#include + +#include + +namespace XCEngine::UI::Editor { + +namespace { + +bool HasMeaningfulViewportInputFrame(const UIEditorViewportInputBridgeFrame& frame) { + return frame.pointerMoved || + frame.pointerPressedInside || + frame.pointerReleasedInside || + frame.focusGained || + frame.focusLost || + frame.captureStarted || + frame.captureEnded || + frame.wheelDelta != 0.0f || + !frame.pressedKeyCodes.empty() || + !frame.releasedKeyCodes.empty() || + !frame.characters.empty(); +} + +} // namespace + +UIEditorWorkspaceInteractionFrame UpdateUIEditorWorkspaceInteraction( + UIEditorWorkspaceInteractionState& state, + UIEditorWorkspaceController& controller, + const ::XCEngine::UI::UIRect& bounds, + const UIEditorWorkspaceInteractionModel& model, + const std::vector<::XCEngine::UI::UIInputEvent>& inputEvents, + const Widgets::UIEditorDockHostMetrics& dockHostMetrics) { + UIEditorWorkspaceInteractionFrame frame = {}; + frame.dockHostFrame = UpdateUIEditorDockHostInteraction( + state.dockHostInteractionState, + controller, + bounds, + inputEvents, + dockHostMetrics); + frame.composeFrame = UpdateUIEditorWorkspaceCompose( + state.composeState, + bounds, + controller.GetPanelRegistry(), + controller.GetWorkspace(), + controller.GetSession(), + model.workspacePresentations, + inputEvents, + state.dockHostInteractionState.dockHostState, + dockHostMetrics); + + frame.result.dockHostResult = frame.dockHostFrame.result; + frame.result.consumed = frame.dockHostFrame.result.consumed; + frame.result.requestPointerCapture = frame.dockHostFrame.result.requestPointerCapture; + frame.result.releasePointerCapture = frame.dockHostFrame.result.releasePointerCapture; + + for (const UIEditorWorkspaceViewportComposeFrame& viewportFrame : frame.composeFrame.viewportFrames) { + const UIEditorViewportInputBridgeFrame& inputFrame = + viewportFrame.viewportShellFrame.inputFrame; + const bool meaningfulInput = HasMeaningfulViewportInputFrame(inputFrame); + + if (!meaningfulInput && + !inputFrame.captureStarted && + !inputFrame.captureEnded) { + continue; + } + + if (frame.result.viewportPanelId.empty()) { + frame.result.viewportPanelId = viewportFrame.panelId; + frame.result.viewportInputFrame = inputFrame; + } + frame.result.viewportInteractionChanged = + frame.result.viewportInteractionChanged || meaningfulInput; + frame.result.requestPointerCapture = + frame.result.requestPointerCapture || inputFrame.captureStarted; + frame.result.releasePointerCapture = + frame.result.releasePointerCapture || inputFrame.captureEnded; + } + + frame.result.consumed = + frame.result.consumed || + frame.result.viewportInteractionChanged || + frame.result.requestPointerCapture || + frame.result.releasePointerCapture; + return frame; +} + +} // namespace XCEngine::UI::Editor diff --git a/tests/UI/Editor/integration/CMakeLists.txt b/tests/UI/Editor/integration/CMakeLists.txt index 96fec18a..179b50c1 100644 --- a/tests/UI/Editor/integration/CMakeLists.txt +++ b/tests/UI/Editor/integration/CMakeLists.txt @@ -15,6 +15,7 @@ set(EDITOR_UI_INTEGRATION_TARGETS editor_ui_panel_session_flow_validation editor_ui_layout_persistence_validation editor_ui_shortcut_dispatch_validation + editor_ui_workspace_interaction_basic_validation ) if(TARGET editor_ui_status_bar_basic_validation) diff --git a/tests/UI/Editor/integration/README.md b/tests/UI/Editor/integration/README.md index 510b29c3..6d32d91e 100644 --- a/tests/UI/Editor/integration/README.md +++ b/tests/UI/Editor/integration/README.md @@ -26,6 +26,7 @@ Layout: - `shell/viewport_slot_basic/`: viewport slot chrome/surface/status only - `shell/viewport_shell_basic/`: viewport shell request/state compose only - `shell/workspace_viewport_compose/`: workspace body external presentation compose only +- `shell/workspace_interaction_basic/`: workspace unified interaction only - `state/panel_session_flow/`: panel session state flow only - `state/layout_persistence/`: layout save/load/reset only - `state/shortcut_dispatch/`: shortcut match/suppression/dispatch only @@ -93,6 +94,11 @@ Scenarios: Executable: `XCUIEditorWorkspaceViewportComposeValidation.exe` Scope: `ResolveUIEditorWorkspaceComposeRequest(...)` + `UpdateUIEditorWorkspaceCompose(...)` body presentation contract only; selected Scene tab body is hosted by `ViewportShell`, switching back to Document restores DockHost placeholder +- `editor.shell.workspace_interaction_basic` + Build target: `editor_ui_workspace_interaction_basic_validation` + Executable: `XCUIEditorWorkspaceInteractionBasicValidation.exe` + Scope: `UpdateUIEditorWorkspaceInteraction(...)` unified contract only; DockHost splitter/tab interaction plus ViewportShell body focus/capture routing in the same workspace layer + - `editor.state.panel_session_flow` Build target: `editor_ui_panel_session_flow_validation` Executable: `XCUIEditorPanelSessionFlowValidation.exe` @@ -162,6 +168,9 @@ Selected controls: - `shell/workspace_viewport_compose/` Click `切到 Scene / 切到 Document`, toggle `TopBar / BottomBar / Texture`, hover or click the center viewport only when `Scene` is selected, inspect `Selected Presentation / Request Size / Hover / Focus / Capture / Result`, press `Reset`, press `截图` or `F12`. +- `shell/workspace_interaction_basic/` + Click the center `Viewport` body to inspect unified focus/capture routing, click `Document` to verify fallback to DockHost placeholder, drag `root-split` to verify layout sync, inspect `Selected Presentation / Active Panel / Host Capture / root-split ratio`, press `Reset`, `Capture`, or `F12`. + - `state/panel_session_flow/` Click `Hide Active / Show Doc A / Close Doc B / Open Doc B / Activate Details / Reset`, press `F12`. diff --git a/tests/UI/Editor/integration/shell/CMakeLists.txt b/tests/UI/Editor/integration/shell/CMakeLists.txt index fe4152d2..e89ee6a2 100644 --- a/tests/UI/Editor/integration/shell/CMakeLists.txt +++ b/tests/UI/Editor/integration/shell/CMakeLists.txt @@ -28,3 +28,6 @@ endif() if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/workspace_viewport_compose/CMakeLists.txt") add_subdirectory(workspace_viewport_compose) endif() +if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/workspace_interaction_basic/CMakeLists.txt") + add_subdirectory(workspace_interaction_basic) +endif() diff --git a/tests/UI/Editor/integration/shell/workspace_interaction_basic/CMakeLists.txt b/tests/UI/Editor/integration/shell/workspace_interaction_basic/CMakeLists.txt new file mode 100644 index 00000000..fb5716ec --- /dev/null +++ b/tests/UI/Editor/integration/shell/workspace_interaction_basic/CMakeLists.txt @@ -0,0 +1,30 @@ +add_executable(editor_ui_workspace_interaction_basic_validation WIN32 + main.cpp +) + +target_include_directories(editor_ui_workspace_interaction_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_workspace_interaction_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_workspace_interaction_basic_validation PRIVATE /utf-8 /FS) + set_property(TARGET editor_ui_workspace_interaction_basic_validation PROPERTY + MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>DLL") +endif() + +target_link_libraries(editor_ui_workspace_interaction_basic_validation PRIVATE + XCUIEditorLib + XCUIEditorHost +) + +set_target_properties(editor_ui_workspace_interaction_basic_validation PROPERTIES + OUTPUT_NAME "XCUIEditorWorkspaceInteractionBasicValidation" +) diff --git a/tests/UI/Editor/integration/shell/workspace_interaction_basic/captures/.gitkeep b/tests/UI/Editor/integration/shell/workspace_interaction_basic/captures/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/tests/UI/Editor/integration/shell/workspace_interaction_basic/main.cpp b/tests/UI/Editor/integration/shell/workspace_interaction_basic/main.cpp new file mode 100644 index 00000000..457f9d75 --- /dev/null +++ b/tests/UI/Editor/integration/shell/workspace_interaction_basic/main.cpp @@ -0,0 +1,714 @@ +#ifndef NOMINMAX +#define NOMINMAX +#endif + +#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::AppendUIEditorWorkspaceCompose; +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::FindUIEditorWorkspaceViewportPresentationFrame; +using XCEngine::UI::Editor::Host::AutoScreenshotController; +using XCEngine::UI::Editor::Host::NativeRenderer; +using XCEngine::UI::Editor::UIEditorPanelPresentationKind; +using XCEngine::UI::Editor::UIEditorPanelRegistry; +using XCEngine::UI::Editor::UIEditorViewportInputBridgeFrame; +using XCEngine::UI::Editor::UIEditorWorkspaceController; +using XCEngine::UI::Editor::UIEditorWorkspaceInteractionFrame; +using XCEngine::UI::Editor::UIEditorWorkspaceInteractionModel; +using XCEngine::UI::Editor::UIEditorWorkspaceInteractionResult; +using XCEngine::UI::Editor::UIEditorWorkspaceInteractionState; +using XCEngine::UI::Editor::UIEditorWorkspaceModel; +using XCEngine::UI::Editor::UIEditorWorkspacePanelPresentationModel; +using XCEngine::UI::Editor::UIEditorWorkspaceSplitAxis; +using XCEngine::UI::Editor::UpdateUIEditorWorkspaceInteraction; +using XCEngine::UI::Editor::Widgets::UIEditorDockHostHitTarget; +using XCEngine::UI::Editor::Widgets::UIEditorDockHostHitTargetKind; + +constexpr const wchar_t* kWindowClassName = L"XCUIEditorWorkspaceInteractionBasicValidation"; +constexpr const wchar_t* kWindowTitle = L"XCUI Editor | Workspace Interaction"; + +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 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); + +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(); +} + +std::string DescribeViewportEvent(const UIEditorViewportInputBridgeFrame& frame) { + if (frame.captureStarted) { + return "Viewport CaptureStarted"; + } + if (frame.captureEnded) { + return "Viewport CaptureEnded"; + } + if (frame.focusGained) { + return "Viewport FocusGained"; + } + if (frame.focusLost) { + return "Viewport FocusLost"; + } + if (frame.pointerPressedInside) { + return "Viewport PointerDownInside"; + } + if (frame.pointerReleasedInside) { + return "Viewport PointerUpInside"; + } + if (frame.pointerMoved) { + return "Viewport PointerMove"; + } + if (frame.wheelDelta != 0.0f) { + return "Viewport Wheel " + FormatFloat(frame.wheelDelta); + } + return "Viewport Input"; +} + +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 = { + { "viewport", "Viewport", UIEditorPanelPresentationKind::ViewportShell, false, true, true }, + { "doc", "Document", UIEditorPanelPresentationKind::Placeholder, true, true, true }, + { "details", "Details", UIEditorPanelPresentationKind::Placeholder, true, true, true } + }; + return registry; +} + +UIEditorWorkspaceModel BuildWorkspace() { + UIEditorWorkspaceModel workspace = {}; + workspace.root = BuildUIEditorWorkspaceSplit( + "root-split", + UIEditorWorkspaceSplitAxis::Horizontal, + 0.7f, + BuildUIEditorWorkspaceTabStack( + "tab-stack", + { + BuildUIEditorWorkspacePanel("viewport-node", "viewport", "Viewport"), + BuildUIEditorWorkspacePanel("doc-node", "doc", "Document", true) + }, + 0u), + BuildUIEditorWorkspacePanel("details-node", "details", "Details", true)); + workspace.activePanelId = "viewport"; + return workspace; +} + +UIEditorWorkspaceInteractionModel BuildInteractionModel() { + UIEditorWorkspaceInteractionModel model = {}; + UIEditorWorkspacePanelPresentationModel presentation = {}; + presentation.panelId = "viewport"; + presentation.kind = UIEditorPanelPresentationKind::ViewportShell; + presentation.viewportShellModel.spec.chrome.title = "Viewport"; + presentation.viewportShellModel.spec.chrome.subtitle = "Workspace Interaction"; + presentation.viewportShellModel.spec.chrome.showTopBar = true; + presentation.viewportShellModel.spec.chrome.showBottomBar = true; + presentation.viewportShellModel.frame.statusText = + "这里只验证 WorkspaceInteraction contract,不接 Scene/Game 业务。"; + model.workspacePresentations = { presentation }; + return model; +} + +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 && + reinterpret_cast(lParam) != hwnd && + app->HasInteractiveCaptureState()) { + UIInputEvent event = {}; + event.type = UIInputEventType::FocusLost; + app->m_pendingInputEvents.push_back(event); + return 0; + } + break; + case WM_KEYDOWN: + case WM_SYSKEYDOWN: + if (app != nullptr && wParam == VK_F12) { + app->m_autoScreenshot.RequestCapture("manual_f12"); + return 0; + } + break; + case WM_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/workspace_interaction_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_interactionModel = BuildInteractionModel(); + m_cachedFrame = {}; + m_pendingInputEvents.clear(); + m_lastStatus = "Ready"; + m_lastMessage = "等待交互。这里只验证 WorkspaceInteraction 统一输入路由 contract。"; + 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_workspaceRect = 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 = "场景状态已重置。请重新检查 viewport body / tab switch / splitter drag。"; + m_lastColor = kWarning; + return; + } + + m_autoScreenshot.RequestCapture("manual_button"); + m_lastStatus = "Ready"; + m_lastMessage = "截图已排队,输出到 tests/UI/Editor/integration/shell/workspace_interaction_basic/captures/。"; + m_lastColor = kWarning; + } + + bool HasInteractiveCaptureState() const { + if (m_interactionState.dockHostInteractionState.splitterDragState.active) { + return true; + } + + for (const auto& panelState : m_interactionState.composeState.panelStates) { + if (panelState.viewportShellState.inputBridgeState.captured) { + return true; + } + } + + return false; + } + + std::string GetSelectedTabId() const { + if (!m_cachedFrame.composeFrame.dockHostLayout.tabStacks.empty()) { + return m_cachedFrame.composeFrame.dockHostLayout.tabStacks.front().selectedPanelId; + } + return m_controller.GetWorkspace().activePanelId; + } + + void ApplyHostCaptureRequests(const UIEditorWorkspaceInteractionResult& result) { + if (result.requestPointerCapture && GetCapture() != m_hwnd) { + SetCapture(m_hwnd); + } + if (result.releasePointerCapture && GetCapture() == m_hwnd) { + ReleaseCapture(); + } + } + + void SetInteractionResult(const UIEditorWorkspaceInteractionResult& result) { + if (result.dockHostResult.layoutResult.status != + XCEngine::UI::Editor::UIEditorWorkspaceLayoutOperationStatus::Rejected) { + m_lastStatus = "DockHostLayout"; + m_lastMessage = result.dockHostResult.layoutResult.message; + m_lastColor = kSuccess; + return; + } + + if (result.dockHostResult.commandResult.status != + XCEngine::UI::Editor::UIEditorWorkspaceCommandStatus::Rejected) { + m_lastStatus = "DockHostCommand"; + m_lastMessage = result.dockHostResult.commandResult.message; + m_lastColor = kSuccess; + return; + } + + if (!result.viewportPanelId.empty()) { + m_lastStatus = result.viewportPanelId; + m_lastMessage = DescribeViewportEvent(result.viewportInputFrame); + m_lastColor = + result.viewportInputFrame.captureStarted || result.viewportInputFrame.focusGained + ? kSuccess + : kWarning; + return; + } + + if (result.requestPointerCapture) { + m_lastStatus = "Capture"; + m_lastMessage = "宿主已收到 WorkspaceInteraction 的 pointer capture 请求。"; + m_lastColor = kSuccess; + return; + } + + if (result.releasePointerCapture) { + m_lastStatus = "Release"; + m_lastMessage = "宿主已执行 WorkspaceInteraction 的 pointer release。"; + m_lastColor = kWarning; + return; + } + + if (result.consumed) { + m_lastStatus = "Consumed"; + m_lastMessage = "这次输入被 WorkspaceInteraction 层消费。"; + m_lastColor = kWarning; + } + } + + void RenderFrame() { + UpdateLayout(); + m_cachedFrame = UpdateUIEditorWorkspaceInteraction( + m_interactionState, + m_controller, + m_workspaceRect, + m_interactionModel, + m_pendingInputEvents); + m_pendingInputEvents.clear(); + ApplyHostCaptureRequests(m_cachedFrame.result); + SetInteractionResult(m_cachedFrame.result); + + const auto* viewportFrame = + FindUIEditorWorkspaceViewportPresentationFrame(m_cachedFrame.composeFrame, "viewport"); + const bool viewportVisible = viewportFrame != nullptr; + const std::string selectedPresentation = + viewportVisible ? "ViewportShell" : "DockHost Placeholder"; + + 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("WorkspaceInteractionBasic"); + drawList.AddFilledRect(UIRect(0.0f, 0.0f, width, height), kWindowBg); + + DrawCard(drawList, m_introRect, "这个测试验证什么功能", "只验证 WorkspaceInteraction 统一路由 contract,不做 editor 业务。"); + drawList.AddText(UIPoint(m_introRect.x + 16.0f, m_introRect.y + 72.0f), "1. 验证 DockHost splitter drag 与 ViewportShell input 可以被同一层统一收口。", kTextPrimary, 12.0f); + drawList.AddText(UIPoint(m_introRect.x + 16.0f, m_introRect.y + 94.0f), "2. 验证点击 viewport body 时,focus/capture 请求会直接冒泡给宿主。", kTextPrimary, 12.0f); + drawList.AddText(UIPoint(m_introRect.x + 16.0f, m_introRect.y + 116.0f), "3. 验证点击 Document tab 后,body 会立即从 ViewportShell 切回 DockHost placeholder。", kTextPrimary, 12.0f); + drawList.AddText(UIPoint(m_introRect.x + 16.0f, m_introRect.y + 138.0f), "4. 验证 splitter 改尺寸后,viewport body bounds 与 workspace layout 同步变化。", kTextPrimary, 12.0f); + drawList.AddText(UIPoint(m_introRect.x + 16.0f, m_introRect.y + 162.0f), "建议操作: 先点中间 viewport,再切到 Document,最后拖右侧 splitter。", kTextWeak, 11.0f); + + DrawCard(drawList, m_controlsRect, "操作", "这里只保留 Reset / Capture。"); + for (const ButtonState& button : m_buttons) { + DrawButton(drawList, button); + } + + DrawCard(drawList, m_stateRect, "状态", "重点检查 WorkspaceInteraction 当前状态。"); + 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("DockHost Hover: " + DescribeHitTarget(m_interactionState.dockHostInteractionState.dockHostState.hoveredTarget), kTextPrimary, 11.0f); + addStateLine("Selected Presentation: " + selectedPresentation, kTextPrimary); + addStateLine("Active Panel: " + (m_controller.GetWorkspace().activePanelId.empty() ? std::string("(none)") : m_controller.GetWorkspace().activePanelId)); + addStateLine("Selected Tab: " + GetSelectedTabId(), kTextPrimary); + addStateLine("Visible Panels: " + JoinVisiblePanelIds(m_controller), kTextWeak, 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("Host Capture: " + FormatBool(GetCapture() == m_hwnd), GetCapture() == m_hwnd ? kSuccess : kTextMuted); + addStateLine("root-split ratio: " + FormatFloat(m_controller.GetWorkspace().root.splitRatio), kTextWeak, 11.0f); + if (viewportFrame != nullptr) { + addStateLine("Viewport Hover: " + FormatBool(viewportFrame->viewportShellFrame.inputFrame.hovered), kTextWeak, 11.0f); + addStateLine("Viewport Focus: " + FormatBool(viewportFrame->viewportShellFrame.inputFrame.focused), kTextWeak, 11.0f); + addStateLine("Viewport Capture: " + FormatBool(viewportFrame->viewportShellFrame.inputFrame.captured), 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", "真实 WorkspaceInteraction 预览,不再在 exe 宿主里手写拼装输入路由。"); + AppendUIEditorWorkspaceCompose(drawList, m_cachedFrame.composeFrame); + + 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 = {}; + UIEditorWorkspaceInteractionState m_interactionState = {}; + UIEditorWorkspaceInteractionModel m_interactionModel = {}; + UIEditorWorkspaceInteractionFrame m_cachedFrame = {}; + std::vector m_pendingInputEvents = {}; + std::vector m_buttons = {}; + UIRect m_introRect = {}; + UIRect m_controlsRect = {}; + UIRect m_stateRect = {}; + UIRect m_previewRect = {}; + UIRect m_workspaceRect = {}; + 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 9ac88a32..4c914c60 100644 --- a/tests/UI/Editor/unit/CMakeLists.txt +++ b/tests/UI/Editor/unit/CMakeLists.txt @@ -22,6 +22,7 @@ set(EDITOR_UI_UNIT_TEST_SOURCES test_ui_editor_viewport_shell.cpp test_ui_editor_viewport_slot.cpp test_ui_editor_workspace_compose.cpp + test_ui_editor_workspace_interaction.cpp test_ui_editor_shortcut_manager.cpp test_ui_editor_workspace_controller.cpp test_ui_editor_workspace_layout_persistence.cpp diff --git a/tests/UI/Editor/unit/test_ui_editor_workspace_interaction.cpp b/tests/UI/Editor/unit/test_ui_editor_workspace_interaction.cpp new file mode 100644 index 00000000..bd8ce959 --- /dev/null +++ b/tests/UI/Editor/unit/test_ui_editor_workspace_interaction.cpp @@ -0,0 +1,248 @@ +#include + +#include + +namespace { + +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::FindUIEditorWorkspaceViewportPresentationFrame; +using XCEngine::UI::Editor::UIEditorPanelPresentationKind; +using XCEngine::UI::Editor::UIEditorPanelRegistry; +using XCEngine::UI::Editor::UIEditorViewportShellModel; +using XCEngine::UI::Editor::UIEditorWorkspaceInteractionModel; +using XCEngine::UI::Editor::UIEditorWorkspaceInteractionState; +using XCEngine::UI::Editor::UIEditorWorkspaceModel; +using XCEngine::UI::Editor::UIEditorWorkspacePanelPresentationModel; +using XCEngine::UI::Editor::UIEditorWorkspaceSplitAxis; +using XCEngine::UI::Editor::UpdateUIEditorWorkspaceInteraction; + +UIEditorPanelRegistry BuildPanelRegistry() { + UIEditorPanelRegistry registry = {}; + registry.panels = { + { "viewport", "Viewport", UIEditorPanelPresentationKind::ViewportShell, false, true, true }, + { "doc", "Document", UIEditorPanelPresentationKind::Placeholder, true, true, true }, + { "details", "Details", UIEditorPanelPresentationKind::Placeholder, true, true, true } + }; + return registry; +} + +UIEditorWorkspaceModel BuildWorkspace() { + UIEditorWorkspaceModel workspace = {}; + workspace.root = BuildUIEditorWorkspaceSplit( + "root-split", + UIEditorWorkspaceSplitAxis::Horizontal, + 0.7f, + BuildUIEditorWorkspaceTabStack( + "tab-stack", + { + BuildUIEditorWorkspacePanel("viewport-node", "viewport", "Viewport"), + BuildUIEditorWorkspacePanel("doc-node", "doc", "Document", true) + }, + 0u), + BuildUIEditorWorkspacePanel("details-node", "details", "Details", true)); + workspace.activePanelId = "viewport"; + return workspace; +} + +UIEditorViewportShellModel BuildViewportShellModel() { + UIEditorViewportShellModel model = {}; + model.spec.chrome.title = "Viewport"; + model.spec.chrome.subtitle = "Workspace Interaction"; + model.spec.chrome.showTopBar = true; + model.spec.chrome.showBottomBar = true; + model.frame.statusText = "Viewport shell"; + return model; +} + +UIEditorWorkspaceInteractionModel BuildInteractionModel() { + UIEditorWorkspaceInteractionModel model = {}; + UIEditorWorkspacePanelPresentationModel presentation = {}; + presentation.panelId = "viewport"; + presentation.kind = UIEditorPanelPresentationKind::ViewportShell; + presentation.viewportShellModel = BuildViewportShellModel(); + model.workspacePresentations = { presentation }; + return model; +} + +UIInputEvent MakePointerEvent( + UIInputEventType type, + float x, + float y, + UIPointerButton button = UIPointerButton::None) { + UIInputEvent event = {}; + event.type = type; + event.position = UIPoint(x, y); + event.pointerButton = button; + return event; +} + +UIPoint RectCenter(const UIRect& rect) { + return UIPoint(rect.x + rect.width * 0.5f, rect.y + rect.height * 0.5f); +} + +} // namespace + +TEST(UIEditorWorkspaceInteractionTest, PointerDownInsideViewportBubblesPointerCaptureRequest) { + auto controller = + BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace()); + UIEditorWorkspaceInteractionState state = {}; + const UIEditorWorkspaceInteractionModel model = BuildInteractionModel(); + + auto frame = UpdateUIEditorWorkspaceInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 1280.0f, 720.0f), + model, + {}); + const auto* viewportFrame = + FindUIEditorWorkspaceViewportPresentationFrame(frame.composeFrame, "viewport"); + ASSERT_NE(viewportFrame, nullptr); + const UIPoint center = RectCenter(viewportFrame->viewportShellFrame.slotLayout.inputRect); + + frame = UpdateUIEditorWorkspaceInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 1280.0f, 720.0f), + model, + { + MakePointerEvent(UIInputEventType::PointerMove, center.x, center.y), + MakePointerEvent(UIInputEventType::PointerButtonDown, center.x, center.y, UIPointerButton::Left) + }); + + EXPECT_TRUE(frame.result.consumed); + EXPECT_TRUE(frame.result.requestPointerCapture); + EXPECT_EQ(frame.result.viewportPanelId, "viewport"); + EXPECT_TRUE(frame.result.viewportInputFrame.captureStarted); + EXPECT_TRUE(frame.result.viewportInputFrame.focused); + + viewportFrame = FindUIEditorWorkspaceViewportPresentationFrame(frame.composeFrame, "viewport"); + ASSERT_NE(viewportFrame, nullptr); + EXPECT_TRUE(viewportFrame->viewportShellFrame.inputFrame.captured); +} + +TEST(UIEditorWorkspaceInteractionTest, PointerUpInsideViewportBubblesPointerReleaseRequest) { + auto controller = + BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace()); + UIEditorWorkspaceInteractionState state = {}; + const UIEditorWorkspaceInteractionModel model = BuildInteractionModel(); + + auto frame = UpdateUIEditorWorkspaceInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 1280.0f, 720.0f), + model, + {}); + const auto* viewportFrame = + FindUIEditorWorkspaceViewportPresentationFrame(frame.composeFrame, "viewport"); + ASSERT_NE(viewportFrame, nullptr); + const UIPoint center = RectCenter(viewportFrame->viewportShellFrame.slotLayout.inputRect); + + UpdateUIEditorWorkspaceInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 1280.0f, 720.0f), + model, + { + MakePointerEvent(UIInputEventType::PointerMove, center.x, center.y), + MakePointerEvent(UIInputEventType::PointerButtonDown, center.x, center.y, UIPointerButton::Left) + }); + + frame = UpdateUIEditorWorkspaceInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 1280.0f, 720.0f), + model, + { + MakePointerEvent(UIInputEventType::PointerButtonUp, center.x, center.y, UIPointerButton::Left) + }); + + EXPECT_TRUE(frame.result.consumed); + EXPECT_TRUE(frame.result.releasePointerCapture); + EXPECT_EQ(frame.result.viewportPanelId, "viewport"); + EXPECT_TRUE(frame.result.viewportInputFrame.captureEnded); +} + +TEST(UIEditorWorkspaceInteractionTest, ActivatingDocumentTabRemovesViewportPresentationInSameFrame) { + auto controller = + BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace()); + UIEditorWorkspaceInteractionState state = {}; + const UIEditorWorkspaceInteractionModel model = BuildInteractionModel(); + + auto frame = UpdateUIEditorWorkspaceInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 1280.0f, 720.0f), + model, + {}); + ASSERT_EQ(frame.composeFrame.dockHostLayout.tabStacks.size(), 1u); + const UIRect docTabRect = + frame.composeFrame.dockHostLayout.tabStacks.front().tabStripLayout.tabHeaderRects[1]; + const UIPoint docTabCenter = RectCenter(docTabRect); + + frame = UpdateUIEditorWorkspaceInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 1280.0f, 720.0f), + model, + { + MakePointerEvent(UIInputEventType::PointerMove, docTabCenter.x, docTabCenter.y), + MakePointerEvent(UIInputEventType::PointerButtonUp, docTabCenter.x, docTabCenter.y, UIPointerButton::Left) + }); + + EXPECT_TRUE(frame.result.dockHostResult.commandExecuted); + EXPECT_EQ(controller.GetWorkspace().activePanelId, "doc"); + EXPECT_TRUE(frame.composeFrame.viewportFrames.empty()); + ASSERT_EQ(frame.composeFrame.dockHostLayout.tabStacks.size(), 1u); + EXPECT_EQ(frame.composeFrame.dockHostLayout.tabStacks.front().selectedPanelId, "doc"); +} + +TEST(UIEditorWorkspaceInteractionTest, SplitterDragKeepsViewportBoundsSyncedAfterLayoutChange) { + auto controller = + BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace()); + UIEditorWorkspaceInteractionState state = {}; + const UIEditorWorkspaceInteractionModel model = BuildInteractionModel(); + + auto frame = UpdateUIEditorWorkspaceInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 1280.0f, 720.0f), + model, + {}); + const auto* viewportFrame = + FindUIEditorWorkspaceViewportPresentationFrame(frame.composeFrame, "viewport"); + ASSERT_NE(viewportFrame, nullptr); + const float initialWidth = viewportFrame->bounds.width; + + frame = UpdateUIEditorWorkspaceInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 1280.0f, 720.0f), + model, + { + MakePointerEvent(UIInputEventType::PointerMove, 888.0f, 140.0f), + MakePointerEvent(UIInputEventType::PointerButtonDown, 888.0f, 140.0f, UIPointerButton::Left) + }); + EXPECT_TRUE(frame.result.requestPointerCapture); + + frame = UpdateUIEditorWorkspaceInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 1280.0f, 720.0f), + model, + { + MakePointerEvent(UIInputEventType::PointerMove, 980.0f, 140.0f) + }); + EXPECT_TRUE(frame.result.dockHostResult.layoutChanged); + + viewportFrame = FindUIEditorWorkspaceViewportPresentationFrame(frame.composeFrame, "viewport"); + ASSERT_NE(viewportFrame, nullptr); + EXPECT_GT(viewportFrame->bounds.width, initialWidth); +}