diff --git a/new_editor/CMakeLists.txt b/new_editor/CMakeLists.txt index 402ac7c2..b93dca3a 100644 --- a/new_editor/CMakeLists.txt +++ b/new_editor/CMakeLists.txt @@ -22,6 +22,7 @@ endfunction() set(XCUI_EDITOR_FOUNDATION_SOURCES src/Foundation/UIEditorCommandDispatcher.cpp src/Foundation/UIEditorCommandRegistry.cpp + src/Foundation/UIEditorRuntimeTrace.cpp src/Foundation/UIEditorShortcutManager.cpp src/Foundation/UIEditorTheme.cpp ) @@ -129,6 +130,7 @@ add_library(XCUIEditorHost STATIC target_include_directories(XCUIEditorHost PUBLIC ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/include ${CMAKE_SOURCE_DIR}/engine/include ) diff --git a/new_editor/Host/NativeRenderer.cpp b/new_editor/Host/NativeRenderer.cpp index 4f270d4a..fc5d872d 100644 --- a/new_editor/Host/NativeRenderer.cpp +++ b/new_editor/Host/NativeRenderer.cpp @@ -8,9 +8,8 @@ namespace XCEngine::UI::Editor::Host { namespace { -D2D1_RECT_F ToD2DRect(const ::XCEngine::UI::UIRect& rect) { - return D2D1::RectF(rect.x, rect.y, rect.x + rect.width, rect.y + rect.height); -} +constexpr float kBaseDpi = 96.0f; +constexpr float kDefaultFontSize = 16.0f; std::string HrToString(const char* operation, HRESULT hr) { char buffer[128] = {}; @@ -18,6 +17,27 @@ std::string HrToString(const char* operation, HRESULT hr) { return buffer; } +float ClampDpiScale(float dpiScale) { + return dpiScale > 0.0f ? dpiScale : 1.0f; +} + +float ResolveFontSize(float fontSize) { + return fontSize > 0.0f ? fontSize : kDefaultFontSize; +} + +float SnapToPixel(float value, float dpiScale) { + const float scale = ClampDpiScale(dpiScale); + return std::round(value * scale); +} + +D2D1_RECT_F ToD2DRect(const ::XCEngine::UI::UIRect& rect, float dpiScale) { + const float left = SnapToPixel(rect.x, dpiScale); + const float top = SnapToPixel(rect.y, dpiScale); + const float right = SnapToPixel(rect.x + rect.width, dpiScale); + const float bottom = SnapToPixel(rect.y + rect.height, dpiScale); + return D2D1::RectF(left, top, right, bottom); +} + } // namespace bool NativeRenderer::Initialize(HWND hwnd) { @@ -64,6 +84,17 @@ void NativeRenderer::Shutdown() { m_hwnd = nullptr; } +void NativeRenderer::SetDpiScale(float dpiScale) { + m_dpiScale = ClampDpiScale(dpiScale); + if (m_renderTarget) { + m_renderTarget->SetDpi(kBaseDpi, kBaseDpi); + } +} + +float NativeRenderer::GetDpiScale() const { + return m_dpiScale; +} + void NativeRenderer::Resize(UINT width, UINT height) { if (!m_renderTarget || width == 0 || height == 0) { return; @@ -104,6 +135,52 @@ const std::string& NativeRenderer::GetLastRenderError() const { return m_lastRenderError; } +float NativeRenderer::MeasureTextWidth( + const ::XCEngine::UI::Editor::UIEditorTextMeasureRequest& request) const { + if (!m_dwriteFactory || request.text.empty()) { + return 0.0f; + } + + const std::wstring text = Utf8ToWide(request.text); + if (text.empty()) { + return 0.0f; + } + + const float dpiScale = ClampDpiScale(m_dpiScale); + const float scaledFontSize = ResolveFontSize(request.fontSize) * dpiScale; + IDWriteTextFormat* textFormat = GetTextFormat(scaledFontSize); + if (textFormat == nullptr) { + return 0.0f; + } + + Microsoft::WRL::ComPtr textLayout; + HRESULT hr = m_dwriteFactory->CreateTextLayout( + text.c_str(), + static_cast(text.size()), + textFormat, + 4096.0f, + scaledFontSize * 2.0f, + textLayout.ReleaseAndGetAddressOf()); + if (FAILED(hr) || !textLayout) { + return 0.0f; + } + + DWRITE_TEXT_METRICS textMetrics = {}; + hr = textLayout->GetMetrics(&textMetrics); + if (FAILED(hr)) { + return 0.0f; + } + + DWRITE_OVERHANG_METRICS overhangMetrics = {}; + float width = textMetrics.widthIncludingTrailingWhitespace; + if (SUCCEEDED(textLayout->GetOverhangMetrics(&overhangMetrics))) { + width += (std::max)(overhangMetrics.left, 0.0f); + width += (std::max)(overhangMetrics.right, 0.0f); + } + + return std::ceil(width) / dpiScale; +} + bool NativeRenderer::CaptureToPng( const ::XCEngine::UI::UIDrawData& drawData, UINT width, @@ -146,7 +223,9 @@ bool NativeRenderer::CaptureToPng( const D2D1_RENDER_TARGET_PROPERTIES renderTargetProperties = D2D1::RenderTargetProperties( D2D1_RENDER_TARGET_TYPE_DEFAULT, - D2D1::PixelFormat(DXGI_FORMAT_B8G8R8A8_UNORM, D2D1_ALPHA_MODE_PREMULTIPLIED)); + D2D1::PixelFormat(DXGI_FORMAT_B8G8R8A8_UNORM, D2D1_ALPHA_MODE_PREMULTIPLIED), + kBaseDpi, + kBaseDpi); Microsoft::WRL::ComPtr offscreenRenderTarget; hr = m_d2dFactory->CreateWicBitmapRenderTarget( @@ -301,7 +380,11 @@ bool NativeRenderer::CreateDeviceResources() { const UINT width = static_cast((std::max)(clientRect.right - clientRect.left, 1L)); const UINT height = static_cast((std::max)(clientRect.bottom - clientRect.top, 1L)); - const D2D1_RENDER_TARGET_PROPERTIES renderTargetProps = D2D1::RenderTargetProperties(); + const D2D1_RENDER_TARGET_PROPERTIES renderTargetProps = D2D1::RenderTargetProperties( + D2D1_RENDER_TARGET_TYPE_DEFAULT, + D2D1::PixelFormat(DXGI_FORMAT_B8G8R8A8_UNORM, D2D1_ALPHA_MODE_PREMULTIPLIED), + kBaseDpi, + kBaseDpi); const D2D1_HWND_RENDER_TARGET_PROPERTIES hwndProps = D2D1::HwndRenderTargetProperties( m_hwnd, D2D1::SizeU(width, height)); @@ -324,6 +407,7 @@ bool NativeRenderer::CreateDeviceResources() { return false; } + m_renderTarget->SetDpi(kBaseDpi, kBaseDpi); m_renderTarget->SetTextAntialiasMode(D2D1_TEXT_ANTIALIAS_MODE_GRAYSCALE); m_lastRenderError.clear(); return true; @@ -333,6 +417,7 @@ bool NativeRenderer::RenderToTarget( ID2D1RenderTarget& renderTarget, ID2D1SolidColorBrush& solidBrush, const ::XCEngine::UI::UIDrawData& drawData) { + renderTarget.SetDpi(kBaseDpi, kBaseDpi); renderTarget.SetTextAntialiasMode(D2D1_TEXT_ANTIALIAS_MODE_GRAYSCALE); renderTarget.BeginDraw(); renderTarget.Clear(D2D1::ColorF(0.04f, 0.05f, 0.06f, 1.0f)); @@ -358,13 +443,15 @@ void NativeRenderer::RenderCommand( const ::XCEngine::UI::UIDrawCommand& command, std::vector& clipStack) { solidBrush.SetColor(ToD2DColor(command.color)); + const float dpiScale = ClampDpiScale(m_dpiScale); switch (command.type) { case ::XCEngine::UI::UIDrawCommandType::FilledRect: { - const D2D1_RECT_F rect = ToD2DRect(command.rect); + const D2D1_RECT_F rect = ToD2DRect(command.rect, dpiScale); + const float rounding = command.rounding > 0.0f ? command.rounding * dpiScale : 0.0f; if (command.rounding > 0.0f) { renderTarget.FillRoundedRectangle( - D2D1::RoundedRect(rect, command.rounding, command.rounding), + D2D1::RoundedRect(rect, rounding, rounding), &solidBrush); } else { renderTarget.FillRectangle(rect, &solidBrush); @@ -372,11 +459,12 @@ void NativeRenderer::RenderCommand( break; } case ::XCEngine::UI::UIDrawCommandType::RectOutline: { - const D2D1_RECT_F rect = ToD2DRect(command.rect); - const float thickness = command.thickness > 0.0f ? command.thickness : 1.0f; + const D2D1_RECT_F rect = ToD2DRect(command.rect, dpiScale); + const float thickness = (command.thickness > 0.0f ? command.thickness : 1.0f) * dpiScale; + const float rounding = command.rounding > 0.0f ? command.rounding * dpiScale : 0.0f; if (command.rounding > 0.0f) { renderTarget.DrawRoundedRectangle( - D2D1::RoundedRect(rect, command.rounding, command.rounding), + D2D1::RoundedRect(rect, rounding, rounding), &solidBrush, thickness); } else { @@ -389,8 +477,9 @@ void NativeRenderer::RenderCommand( break; } - const float fontSize = command.fontSize > 0.0f ? command.fontSize : 16.0f; - IDWriteTextFormat* textFormat = GetTextFormat(fontSize); + const float fontSize = ResolveFontSize(command.fontSize); + const float scaledFontSize = fontSize * dpiScale; + IDWriteTextFormat* textFormat = GetTextFormat(scaledFontSize); if (textFormat == nullptr) { break; } @@ -401,11 +490,14 @@ void NativeRenderer::RenderCommand( } const D2D1_SIZE_F targetSize = renderTarget.GetSize(); + const float originX = SnapToPixel(command.position.x, dpiScale); + const float originY = SnapToPixel(command.position.y, dpiScale); + const float lineHeight = std::ceil(scaledFontSize * 1.6f); const D2D1_RECT_F layoutRect = D2D1::RectF( - command.position.x, - command.position.y, + originX, + originY, targetSize.width, - command.position.y + fontSize * 1.8f); + originY + lineHeight); renderTarget.DrawTextW( text.c_str(), static_cast(text.size()), @@ -413,7 +505,7 @@ void NativeRenderer::RenderCommand( layoutRect, &solidBrush, D2D1_DRAW_TEXT_OPTIONS_CLIP, - DWRITE_MEASURING_MODE_NATURAL); + DWRITE_MEASURING_MODE_GDI_NATURAL); break; } case ::XCEngine::UI::UIDrawCommandType::Image: { @@ -421,12 +513,12 @@ void NativeRenderer::RenderCommand( break; } - const D2D1_RECT_F rect = ToD2DRect(command.rect); + const D2D1_RECT_F rect = ToD2DRect(command.rect, dpiScale); renderTarget.DrawRectangle(rect, &solidBrush, 1.0f); break; } case ::XCEngine::UI::UIDrawCommandType::PushClipRect: { - const D2D1_RECT_F rect = ToD2DRect(command.rect); + const D2D1_RECT_F rect = ToD2DRect(command.rect, dpiScale); renderTarget.PushAxisAlignedClip(rect, D2D1_ANTIALIAS_MODE_PER_PRIMITIVE); clipStack.push_back(rect); break; @@ -443,12 +535,13 @@ void NativeRenderer::RenderCommand( } } -IDWriteTextFormat* NativeRenderer::GetTextFormat(float fontSize) { +IDWriteTextFormat* NativeRenderer::GetTextFormat(float fontSize) const { if (!m_dwriteFactory) { return nullptr; } - const int key = static_cast(std::lround(fontSize * 10.0f)); + const float resolvedFontSize = ResolveFontSize(fontSize); + const int key = static_cast(std::lround(resolvedFontSize * 10.0f)); const auto found = m_textFormats.find(key); if (found != m_textFormats.end()) { return found->second.Get(); @@ -461,7 +554,7 @@ IDWriteTextFormat* NativeRenderer::GetTextFormat(float fontSize) { DWRITE_FONT_WEIGHT_REGULAR, DWRITE_FONT_STYLE_NORMAL, DWRITE_FONT_STRETCH_NORMAL, - fontSize, + resolvedFontSize, L"", textFormat.ReleaseAndGetAddressOf()); if (FAILED(hr)) { diff --git a/new_editor/Host/NativeRenderer.h b/new_editor/Host/NativeRenderer.h index 5c89aec1..5243f394 100644 --- a/new_editor/Host/NativeRenderer.h +++ b/new_editor/Host/NativeRenderer.h @@ -4,6 +4,8 @@ #define NOMINMAX #endif +#include + #include #include @@ -20,13 +22,17 @@ namespace XCEngine::UI::Editor::Host { -class NativeRenderer { +class NativeRenderer : public ::XCEngine::UI::Editor::UIEditorTextMeasurer { public: bool Initialize(HWND hwnd); void Shutdown(); + void SetDpiScale(float dpiScale); + float GetDpiScale() const; void Resize(UINT width, UINT height); bool Render(const ::XCEngine::UI::UIDrawData& drawData); const std::string& GetLastRenderError() const; + float MeasureTextWidth( + const ::XCEngine::UI::Editor::UIEditorTextMeasureRequest& request) const override; bool CaptureToPng( const ::XCEngine::UI::UIDrawData& drawData, UINT width, @@ -49,7 +55,7 @@ private: const ::XCEngine::UI::UIDrawCommand& command, std::vector& clipStack); - IDWriteTextFormat* GetTextFormat(float fontSize); + IDWriteTextFormat* GetTextFormat(float fontSize) const; static D2D1_COLOR_F ToD2DColor(const ::XCEngine::UI::UIColor& color); static std::wstring Utf8ToWide(std::string_view text); @@ -59,9 +65,10 @@ private: Microsoft::WRL::ComPtr m_wicFactory; Microsoft::WRL::ComPtr m_renderTarget; Microsoft::WRL::ComPtr m_solidBrush; - std::unordered_map> m_textFormats; + mutable std::unordered_map> m_textFormats; std::string m_lastRenderError = {}; bool m_wicComInitialized = false; + float m_dpiScale = 1.0f; }; } // namespace XCEngine::UI::Editor::Host diff --git a/new_editor/app/Application.cpp b/new_editor/app/Application.cpp index d9399269..3050946d 100644 --- a/new_editor/app/Application.cpp +++ b/new_editor/app/Application.cpp @@ -2,6 +2,7 @@ #include "Shell/ProductShellAsset.h" +#include #include #include @@ -10,8 +11,11 @@ #include #include #include +#include #include +#include + #ifndef XCUIEDITOR_REPO_ROOT #define XCUIEDITOR_REPO_ROOT "." #endif @@ -34,11 +38,112 @@ using App::BuildProductShellInteractionDefinition; constexpr const wchar_t* kWindowClassName = L"XCEditorShellHost"; constexpr const wchar_t* kWindowTitle = L"Main Scene * - Main.xx - XCEngine Editor"; +constexpr UINT kDefaultDpi = 96u; +constexpr float kBaseDpiScale = 96.0f; Application* GetApplicationFromWindow(HWND hwnd) { return reinterpret_cast(GetWindowLongPtrW(hwnd, GWLP_USERDATA)); } +UINT QuerySystemDpi() { + HDC screenDc = GetDC(nullptr); + if (screenDc == nullptr) { + return kDefaultDpi; + } + + const int dpiX = GetDeviceCaps(screenDc, LOGPIXELSX); + ReleaseDC(nullptr, screenDc); + return dpiX > 0 ? static_cast(dpiX) : kDefaultDpi; +} + +UINT QueryWindowDpi(HWND hwnd) { + if (hwnd != nullptr) { + const HMODULE user32 = GetModuleHandleW(L"user32.dll"); + if (user32 != nullptr) { + using GetDpiForWindowFn = UINT(WINAPI*)(HWND); + const auto getDpiForWindow = + reinterpret_cast(GetProcAddress(user32, "GetDpiForWindow")); + if (getDpiForWindow != nullptr) { + const UINT dpi = getDpiForWindow(hwnd); + if (dpi != 0u) { + return dpi; + } + } + } + } + + return QuerySystemDpi(); +} + +void EnableDpiAwareness() { + const HMODULE user32 = GetModuleHandleW(L"user32.dll"); + if (user32 != nullptr) { + using SetProcessDpiAwarenessContextFn = BOOL(WINAPI*)(DPI_AWARENESS_CONTEXT); + const auto setProcessDpiAwarenessContext = + reinterpret_cast( + GetProcAddress(user32, "SetProcessDpiAwarenessContext")); + if (setProcessDpiAwarenessContext != nullptr) { + if (setProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)) { + return; + } + if (GetLastError() == ERROR_ACCESS_DENIED) { + return; + } + if (setProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE)) { + return; + } + if (GetLastError() == ERROR_ACCESS_DENIED) { + return; + } + } + } + + const HMODULE shcore = LoadLibraryW(L"shcore.dll"); + if (shcore != nullptr) { + using SetProcessDpiAwarenessFn = HRESULT(WINAPI*)(PROCESS_DPI_AWARENESS); + const auto setProcessDpiAwareness = + reinterpret_cast( + GetProcAddress(shcore, "SetProcessDpiAwareness")); + if (setProcessDpiAwareness != nullptr) { + const HRESULT hr = setProcessDpiAwareness(PROCESS_PER_MONITOR_DPI_AWARE); + FreeLibrary(shcore); + if (SUCCEEDED(hr) || hr == E_ACCESSDENIED) { + return; + } + } else { + FreeLibrary(shcore); + } + } + + if (user32 != nullptr) { + using SetProcessDPIAwareFn = BOOL(WINAPI*)(); + const auto setProcessDPIAware = + reinterpret_cast(GetProcAddress(user32, "SetProcessDPIAware")); + if (setProcessDPIAware != nullptr) { + setProcessDPIAware(); + } + } +} + +void TryEnableNonClientDpiScaling(HWND hwnd) { + if (hwnd == nullptr) { + return; + } + + const HMODULE user32 = GetModuleHandleW(L"user32.dll"); + if (user32 == nullptr) { + return; + } + + using EnableNonClientDpiScalingFn = BOOL(WINAPI*)(HWND); + const auto enableNonClientDpiScaling = + reinterpret_cast( + GetProcAddress(user32, "EnableNonClientDpiScaling")); + if (enableNonClientDpiScaling != nullptr) { + enableNonClientDpiScaling(hwnd); + } +} + std::string TruncateText(const std::string& text, std::size_t maxLength) { if (text.size() <= maxLength) { return text; @@ -146,6 +251,44 @@ bool IsRepeatKeyMessage(LPARAM lParam) { return (static_cast(lParam) & (1ul << 30)) != 0ul; } +std::filesystem::path GetExecutableDirectory() { + std::vector buffer(MAX_PATH); + while (true) { + const DWORD copied = ::GetModuleFileNameW( + nullptr, + buffer.data(), + static_cast(buffer.size())); + if (copied == 0u) { + return std::filesystem::current_path().lexically_normal(); + } + + if (copied < buffer.size() - 1u) { + return std::filesystem::path(std::wstring(buffer.data(), copied)) + .parent_path() + .lexically_normal(); + } + + buffer.resize(buffer.size() * 2u); + } +} + +std::string DescribeInputEventType(const UIInputEvent& event) { + switch (event.type) { + case UIInputEventType::PointerMove: return "PointerMove"; + case UIInputEventType::PointerEnter: return "PointerEnter"; + case UIInputEventType::PointerLeave: return "PointerLeave"; + case UIInputEventType::PointerButtonDown: return "PointerDown"; + case UIInputEventType::PointerButtonUp: return "PointerUp"; + case UIInputEventType::PointerWheel: return "PointerWheel"; + case UIInputEventType::KeyDown: return "KeyDown"; + case UIInputEventType::KeyUp: return "KeyUp"; + case UIInputEventType::Character: return "Character"; + case UIInputEventType::FocusGained: return "FocusGained"; + case UIInputEventType::FocusLost: return "FocusLost"; + default: return "Unknown"; + } +} + } // namespace int Application::Run(HINSTANCE hInstance, int nCmdShow) { @@ -172,10 +315,17 @@ int Application::Run(HINSTANCE hInstance, int nCmdShow) { bool Application::Initialize(HINSTANCE hInstance, int nCmdShow) { m_hInstance = hInstance; + EnableDpiAwareness(); + const std::filesystem::path logRoot = + GetExecutableDirectory() / "logs"; + InitializeUIEditorRuntimeTrace(logRoot); + SetUnhandledExceptionFilter(&Application::HandleUnhandledException); + LogRuntimeTrace("app", "initialize begin"); m_shellAsset = BuildProductShellAsset(ResolveRepoRootPath()); m_shellValidation = ValidateEditorShellAsset(m_shellAsset); m_validationMessage = m_shellValidation.message; if (!m_shellValidation.IsValid()) { + LogRuntimeTrace("app", "shell asset validation failed: " + m_validationMessage); return false; } @@ -188,8 +338,10 @@ bool Application::Initialize(HINSTANCE hInstance, int nCmdShow) { m_shellServices = {}; m_shellServices.commandDispatcher = &m_shortcutManager.GetCommandDispatcher(); m_shellServices.shortcutManager = &m_shortcutManager; + m_shellServices.textMeasurer = &m_renderer; m_lastStatus = "Ready"; m_lastMessage = "Old editor shell baseline loaded."; + LogRuntimeTrace("app", "workspace initialized: " + DescribeWorkspaceState()); WNDCLASSEXW windowClass = {}; windowClass.cbSize = sizeof(windowClass); @@ -200,6 +352,7 @@ bool Application::Initialize(HINSTANCE hInstance, int nCmdShow) { windowClass.lpszClassName = kWindowClassName; m_windowClassAtom = RegisterClassExW(&windowClass); if (m_windowClassAtom == 0) { + LogRuntimeTrace("app", "window class registration failed"); return false; } @@ -207,7 +360,7 @@ bool Application::Initialize(HINSTANCE hInstance, int nCmdShow) { 0, kWindowClassName, kWindowTitle, - WS_OVERLAPPEDWINDOW | WS_VISIBLE, + WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, 1540, @@ -217,26 +370,37 @@ bool Application::Initialize(HINSTANCE hInstance, int nCmdShow) { hInstance, this); if (m_hwnd == nullptr) { + LogRuntimeTrace("app", "window creation failed"); + return false; + } + m_windowDpi = QueryWindowDpi(m_hwnd); + m_dpiScale = GetDpiScale(); + m_renderer.SetDpiScale(m_dpiScale); + + std::ostringstream dpiTrace = {}; + dpiTrace << "initial dpi=" << m_windowDpi << " scale=" << m_dpiScale; + LogRuntimeTrace("window", dpiTrace.str()); + + if (!m_renderer.Initialize(m_hwnd)) { + LogRuntimeTrace("app", "renderer initialization failed"); return false; } ShowWindow(m_hwnd, nCmdShow); UpdateWindow(m_hwnd); - if (!m_renderer.Initialize(m_hwnd)) { - return false; - } - m_autoScreenshot.Initialize(m_shellAsset.captureRootPath); if (IsAutoCaptureOnStartupEnabled()) { m_autoScreenshot.RequestCapture("startup"); m_lastStatus = "Capture"; m_lastMessage = "Startup capture requested."; } + LogRuntimeTrace("app", "initialize completed"); return true; } void Application::Shutdown() { + LogRuntimeTrace("app", "shutdown begin"); if (GetCapture() == m_hwnd) { ReleaseCapture(); } @@ -253,6 +417,9 @@ void Application::Shutdown() { UnregisterClassW(kWindowClassName, m_hInstance); m_windowClassAtom = 0; } + + LogRuntimeTrace("app", "shutdown end"); + ShutdownUIEditorRuntimeTrace(); } void Application::RenderFrame() { @@ -262,8 +429,12 @@ void Application::RenderFrame() { 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)); + const unsigned int pixelWidth = + static_cast((std::max)(clientRect.right - clientRect.left, 1L)); + const unsigned int pixelHeight = + static_cast((std::max)(clientRect.bottom - clientRect.top, 1L)); + const float width = PixelsToDips(static_cast(pixelWidth)); + const float height = PixelsToDips(static_cast(pixelHeight)); UIDrawData drawData = {}; UIDrawList& drawList = drawData.EmplaceDrawList("XCEditorShell"); @@ -277,6 +448,11 @@ void Application::RenderFrame() { const UIEditorShellInteractionDefinition definition = BuildShellDefinition(); std::vector frameEvents = std::move(m_pendingInputEvents); m_pendingInputEvents.clear(); + if (!frameEvents.empty()) { + LogRuntimeTrace( + "input", + DescribeInputEvents(frameEvents) + " | " + DescribeWorkspaceState()); + } m_shellFrame = UpdateUIEditorShellInteraction( m_shellInteractionState, @@ -286,6 +462,24 @@ void Application::RenderFrame() { frameEvents, m_shellServices, metrics); + if (!frameEvents.empty() || + m_shellFrame.result.workspaceResult.dockHostResult.layoutChanged || + m_shellFrame.result.workspaceResult.dockHostResult.commandExecuted) { + std::ostringstream frameTrace = {}; + frameTrace << "result consumed=" + << (m_shellFrame.result.consumed ? "true" : "false") + << " layoutChanged=" + << (m_shellFrame.result.workspaceResult.dockHostResult.layoutChanged ? "true" : "false") + << " commandExecuted=" + << (m_shellFrame.result.workspaceResult.dockHostResult.commandExecuted ? "true" : "false") + << " active=" + << m_workspaceController.GetWorkspace().activePanelId + << " message=" + << m_shellFrame.result.workspaceResult.dockHostResult.layoutResult.message; + LogRuntimeTrace( + "frame", + frameTrace.str()); + } ApplyHostCaptureRequests(m_shellFrame.result); UpdateLastStatus(m_shellFrame.result); AppendUIEditorShellInteraction( @@ -311,11 +505,78 @@ void Application::RenderFrame() { m_autoScreenshot.CaptureIfRequested( m_renderer, drawData, - static_cast(width), - static_cast(height), + pixelWidth, + pixelHeight, framePresented); } +float Application::GetDpiScale() const { + const UINT dpi = m_windowDpi == 0u ? kDefaultDpi : m_windowDpi; + return static_cast(dpi) / kBaseDpiScale; +} + +float Application::PixelsToDips(float pixels) const { + const float dpiScale = GetDpiScale(); + return dpiScale > 0.0f ? pixels / dpiScale : pixels; +} + +UIPoint Application::ConvertClientPixelsToDips(LONG x, LONG y) const { + return UIPoint( + PixelsToDips(static_cast(x)), + PixelsToDips(static_cast(y))); +} + +void Application::LogRuntimeTrace( + std::string_view channel, + std::string_view message) const { + AppendUIEditorRuntimeTrace(channel, message); +} + +std::string Application::DescribeWorkspaceState() const { + std::ostringstream stream = {}; + stream << "active=" << m_workspaceController.GetWorkspace().activePanelId; + const auto visiblePanels = + CollectUIEditorWorkspaceVisiblePanels( + m_workspaceController.GetWorkspace(), + m_workspaceController.GetSession()); + stream << " visible=["; + for (std::size_t index = 0; index < visiblePanels.size(); ++index) { + if (index > 0u) { + stream << ','; + } + stream << visiblePanels[index].panelId; + } + stream << ']'; + + const auto& dockState = + m_shellInteractionState.workspaceInteractionState.dockHostInteractionState; + stream << " dragNode=" << dockState.activeTabDragNodeId; + stream << " dragPanel=" << dockState.activeTabDragPanelId; + if (dockState.dockHostState.dropPreview.visible) { + stream << " dropTarget=" << dockState.dockHostState.dropPreview.targetNodeId; + } + return stream.str(); +} + +std::string Application::DescribeInputEvents( + const std::vector& events) const { + std::ostringstream stream = {}; + stream << "events=["; + for (std::size_t index = 0; index < events.size(); ++index) { + if (index > 0u) { + stream << " | "; + } + const UIInputEvent& event = events[index]; + stream << DescribeInputEventType(event) + << '@' + << static_cast(event.position.x) + << ',' + << static_cast(event.position.y); + } + stream << ']'; + return stream.str(); +} + void Application::OnResize(UINT width, UINT height) { if (width == 0 || height == 0) { return; @@ -324,6 +585,27 @@ void Application::OnResize(UINT width, UINT height) { m_renderer.Resize(width, height); } +void Application::OnDpiChanged(UINT dpi, const RECT& suggestedRect) { + m_windowDpi = dpi == 0u ? kDefaultDpi : dpi; + m_dpiScale = GetDpiScale(); + m_renderer.SetDpiScale(m_dpiScale); + if (m_hwnd != nullptr) { + SetWindowPos( + m_hwnd, + nullptr, + suggestedRect.left, + suggestedRect.top, + suggestedRect.right - suggestedRect.left, + suggestedRect.bottom - suggestedRect.top, + SWP_NOZORDER | SWP_NOACTIVATE); + InvalidateRect(m_hwnd, nullptr, FALSE); + } + + std::ostringstream trace = {}; + trace << "dpi changed to " << m_windowDpi << " scale=" << m_dpiScale; + LogRuntimeTrace("window", trace.str()); +} + void Application::ApplyHostCaptureRequests(const UIEditorShellInteractionResult& result) { if (result.requestPointerCapture && GetCapture() != m_hwnd) { SetCapture(m_hwnd); @@ -338,6 +620,10 @@ bool Application::HasInteractiveCaptureState() const { return true; } + if (!m_shellInteractionState.workspaceInteractionState.dockHostInteractionState.activeTabDragNodeId.empty()) { + return true; + } + for (const auto& panelState : m_shellInteractionState.workspaceInteractionState.composeState.panelStates) { if (panelState.viewportShellState.inputBridgeState.captured) { return true; @@ -434,9 +720,9 @@ void Application::QueuePointerEvent( UIInputEvent event = {}; event.type = type; event.pointerButton = button; - event.position = UIPoint( - static_cast(GET_X_LPARAM(lParam)), - static_cast(GET_Y_LPARAM(lParam))); + event.position = ConvertClientPixelsToDips( + GET_X_LPARAM(lParam), + GET_Y_LPARAM(lParam)); event.modifiers = m_inputModifierTracker.BuildPointerModifiers(static_cast(wParam)); m_pendingInputEvents.push_back(event); } @@ -448,9 +734,7 @@ void Application::QueuePointerLeaveEvent() { POINT clientPoint = {}; GetCursorPos(&clientPoint); ScreenToClient(m_hwnd, &clientPoint); - event.position = UIPoint( - static_cast(clientPoint.x), - static_cast(clientPoint.y)); + event.position = ConvertClientPixelsToDips(clientPoint.x, clientPoint.y); } m_pendingInputEvents.push_back(event); } @@ -468,9 +752,7 @@ void Application::QueuePointerWheelEvent(short wheelDelta, WPARAM wParam, LPARAM UIInputEvent event = {}; event.type = UIInputEventType::PointerWheel; - event.position = UIPoint( - static_cast(screenPoint.x), - static_cast(screenPoint.y)); + event.position = ConvertClientPixelsToDips(screenPoint.x, screenPoint.y); event.wheelDelta = static_cast(wheelDelta); event.modifiers = m_inputModifierTracker.BuildPointerModifiers(static_cast(wParam)); m_pendingInputEvents.push_back(event); @@ -507,6 +789,19 @@ std::filesystem::path Application::ResolveRepoRootPath() { return std::filesystem::path(root).lexically_normal(); } +LONG WINAPI Application::HandleUnhandledException(EXCEPTION_POINTERS* exceptionInfo) { + if (exceptionInfo != nullptr && + exceptionInfo->ExceptionRecord != nullptr) { + AppendUIEditorCrashTrace( + exceptionInfo->ExceptionRecord->ExceptionCode, + exceptionInfo->ExceptionRecord->ExceptionAddress); + } else { + AppendUIEditorCrashTrace(0u, nullptr); + } + + return EXCEPTION_EXECUTE_HANDLER; +} + UIEditorHostCommandEvaluationResult Application::EvaluateHostCommand( std::string_view commandId) const { UIEditorHostCommandEvaluationResult result = {}; @@ -573,6 +868,7 @@ UIEditorHostCommandDispatchResult Application::DispatchHostCommand( LRESULT CALLBACK Application::WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) { if (message == WM_NCCREATE) { + TryEnableNonClientDpiScaling(hwnd); const auto* createStruct = reinterpret_cast(lParam); auto* application = reinterpret_cast(createStruct->lpCreateParams); SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast(application)); @@ -581,6 +877,14 @@ LRESULT CALLBACK Application::WndProc(HWND hwnd, UINT message, WPARAM wParam, LP Application* application = GetApplicationFromWindow(hwnd); switch (message) { + case WM_DPICHANGED: + if (application != nullptr && lParam != 0) { + application->OnDpiChanged( + static_cast(LOWORD(wParam)), + *reinterpret_cast(lParam)); + return 0; + } + break; case WM_SIZE: if (application != nullptr && wParam != SIZE_MINIMIZED) { application->OnResize(static_cast(LOWORD(lParam)), static_cast(HIWORD(lParam))); diff --git a/new_editor/app/Application.h b/new_editor/app/Application.h index ec3fcc32..d8469470 100644 --- a/new_editor/app/Application.h +++ b/new_editor/app/Application.h @@ -42,10 +42,18 @@ private: void Shutdown(); void RenderFrame(); void OnResize(UINT width, UINT height); + void OnDpiChanged(UINT dpi, const RECT& suggestedRect); + float GetDpiScale() const; + float PixelsToDips(float pixels) const; + ::XCEngine::UI::UIPoint ConvertClientPixelsToDips(LONG x, LONG y) const; + void LogRuntimeTrace(std::string_view channel, std::string_view message) const; void ApplyHostCaptureRequests(const UIEditorShellInteractionResult& result); bool HasInteractiveCaptureState() const; UIEditorShellInteractionDefinition BuildShellDefinition() const; void UpdateLastStatus(const UIEditorShellInteractionResult& result); + std::string DescribeWorkspaceState() const; + std::string DescribeInputEvents( + const std::vector<::XCEngine::UI::UIInputEvent>& events) const; void QueuePointerEvent( ::XCEngine::UI::UIInputEventType type, ::XCEngine::UI::UIPointerButton button, @@ -57,6 +65,7 @@ private: void QueueCharacterEvent(WPARAM wParam, LPARAM lParam); void QueueWindowFocusEvent(::XCEngine::UI::UIInputEventType type); static std::filesystem::path ResolveRepoRootPath(); + static LONG WINAPI HandleUnhandledException(EXCEPTION_POINTERS* exceptionInfo); HWND m_hwnd = nullptr; HINSTANCE m_hInstance = nullptr; @@ -76,6 +85,8 @@ private: std::string m_validationMessage = {}; std::string m_lastStatus = {}; std::string m_lastMessage = {}; + UINT m_windowDpi = 96u; + float m_dpiScale = 1.0f; }; int RunXCUIEditorApp(HINSTANCE hInstance, int nCmdShow); diff --git a/new_editor/include/XCEditor/Collections/UIEditorTabStrip.h b/new_editor/include/XCEditor/Collections/UIEditorTabStrip.h index 18dc94ed..3ef65de5 100644 --- a/new_editor/include/XCEditor/Collections/UIEditorTabStrip.h +++ b/new_editor/include/XCEditor/Collections/UIEditorTabStrip.h @@ -22,11 +22,21 @@ struct UIEditorTabStripItem { float desiredHeaderLabelWidth = 0.0f; }; +struct UIEditorTabStripReorderState { + ::XCEngine::UI::UIPoint pressPosition = {}; + std::size_t pressedIndex = UIEditorTabStripInvalidIndex; + std::size_t sourceIndex = UIEditorTabStripInvalidIndex; + std::size_t previewInsertionIndex = UIEditorTabStripInvalidIndex; + bool armed = false; + bool dragging = false; +}; + struct UIEditorTabStripState { std::size_t selectedIndex = UIEditorTabStripInvalidIndex; std::size_t hoveredIndex = UIEditorTabStripInvalidIndex; std::size_t closeHoveredIndex = UIEditorTabStripInvalidIndex; bool focused = false; + UIEditorTabStripReorderState reorder = {}; }; struct UIEditorTabStripMetrics { @@ -38,21 +48,26 @@ struct UIEditorTabStripMetrics { float closeInsetRight = 6.0f; float closeInsetY = 0.0f; float labelInsetX = 8.0f; - float labelInsetY = -2.5f; + float labelInsetY = -0.5f; float baseBorderThickness = 1.0f; float selectedBorderThickness = 1.0f; float focusedBorderThickness = 1.0f; + float reorderDragThreshold = 6.0f; + float reorderPreviewThickness = 2.0f; + float reorderPreviewInsetY = 3.0f; }; struct UIEditorTabStripPalette { ::XCEngine::UI::UIColor stripBackgroundColor = - ::XCEngine::UI::UIColor(0.16f, 0.16f, 0.16f, 1.0f); + ::XCEngine::UI::UIColor(0.18f, 0.18f, 0.18f, 1.0f); ::XCEngine::UI::UIColor headerBackgroundColor = ::XCEngine::UI::UIColor(0.18f, 0.18f, 0.18f, 1.0f); ::XCEngine::UI::UIColor contentBackgroundColor = - ::XCEngine::UI::UIColor(0.17f, 0.17f, 0.17f, 1.0f); + ::XCEngine::UI::UIColor(0.18f, 0.18f, 0.18f, 1.0f); ::XCEngine::UI::UIColor stripBorderColor = ::XCEngine::UI::UIColor(0.24f, 0.24f, 0.24f, 1.0f); + ::XCEngine::UI::UIColor headerContentSeparatorColor = + ::XCEngine::UI::UIColor(0.27f, 0.27f, 0.27f, 1.0f); ::XCEngine::UI::UIColor focusedBorderColor = ::XCEngine::UI::UIColor(0.36f, 0.36f, 0.36f, 1.0f); ::XCEngine::UI::UIColor tabColor = @@ -60,7 +75,7 @@ struct UIEditorTabStripPalette { ::XCEngine::UI::UIColor tabHoveredColor = ::XCEngine::UI::UIColor(0.23f, 0.23f, 0.23f, 1.0f); ::XCEngine::UI::UIColor tabSelectedColor = - ::XCEngine::UI::UIColor(0.22f, 0.22f, 0.22f, 1.0f); + ::XCEngine::UI::UIColor(0.18f, 0.18f, 0.18f, 1.0f); ::XCEngine::UI::UIColor tabBorderColor = ::XCEngine::UI::UIColor(0.24f, 0.24f, 0.24f, 1.0f); ::XCEngine::UI::UIColor tabHoveredBorderColor = @@ -81,6 +96,14 @@ struct UIEditorTabStripPalette { ::XCEngine::UI::UIColor(0.30f, 0.30f, 0.30f, 1.0f); ::XCEngine::UI::UIColor closeGlyphColor = ::XCEngine::UI::UIColor(0.83f, 0.83f, 0.83f, 1.0f); + ::XCEngine::UI::UIColor reorderPreviewColor = + ::XCEngine::UI::UIColor(0.82f, 0.82f, 0.82f, 1.0f); +}; + +struct UIEditorTabStripInsertionPreview { + bool visible = false; + std::size_t insertionIndex = UIEditorTabStripInvalidIndex; + ::XCEngine::UI::UIRect indicatorRect = {}; }; struct UIEditorTabStripLayout { @@ -91,6 +114,7 @@ struct UIEditorTabStripLayout { std::vector<::XCEngine::UI::UIRect> closeButtonRects = {}; std::vector showCloseButtons = {}; std::size_t selectedIndex = UIEditorTabStripInvalidIndex; + UIEditorTabStripInsertionPreview insertionPreview = {}; }; enum class UIEditorTabStripHitTargetKind : std::uint8_t { diff --git a/new_editor/include/XCEditor/Collections/UIEditorTabStripInteraction.h b/new_editor/include/XCEditor/Collections/UIEditorTabStripInteraction.h index 1fffef3a..daca89ae 100644 --- a/new_editor/include/XCEditor/Collections/UIEditorTabStripInteraction.h +++ b/new_editor/include/XCEditor/Collections/UIEditorTabStripInteraction.h @@ -3,6 +3,7 @@ #include #include +#include #include #include @@ -13,9 +14,13 @@ namespace XCEngine::UI::Editor { struct UIEditorTabStripInteractionState { Widgets::UIEditorTabStripState tabStripState = {}; ::XCEngine::UI::Widgets::UITabStripModel navigationModel = {}; + ::XCEngine::UI::Widgets::UIDragDropState reorderDragState = {}; Widgets::UIEditorTabStripHitTarget pressedTarget = {}; ::XCEngine::UI::UIPoint pointerPosition = {}; + std::size_t reorderSourceIndex = Widgets::UIEditorTabStripInvalidIndex; + std::size_t reorderPreviewIndex = Widgets::UIEditorTabStripInvalidIndex; bool hasPointerPosition = false; + bool reorderCaptureActive = false; }; struct UIEditorTabStripInteractionResult { @@ -23,11 +28,23 @@ struct UIEditorTabStripInteractionResult { bool selectionChanged = false; bool closeRequested = false; bool keyboardNavigated = false; + bool requestPointerCapture = false; + bool releasePointerCapture = false; + bool dragStarted = false; + bool dragEnded = false; + bool dragCanceled = false; + bool reorderRequested = false; + bool reorderPreviewActive = false; Widgets::UIEditorTabStripHitTarget hitTarget = {}; std::string selectedTabId = {}; std::size_t selectedIndex = Widgets::UIEditorTabStripInvalidIndex; std::string closedTabId = {}; std::size_t closedIndex = Widgets::UIEditorTabStripInvalidIndex; + std::string draggedTabId = {}; + std::size_t dragSourceIndex = Widgets::UIEditorTabStripInvalidIndex; + std::size_t dropInsertionIndex = Widgets::UIEditorTabStripInvalidIndex; + std::size_t reorderToIndex = Widgets::UIEditorTabStripInvalidIndex; + std::size_t reorderPreviewIndex = Widgets::UIEditorTabStripInvalidIndex; }; struct UIEditorTabStripInteractionFrame { diff --git a/new_editor/include/XCEditor/Foundation/UIEditorRuntimeTrace.h b/new_editor/include/XCEditor/Foundation/UIEditorRuntimeTrace.h new file mode 100644 index 00000000..1839db21 --- /dev/null +++ b/new_editor/include/XCEditor/Foundation/UIEditorRuntimeTrace.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include +#include + +namespace XCEngine::UI::Editor { + +void InitializeUIEditorRuntimeTrace(const std::filesystem::path& logRoot); +void ShutdownUIEditorRuntimeTrace(); + +void AppendUIEditorRuntimeTrace( + std::string_view channel, + std::string_view message); + +void AppendUIEditorCrashTrace( + std::uint32_t exceptionCode, + const void* exceptionAddress); + +std::filesystem::path GetUIEditorRuntimeTracePath(); +std::filesystem::path GetUIEditorCrashTracePath(); + +} // namespace XCEngine::UI::Editor diff --git a/new_editor/include/XCEditor/Foundation/UIEditorTextMeasurement.h b/new_editor/include/XCEditor/Foundation/UIEditorTextMeasurement.h new file mode 100644 index 00000000..a28f2068 --- /dev/null +++ b/new_editor/include/XCEditor/Foundation/UIEditorTextMeasurement.h @@ -0,0 +1,19 @@ +#pragma once + +#include + +namespace XCEngine::UI::Editor { + +struct UIEditorTextMeasureRequest { + std::string_view text = {}; + float fontSize = 0.0f; +}; + +class UIEditorTextMeasurer { +public: + virtual ~UIEditorTextMeasurer() = default; + + virtual float MeasureTextWidth(const UIEditorTextMeasureRequest& request) const = 0; +}; + +} // namespace XCEngine::UI::Editor diff --git a/new_editor/include/XCEditor/Shell/UIEditorDockHost.h b/new_editor/include/XCEditor/Shell/UIEditorDockHost.h index 24bf03b9..cb970aa6 100644 --- a/new_editor/include/XCEditor/Shell/UIEditorDockHost.h +++ b/new_editor/include/XCEditor/Shell/UIEditorDockHost.h @@ -40,16 +40,27 @@ struct UIEditorDockHostTabStripVisualState { UIEditorTabStripState state = {}; }; +struct UIEditorDockHostDropPreviewState { + bool visible = false; + std::string sourceNodeId = {}; + std::string sourcePanelId = {}; + std::string targetNodeId = {}; + UIEditorWorkspaceDockPlacement placement = + UIEditorWorkspaceDockPlacement::Center; + std::size_t insertionIndex = UIEditorTabStripInvalidIndex; +}; + struct UIEditorDockHostState { bool focused = false; UIEditorDockHostHitTarget hoveredTarget = {}; std::string activeSplitterNodeId = {}; std::vector tabStripStates = {}; + UIEditorDockHostDropPreviewState dropPreview = {}; }; struct UIEditorDockHostMetrics { ::XCEngine::UI::Layout::UISplitterMetrics splitterMetrics = - ::XCEngine::UI::Layout::UISplitterMetrics{ 4.0f, 12.0f }; + ::XCEngine::UI::Layout::UISplitterMetrics{ 1.0f, 10.0f }; UIEditorTabStripMetrics tabStripMetrics = {}; UIEditorPanelFrameMetrics panelFrameMetrics = {}; ::XCEngine::UI::UISize minimumStandalonePanelBodySize = @@ -75,6 +86,10 @@ struct UIEditorDockHostPalette { ::XCEngine::UI::UIColor(0.70f, 0.72f, 0.74f, 1.0f); ::XCEngine::UI::UIColor placeholderMutedColor = ::XCEngine::UI::UIColor(0.58f, 0.59f, 0.62f, 1.0f); + ::XCEngine::UI::UIColor dropPreviewFillColor = + ::XCEngine::UI::UIColor(0.88f, 0.88f, 0.88f, 0.14f); + ::XCEngine::UI::UIColor dropPreviewBorderColor = + ::XCEngine::UI::UIColor(0.94f, 0.94f, 0.94f, 0.78f); }; struct UIEditorDockHostTabItemLayout { @@ -107,10 +122,20 @@ struct UIEditorDockHostTabStackLayout { UIEditorPanelFrameLayout contentFrameLayout = {}; }; +struct UIEditorDockHostDropPreviewLayout { + bool visible = false; + std::string targetNodeId = {}; + UIEditorWorkspaceDockPlacement placement = + UIEditorWorkspaceDockPlacement::Center; + std::size_t insertionIndex = UIEditorTabStripInvalidIndex; + ::XCEngine::UI::UIRect previewRect = {}; +}; + struct UIEditorDockHostLayout { ::XCEngine::UI::UIRect bounds = {}; std::vector splitters = {}; std::vector tabStacks = {}; + UIEditorDockHostDropPreviewLayout dropPreview = {}; }; // Allows higher-level compose to own panel body presentation while DockHost diff --git a/new_editor/include/XCEditor/Shell/UIEditorDockHostInteraction.h b/new_editor/include/XCEditor/Shell/UIEditorDockHostInteraction.h index a80901b6..6da8711f 100644 --- a/new_editor/include/XCEditor/Shell/UIEditorDockHostInteraction.h +++ b/new_editor/include/XCEditor/Shell/UIEditorDockHostInteraction.h @@ -21,6 +21,8 @@ struct UIEditorDockHostInteractionState { Widgets::UIEditorDockHostState dockHostState = {}; ::XCEngine::UI::Widgets::UISplitterDragState splitterDragState = {}; std::vector tabStripInteractions = {}; + std::string activeTabDragNodeId = {}; + std::string activeTabDragPanelId = {}; ::XCEngine::UI::UIPoint pointerPosition = {}; bool hasPointerPosition = false; }; diff --git a/new_editor/include/XCEditor/Shell/UIEditorMenuBar.h b/new_editor/include/XCEditor/Shell/UIEditorMenuBar.h index 80919d54..145c243a 100644 --- a/new_editor/include/XCEditor/Shell/UIEditorMenuBar.h +++ b/new_editor/include/XCEditor/Shell/UIEditorMenuBar.h @@ -29,12 +29,13 @@ struct UIEditorMenuBarMetrics { float barHeight = 24.0f; float horizontalInset = 0.0f; float verticalInset = 2.0f; - float buttonGap = 2.0f; - float buttonPaddingX = 10.0f; + float buttonGap = 1.0f; + float buttonPaddingX = 4.0f; + float labelFontSize = 13.0f; float estimatedGlyphWidth = 6.5f; float labelInsetY = -1.5f; float barCornerRounding = 0.0f; - float buttonCornerRounding = 0.0f; + float buttonCornerRounding = 0.75f; float baseBorderThickness = 1.0f; float focusedBorderThickness = 1.0f; float openBorderThickness = 1.0f; @@ -42,23 +43,23 @@ struct UIEditorMenuBarMetrics { struct UIEditorMenuBarPalette { ::XCEngine::UI::UIColor barColor = - ::XCEngine::UI::UIColor(0.14f, 0.14f, 0.14f, 1.0f); + ::XCEngine::UI::UIColor(1.0f, 1.0f, 1.0f, 1.0f); ::XCEngine::UI::UIColor buttonColor = - ::XCEngine::UI::UIColor(0.18f, 0.18f, 0.18f, 1.0f); + ::XCEngine::UI::UIColor(0.0f, 0.0f, 0.0f, 0.0f); ::XCEngine::UI::UIColor buttonHoveredColor = - ::XCEngine::UI::UIColor(0.23f, 0.23f, 0.23f, 1.0f); + ::XCEngine::UI::UIColor(0.88f, 0.88f, 0.88f, 1.0f); ::XCEngine::UI::UIColor buttonOpenColor = - ::XCEngine::UI::UIColor(0.28f, 0.28f, 0.28f, 1.0f); + ::XCEngine::UI::UIColor(0.84f, 0.84f, 0.84f, 1.0f); ::XCEngine::UI::UIColor borderColor = - ::XCEngine::UI::UIColor(0.24f, 0.24f, 0.24f, 1.0f); + ::XCEngine::UI::UIColor(0.0f, 0.0f, 0.0f, 0.0f); ::XCEngine::UI::UIColor focusedBorderColor = - ::XCEngine::UI::UIColor(0.38f, 0.38f, 0.38f, 1.0f); + ::XCEngine::UI::UIColor(0.0f, 0.0f, 0.0f, 0.0f); ::XCEngine::UI::UIColor openBorderColor = - ::XCEngine::UI::UIColor(0.34f, 0.34f, 0.34f, 1.0f); + ::XCEngine::UI::UIColor(0.0f, 0.0f, 0.0f, 0.0f); ::XCEngine::UI::UIColor textPrimary = - ::XCEngine::UI::UIColor(0.85f, 0.85f, 0.85f, 1.0f); + ::XCEngine::UI::UIColor(0.08f, 0.08f, 0.08f, 1.0f); ::XCEngine::UI::UIColor textMuted = - ::XCEngine::UI::UIColor(0.67f, 0.67f, 0.67f, 1.0f); + ::XCEngine::UI::UIColor(0.28f, 0.28f, 0.28f, 1.0f); ::XCEngine::UI::UIColor textDisabled = ::XCEngine::UI::UIColor(0.50f, 0.50f, 0.50f, 1.0f); }; diff --git a/new_editor/include/XCEditor/Shell/UIEditorMenuPopup.h b/new_editor/include/XCEditor/Shell/UIEditorMenuPopup.h index 287c401f..889189c4 100644 --- a/new_editor/include/XCEditor/Shell/UIEditorMenuPopup.h +++ b/new_editor/include/XCEditor/Shell/UIEditorMenuPopup.h @@ -34,19 +34,19 @@ struct UIEditorMenuPopupState { }; struct UIEditorMenuPopupMetrics { - float contentPaddingX = 6.0f; + float contentPaddingX = 2.0f; float contentPaddingY = 6.0f; float itemHeight = 28.0f; float separatorHeight = 9.0f; - float checkColumnWidth = 18.0f; - float shortcutGap = 20.0f; - float submenuIndicatorWidth = 14.0f; - float rowCornerRounding = 5.0f; - float popupCornerRounding = 8.0f; - float labelInsetX = 14.0f; + float checkColumnWidth = 12.0f; + float shortcutGap = 14.0f; + float submenuIndicatorWidth = 10.0f; + float rowCornerRounding = 2.5f; + float popupCornerRounding = 4.0f; + float labelInsetX = 4.0f; float labelInsetY = -1.0f; float labelFontSize = 13.0f; - float shortcutInsetRight = 24.0f; + float shortcutInsetRight = 8.0f; float estimatedGlyphWidth = 7.0f; float glyphFontSize = 12.0f; float separatorThickness = 1.0f; diff --git a/new_editor/include/XCEditor/Shell/UIEditorShellCompose.h b/new_editor/include/XCEditor/Shell/UIEditorShellCompose.h index dba5b00d..94c71e63 100644 --- a/new_editor/include/XCEditor/Shell/UIEditorShellCompose.h +++ b/new_editor/include/XCEditor/Shell/UIEditorShellCompose.h @@ -110,6 +110,12 @@ struct UIEditorShellComposeFrame { UIEditorWorkspaceComposeFrame workspaceFrame = {}; }; +UIEditorShellComposeLayout BuildUIEditorShellComposeLayout( + const ::XCEngine::UI::UIRect& bounds, + const std::vector& menuBarItems, + const std::vector& statusSegments, + const UIEditorShellComposeMetrics& metrics = {}); + UIEditorShellComposeLayout BuildUIEditorShellComposeLayout( const ::XCEngine::UI::UIRect& bounds, const std::vector& menuBarItems, diff --git a/new_editor/include/XCEditor/Shell/UIEditorShellInteraction.h b/new_editor/include/XCEditor/Shell/UIEditorShellInteraction.h index bed7b813..8e0aa266 100644 --- a/new_editor/include/XCEditor/Shell/UIEditorShellInteraction.h +++ b/new_editor/include/XCEditor/Shell/UIEditorShellInteraction.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -50,6 +51,7 @@ struct UIEditorShellInteractionPalette { struct UIEditorShellInteractionServices { const UIEditorCommandDispatcher* commandDispatcher = nullptr; const UIEditorShortcutManager* shortcutManager = nullptr; + const UIEditorTextMeasurer* textMeasurer = nullptr; }; struct UIEditorShellInteractionMenuButtonRequest { diff --git a/new_editor/include/XCEditor/Shell/UIEditorWorkspaceController.h b/new_editor/include/XCEditor/Shell/UIEditorWorkspaceController.h index b664fb23..9ac80923 100644 --- a/new_editor/include/XCEditor/Shell/UIEditorWorkspaceController.h +++ b/new_editor/include/XCEditor/Shell/UIEditorWorkspaceController.h @@ -105,6 +105,21 @@ public: UIEditorWorkspaceLayoutOperationResult SetSplitRatio( std::string_view nodeId, float splitRatio); + UIEditorWorkspaceLayoutOperationResult ReorderTab( + std::string_view nodeId, + std::string_view panelId, + std::size_t targetVisibleInsertionIndex); + UIEditorWorkspaceLayoutOperationResult MoveTabToStack( + std::string_view sourceNodeId, + std::string_view panelId, + std::string_view targetNodeId, + std::size_t targetVisibleInsertionIndex); + UIEditorWorkspaceLayoutOperationResult DockTabRelative( + std::string_view sourceNodeId, + std::string_view panelId, + std::string_view targetNodeId, + UIEditorWorkspaceDockPlacement placement, + float splitRatio = 0.5f); UIEditorWorkspaceCommandResult Dispatch(const UIEditorWorkspaceCommand& command); private: diff --git a/new_editor/include/XCEditor/Shell/UIEditorWorkspaceModel.h b/new_editor/include/XCEditor/Shell/UIEditorWorkspaceModel.h index 71761e67..c6e72434 100644 --- a/new_editor/include/XCEditor/Shell/UIEditorWorkspaceModel.h +++ b/new_editor/include/XCEditor/Shell/UIEditorWorkspaceModel.h @@ -8,6 +8,8 @@ namespace XCEngine::UI::Editor { +struct UIEditorWorkspaceSession; + enum class UIEditorWorkspaceNodeKind : std::uint8_t { Panel = 0, TabStack, @@ -19,6 +21,14 @@ enum class UIEditorWorkspaceSplitAxis : std::uint8_t { Vertical }; +enum class UIEditorWorkspaceDockPlacement : std::uint8_t { + Center = 0, + Left, + Right, + Top, + Bottom +}; + struct UIEditorWorkspacePanelState { std::string panelId = {}; std::string title = {}; @@ -133,4 +143,28 @@ bool TrySetUIEditorWorkspaceSplitRatio( std::string_view nodeId, float splitRatio); +bool TryReorderUIEditorWorkspaceTab( + UIEditorWorkspaceModel& workspace, + const UIEditorWorkspaceSession& session, + std::string_view nodeId, + std::string_view panelId, + std::size_t targetVisibleInsertionIndex); + +bool TryMoveUIEditorWorkspaceTabToStack( + UIEditorWorkspaceModel& workspace, + const UIEditorWorkspaceSession& session, + std::string_view sourceNodeId, + std::string_view panelId, + std::string_view targetNodeId, + std::size_t targetVisibleInsertionIndex); + +bool TryDockUIEditorWorkspaceTabRelative( + UIEditorWorkspaceModel& workspace, + const UIEditorWorkspaceSession& session, + std::string_view sourceNodeId, + std::string_view panelId, + std::string_view targetNodeId, + UIEditorWorkspaceDockPlacement placement, + float splitRatio = 0.5f); + } // namespace XCEngine::UI::Editor diff --git a/new_editor/src/Collections/UIEditorTabStrip.cpp b/new_editor/src/Collections/UIEditorTabStrip.cpp index d2004f50..9258ce74 100644 --- a/new_editor/src/Collections/UIEditorTabStrip.cpp +++ b/new_editor/src/Collections/UIEditorTabStrip.cpp @@ -18,8 +18,7 @@ using ::XCEngine::UI::Layout::MeasureUITabStripHeaderWidth; constexpr float kTabRounding = 0.0f; constexpr float kStripRounding = 0.0f; -constexpr float kHeaderFontSize = 11.0f; -constexpr float kCloseFontSize = 10.0f; +constexpr float kHeaderFontSize = 13.0f; float ClampNonNegative(float value) { return (std::max)(value, 0.0f); @@ -44,10 +43,6 @@ float ResolveTabRounding(const UIEditorTabStripMetrics& metrics) { return kTabRounding; } -float ResolveCloseButtonRounding(const UIEditorTabStripMetrics& metrics) { - return (std::min)(ClampNonNegative(metrics.closeButtonExtent) * 0.2f, 2.0f); -} - std::size_t ResolveSelectedIndex( std::size_t itemCount, std::size_t selectedIndex) { @@ -78,20 +73,18 @@ float ResolveTabTextTop( return rect.y + (std::max)(0.0f, (rect.height - kHeaderFontSize) * 0.5f) + metrics.labelInsetY; } -float ResolveCloseTextTop(const UIRect& rect) { - return rect.y + (std::max)(0.0f, (rect.height - kCloseFontSize) * 0.5f) - 0.5f; -} - UIColor ResolveStripBorderColor( const UIEditorTabStripState& state, const UIEditorTabStripPalette& palette) { - return state.focused ? palette.focusedBorderColor : palette.stripBorderColor; + (void)state; + return palette.stripBorderColor; } float ResolveStripBorderThickness( const UIEditorTabStripState& state, const UIEditorTabStripMetrics& metrics) { - return state.focused ? metrics.focusedBorderThickness : metrics.baseBorderThickness; + (void)state; + return metrics.baseBorderThickness; } UIColor ResolveTabFillColor( @@ -115,7 +108,8 @@ UIColor ResolveTabBorderColor( bool focused, const UIEditorTabStripPalette& palette) { if (selected) { - return focused ? palette.focusedBorderColor : palette.tabSelectedBorderColor; + (void)focused; + return palette.tabSelectedBorderColor; } if (hovered) { @@ -125,33 +119,121 @@ UIColor ResolveTabBorderColor( return palette.tabBorderColor; } -float ResolveTabBorderThickness( - bool selected, - bool focused, - const UIEditorTabStripMetrics& metrics) { +float ResolveTabBorderThickness(bool selected, bool focused, const UIEditorTabStripMetrics& metrics) { if (selected) { - return focused ? metrics.focusedBorderThickness : metrics.selectedBorderThickness; + (void)focused; + return metrics.selectedBorderThickness; } return metrics.baseBorderThickness; } -UIRect BuildCloseButtonRect( - const UIRect& headerRect, +void AppendHeaderContentSeparator( + UIDrawList& drawList, + const UIEditorTabStripLayout& layout, + const UIEditorTabStripPalette& palette, const UIEditorTabStripMetrics& metrics) { - const float insetY = ClampNonNegative(metrics.closeInsetY); - const float extent = (std::min)( - ClampNonNegative(metrics.closeButtonExtent), - (std::max)(headerRect.height - insetY * 2.0f, 0.0f)); - if (extent <= 0.0f) { - return {}; + if (layout.headerRect.width <= 0.0f || + layout.headerRect.height <= 0.0f || + layout.contentRect.height <= 0.0f) { + return; } - return UIRect( - headerRect.x + headerRect.width - ClampNonNegative(metrics.closeInsetRight) - extent, - headerRect.y + insetY + (std::max)(0.0f, headerRect.height - insetY * 2.0f - extent) * 0.5f, - extent, - extent); + const float thickness = (std::max)(ClampNonNegative(metrics.baseBorderThickness), 1.0f); + const float separatorY = layout.contentRect.y; + const float separatorLeft = layout.headerRect.x; + const float separatorRight = layout.headerRect.x + layout.headerRect.width; + + if (layout.selectedIndex == UIEditorTabStripInvalidIndex || + layout.selectedIndex >= layout.tabHeaderRects.size()) { + drawList.AddFilledRect( + UIRect( + separatorLeft, + separatorY, + (std::max)(separatorRight - separatorLeft, 0.0f), + thickness), + palette.headerContentSeparatorColor); + return; + } + + const UIRect& selectedRect = layout.tabHeaderRects[layout.selectedIndex]; + const float gapLeft = (std::max)(separatorLeft, selectedRect.x + 1.0f); + const float gapRight = (std::min)(separatorRight, selectedRect.x + selectedRect.width - 1.0f); + + if (gapLeft > separatorLeft) { + drawList.AddFilledRect( + UIRect( + separatorLeft, + separatorY, + (std::max)(gapLeft - separatorLeft, 0.0f), + thickness), + palette.headerContentSeparatorColor); + } + + if (gapRight < separatorRight) { + drawList.AddFilledRect( + UIRect( + gapRight, + separatorY, + (std::max)(separatorRight - gapRight, 0.0f), + thickness), + palette.headerContentSeparatorColor); + } +} + +void AppendSelectedTabBottomBorderMask( + UIDrawList& drawList, + const UIRect& rect, + float thickness, + const UIEditorTabStripPalette& palette) { + if (rect.width <= 0.0f || rect.height <= 0.0f || thickness <= 0.0f) { + return; + } + + const float maskHeight = (std::max)(1.0f, thickness + 1.0f); + drawList.AddFilledRect( + UIRect( + rect.x + 1.0f, + rect.y + rect.height - maskHeight, + (std::max)(rect.width - 2.0f, 0.0f), + maskHeight + 1.0f), + palette.contentBackgroundColor, + 0.0f); +} + +UIEditorTabStripInsertionPreview BuildInsertionPreview( + const UIEditorTabStripLayout& layout, + const UIEditorTabStripState& state, + const UIEditorTabStripMetrics& metrics) { + UIEditorTabStripInsertionPreview preview = {}; + if (!state.reorder.dragging || + state.reorder.previewInsertionIndex == UIEditorTabStripInvalidIndex || + layout.tabHeaderRects.empty() || + state.reorder.previewInsertionIndex > layout.tabHeaderRects.size() || + layout.headerRect.height <= 0.0f) { + return preview; + } + + float indicatorX = layout.tabHeaderRects.front().x; + if (state.reorder.previewInsertionIndex >= layout.tabHeaderRects.size()) { + const UIRect& lastRect = layout.tabHeaderRects.back(); + indicatorX = lastRect.x + lastRect.width; + } else { + indicatorX = layout.tabHeaderRects[state.reorder.previewInsertionIndex].x; + } + + const float thickness = (std::max)(ClampNonNegative(metrics.reorderPreviewThickness), 1.0f); + const float insetY = ClampNonNegative(metrics.reorderPreviewInsetY); + const float indicatorHeight = (std::max)(layout.headerRect.height - insetY * 2.0f, thickness); + + preview.visible = true; + preview.insertionIndex = state.reorder.previewInsertionIndex; + preview.indicatorRect = UIRect( + indicatorX - thickness * 0.5f, + layout.headerRect.y + insetY, + thickness, + indicatorHeight); + return preview; } } // namespace @@ -162,13 +244,8 @@ float ResolveUIEditorTabStripDesiredHeaderLabelWidth( const float labelWidth = ResolveEstimatedLabelWidth(item, metrics); const float horizontalPadding = ClampNonNegative(metrics.layoutMetrics.tabHorizontalPadding); const float extraLeftInset = (std::max)(ClampNonNegative(metrics.labelInsetX) - horizontalPadding, 0.0f); - const float extraRightInset = (std::max)(ClampNonNegative(metrics.closeInsetRight) - horizontalPadding, 0.0f); - const float closeBudget = item.closable - ? ClampNonNegative(metrics.closeButtonExtent) + - ClampNonNegative(metrics.closeButtonGap) + - extraRightInset - : 0.0f; - return labelWidth + extraLeftInset + closeBudget; + (void)item; + return labelWidth + extraLeftInset; } std::size_t ResolveUIEditorTabStripSelectedIndex( @@ -188,34 +265,6 @@ std::size_t ResolveUIEditorTabStripSelectedIndex( return ResolveSelectedIndex(items.size(), fallbackIndex); } -std::size_t ResolveUIEditorTabStripSelectedIndexAfterClose( - std::size_t selectedIndex, - std::size_t closedIndex, - std::size_t itemCountBeforeClose) { - if (itemCountBeforeClose == 0u || closedIndex >= itemCountBeforeClose) { - return UIEditorTabStripInvalidIndex; - } - - const std::size_t itemCountAfterClose = itemCountBeforeClose - 1u; - if (itemCountAfterClose == 0u) { - return UIEditorTabStripInvalidIndex; - } - - if (selectedIndex == UIEditorTabStripInvalidIndex || selectedIndex >= itemCountBeforeClose) { - return (std::min)(closedIndex, itemCountAfterClose - 1u); - } - - if (closedIndex < selectedIndex) { - return selectedIndex - 1u; - } - - if (closedIndex > selectedIndex) { - return selectedIndex; - } - - return (std::min)(selectedIndex, itemCountAfterClose - 1u); -} - UIEditorTabStripLayout BuildUIEditorTabStripLayout( const UIRect& bounds, const std::vector& items, @@ -245,17 +294,7 @@ UIEditorTabStripLayout BuildUIEditorTabStripLayout( layout.tabHeaderRects = arranged.tabHeaderRects; layout.closeButtonRects.resize(items.size()); layout.showCloseButtons.resize(items.size(), false); - - for (std::size_t index = 0; index < items.size(); ++index) { - if (!items[index].closable) { - continue; - } - - layout.closeButtonRects[index] = BuildCloseButtonRect(layout.tabHeaderRects[index], metrics); - layout.showCloseButtons[index] = - layout.closeButtonRects[index].width > 0.0f && layout.closeButtonRects[index].height > 0.0f; - } - + layout.insertionPreview = BuildInsertionPreview(layout, state, metrics); return layout; } @@ -269,15 +308,6 @@ UIEditorTabStripHitTarget HitTestUIEditorTabStrip( return target; } - for (std::size_t index = 0; index < layout.closeButtonRects.size(); ++index) { - if (layout.showCloseButtons[index] && - IsPointInsideRect(layout.closeButtonRects[index], point)) { - target.kind = UIEditorTabStripHitTargetKind::CloseButton; - target.index = index; - return target; - } - } - for (std::size_t index = 0; index < layout.tabHeaderRects.size(); ++index) { if (IsPointInsideRect(layout.tabHeaderRects[index], point)) { target.kind = UIEditorTabStripHitTargetKind::Tab; @@ -322,7 +352,7 @@ void AppendUIEditorTabStripBackground( for (std::size_t index = 0; index < layout.tabHeaderRects.size(); ++index) { const bool selected = layout.selectedIndex == index; - const bool hovered = state.hoveredIndex == index || state.closeHoveredIndex == index; + const bool hovered = state.hoveredIndex == index; drawList.AddFilledRect( layout.tabHeaderRects[index], ResolveTabFillColor(selected, hovered, palette), @@ -332,6 +362,20 @@ void AppendUIEditorTabStripBackground( ResolveTabBorderColor(selected, hovered, state.focused, palette), ResolveTabBorderThickness(selected, state.focused, metrics), tabRounding); + if (selected) { + AppendSelectedTabBottomBorderMask( + drawList, + layout.tabHeaderRects[index], + ResolveTabBorderThickness(selected, state.focused, metrics), + palette); + } + } + + if (layout.insertionPreview.visible) { + drawList.AddFilledRect( + layout.insertionPreview.indicatorRect, + palette.reorderPreviewColor, + 0.0f); } } @@ -342,6 +386,8 @@ void AppendUIEditorTabStripForeground( const UIEditorTabStripState& state, const UIEditorTabStripPalette& palette, const UIEditorTabStripMetrics& metrics) { + AppendHeaderContentSeparator(drawList, layout, palette, metrics); + const float leftInset = (std::max)( ClampNonNegative(metrics.layoutMetrics.tabHorizontalPadding), ClampNonNegative(metrics.labelInsetX)); @@ -349,12 +395,9 @@ void AppendUIEditorTabStripForeground( for (std::size_t index = 0; index < items.size() && index < layout.tabHeaderRects.size(); ++index) { const UIRect& tabRect = layout.tabHeaderRects[index]; const bool selected = layout.selectedIndex == index; - const bool hovered = state.hoveredIndex == index || state.closeHoveredIndex == index; + const bool hovered = state.hoveredIndex == index; const float textLeft = tabRect.x + leftInset; - float textRight = tabRect.x + tabRect.width - ClampNonNegative(metrics.layoutMetrics.tabHorizontalPadding); - if (layout.showCloseButtons[index]) { - textRight = layout.closeButtonRects[index].x - ClampNonNegative(metrics.closeButtonGap); - } + const float textRight = tabRect.x + tabRect.width - ClampNonNegative(metrics.layoutMetrics.tabHorizontalPadding); if (textRight > textLeft) { const UIRect clipRect( @@ -370,30 +413,6 @@ void AppendUIEditorTabStripForeground( kHeaderFontSize); drawList.PopClipRect(); } - - if (!layout.showCloseButtons[index]) { - continue; - } - - const bool closeHovered = state.closeHoveredIndex == index; - const UIRect& closeRect = layout.closeButtonRects[index]; - const float closeRounding = ResolveCloseButtonRounding(metrics); - drawList.AddFilledRect( - closeRect, - closeHovered ? palette.closeButtonHoveredColor : palette.closeButtonColor, - closeRounding); - drawList.AddRectOutline( - closeRect, - palette.closeButtonBorderColor, - 1.0f, - closeRounding); - drawList.AddText( - UIPoint( - closeRect.x + (std::max)(0.0f, (closeRect.width - 7.0f) * 0.5f), - ResolveCloseTextTop(closeRect)), - "X", - palette.closeGlyphColor, - kCloseFontSize); } } diff --git a/new_editor/src/Collections/UIEditorTabStripInteraction.cpp b/new_editor/src/Collections/UIEditorTabStripInteraction.cpp index 51925558..837c4160 100644 --- a/new_editor/src/Collections/UIEditorTabStripInteraction.cpp +++ b/new_editor/src/Collections/UIEditorTabStripInteraction.cpp @@ -2,6 +2,7 @@ #include +#include #include namespace XCEngine::UI::Editor { @@ -12,6 +13,16 @@ using ::XCEngine::Input::KeyCode; using ::XCEngine::UI::UIInputEvent; using ::XCEngine::UI::UIInputEventType; using ::XCEngine::UI::UIPointerButton; +using ::XCEngine::UI::Widgets::BeginUIDragDrop; +using ::XCEngine::UI::Widgets::CancelUIDragDrop; +using ::XCEngine::UI::Widgets::EndUIDragDrop; +using ::XCEngine::UI::Widgets::UIDragDropOperation; +using ::XCEngine::UI::Widgets::UIDragDropPayload; +using ::XCEngine::UI::Widgets::UIDragDropResult; +using ::XCEngine::UI::Widgets::UIDragDropSourceDescriptor; +using ::XCEngine::UI::Widgets::UIDragDropTargetDescriptor; +using ::XCEngine::UI::Widgets::UpdateUIDragDropPointer; +using ::XCEngine::UI::Widgets::UpdateUIDragDropTarget; using Widgets::BuildUIEditorTabStripLayout; using Widgets::HitTestUIEditorTabStrip; using Widgets::ResolveUIEditorTabStripSelectedIndex; @@ -19,6 +30,9 @@ using Widgets::UIEditorTabStripHitTarget; using Widgets::UIEditorTabStripHitTargetKind; using Widgets::UIEditorTabStripInvalidIndex; +constexpr ::XCEngine::UI::UIElementId kTabStripDragOwnerId = 0x58435441u; +constexpr std::string_view kTabStripDragPayloadType = "xc.editor.tab"; + bool ShouldUsePointerPosition(const UIInputEvent& event) { switch (event.type) { case UIInputEventType::PointerMove: @@ -53,6 +67,14 @@ void ClearHoverState(UIEditorTabStripInteractionState& state) { state.tabStripState.closeHoveredIndex = UIEditorTabStripInvalidIndex; } +void ClearReorderState(UIEditorTabStripInteractionState& state) { + state.tabStripState.reorder = {}; + state.reorderDragState = {}; + state.reorderSourceIndex = UIEditorTabStripInvalidIndex; + state.reorderPreviewIndex = UIEditorTabStripInvalidIndex; + state.reorderCaptureActive = false; +} + void SyncHoverTarget( UIEditorTabStripInteractionState& state, const Widgets::UIEditorTabStripLayout& layout) { @@ -68,11 +90,6 @@ void SyncHoverTarget( } switch (hitTarget.kind) { - case UIEditorTabStripHitTargetKind::CloseButton: - state.tabStripState.hoveredIndex = hitTarget.index; - state.tabStripState.closeHoveredIndex = hitTarget.index; - break; - case UIEditorTabStripHitTargetKind::Tab: state.tabStripState.hoveredIndex = hitTarget.index; break; @@ -145,6 +162,205 @@ bool ApplyKeyboardNavigation( } } +bool HasReorderInteraction(const UIEditorTabStripInteractionState& state) { + return state.reorderDragState.armed || state.reorderDragState.active; +} + +std::size_t ResolveVisibleDropInsertionIndex( + const Widgets::UIEditorTabStripLayout& layout, + const ::XCEngine::UI::UIPoint& pointerPosition) { + if (!IsPointInside(layout.headerRect, pointerPosition)) { + return UIEditorTabStripInvalidIndex; + } + + std::size_t insertionIndex = 0u; + for (const ::XCEngine::UI::UIRect& rect : layout.tabHeaderRects) { + const float midpoint = rect.x + rect.width * 0.5f; + if (pointerPosition.x > midpoint) { + ++insertionIndex; + } + } + + return insertionIndex; +} + +std::size_t ResolveReorderTargetIndex( + std::size_t sourceIndex, + std::size_t dropInsertionIndex, + std::size_t itemCount) { + if (sourceIndex >= itemCount || dropInsertionIndex > itemCount) { + return UIEditorTabStripInvalidIndex; + } + + if (dropInsertionIndex <= sourceIndex) { + return dropInsertionIndex; + } + + return dropInsertionIndex - 1u; +} + +std::size_t ResolveDropInsertionIndexFromPreviewTargetIndex( + std::size_t sourceIndex, + std::size_t previewTargetIndex) { + if (sourceIndex == UIEditorTabStripInvalidIndex || + previewTargetIndex == UIEditorTabStripInvalidIndex) { + return UIEditorTabStripInvalidIndex; + } + + return previewTargetIndex <= sourceIndex + ? previewTargetIndex + : previewTargetIndex + 1u; +} + +std::size_t ResolveCommittedReorderInsertionIndex( + const UIEditorTabStripInteractionState& state, + const Widgets::UIEditorTabStripLayout& layout) { + const std::size_t previewInsertionIndex = + ResolveDropInsertionIndexFromPreviewTargetIndex( + state.reorderSourceIndex, + state.reorderPreviewIndex); + if (previewInsertionIndex != UIEditorTabStripInvalidIndex) { + return previewInsertionIndex; + } + + if (state.tabStripState.reorder.previewInsertionIndex != UIEditorTabStripInvalidIndex) { + return state.tabStripState.reorder.previewInsertionIndex; + } + + if (!state.hasPointerPosition) { + return UIEditorTabStripInvalidIndex; + } + + return ResolveVisibleDropInsertionIndex(layout, state.pointerPosition); +} + +void SyncReorderPreview( + UIEditorTabStripInteractionState& state, + const Widgets::UIEditorTabStripLayout& layout, + const std::vector& items, + UIEditorTabStripInteractionResult& result) { + state.tabStripState.reorder.armed = state.reorderDragState.armed; + state.tabStripState.reorder.dragging = state.reorderDragState.active; + state.tabStripState.reorder.sourceIndex = state.reorderSourceIndex; + state.tabStripState.reorder.pressedIndex = state.reorderSourceIndex; + state.tabStripState.reorder.pressPosition = state.reorderDragState.pointerDownPosition; + result.dragSourceIndex = state.reorderSourceIndex; + if (state.reorderSourceIndex < items.size()) { + result.draggedTabId = items[state.reorderSourceIndex].tabId; + } + + if (!state.reorderDragState.active || !state.hasPointerPosition || items.size() < 2u) { + UIDragDropResult dragDropResult = {}; + UpdateUIDragDropTarget(state.reorderDragState, nullptr, &dragDropResult); + state.tabStripState.reorder.previewInsertionIndex = UIEditorTabStripInvalidIndex; + state.reorderPreviewIndex = UIEditorTabStripInvalidIndex; + result.dropInsertionIndex = UIEditorTabStripInvalidIndex; + result.reorderToIndex = UIEditorTabStripInvalidIndex; + result.reorderPreviewIndex = UIEditorTabStripInvalidIndex; + return; + } + + const std::size_t dropInsertionIndex = + ResolveVisibleDropInsertionIndex(layout, state.pointerPosition); + if (dropInsertionIndex == UIEditorTabStripInvalidIndex) { + UIDragDropResult dragDropResult = {}; + UpdateUIDragDropTarget(state.reorderDragState, nullptr, &dragDropResult); + state.tabStripState.reorder.previewInsertionIndex = UIEditorTabStripInvalidIndex; + state.reorderPreviewIndex = UIEditorTabStripInvalidIndex; + result.dropInsertionIndex = UIEditorTabStripInvalidIndex; + result.reorderToIndex = UIEditorTabStripInvalidIndex; + result.reorderPreviewIndex = UIEditorTabStripInvalidIndex; + return; + } + + const std::size_t reorderToIndex = + ResolveReorderTargetIndex( + state.reorderSourceIndex, + dropInsertionIndex, + items.size()); + if (reorderToIndex == UIEditorTabStripInvalidIndex) { + return; + } + + static constexpr std::array kAcceptedPayloadTypes = { + kTabStripDragPayloadType + }; + const std::string targetId = "insert:" + std::to_string(dropInsertionIndex); + UIDragDropTargetDescriptor target = {}; + target.ownerId = kTabStripDragOwnerId; + target.targetId = targetId; + target.acceptedPayloadTypes = kAcceptedPayloadTypes; + target.acceptedOperations = UIDragDropOperation::Move; + target.preferredOperation = UIDragDropOperation::Move; + + UIDragDropResult dragDropResult = {}; + UpdateUIDragDropTarget(state.reorderDragState, &target, &dragDropResult); + + state.tabStripState.reorder.previewInsertionIndex = dropInsertionIndex; + state.reorderPreviewIndex = reorderToIndex; + result.reorderPreviewActive = true; + result.dropInsertionIndex = dropInsertionIndex; + result.reorderToIndex = reorderToIndex; + result.reorderPreviewIndex = reorderToIndex; +} + +void BeginTabReorder( + UIEditorTabStripInteractionState& state, + const std::vector& items, + std::size_t sourceIndex) { + if (sourceIndex >= items.size()) { + return; + } + + UIDragDropSourceDescriptor source = {}; + source.ownerId = kTabStripDragOwnerId; + source.sourceId = items[sourceIndex].tabId; + source.pointerDownPosition = state.pointerPosition; + source.payload = UIDragDropPayload{ + std::string(kTabStripDragPayloadType), + items[sourceIndex].tabId, + items[sourceIndex].title + }; + source.allowedOperations = UIDragDropOperation::Move; + source.activationDistance = 4.0f; + + UIDragDropResult dragDropResult = {}; + if (BeginUIDragDrop(source, state.reorderDragState, &dragDropResult)) { + state.tabStripState.reorder.pressPosition = state.pointerPosition; + state.tabStripState.reorder.pressedIndex = sourceIndex; + state.tabStripState.reorder.sourceIndex = sourceIndex; + state.tabStripState.reorder.previewInsertionIndex = UIEditorTabStripInvalidIndex; + state.tabStripState.reorder.armed = true; + state.tabStripState.reorder.dragging = false; + state.reorderSourceIndex = sourceIndex; + state.reorderPreviewIndex = UIEditorTabStripInvalidIndex; + } +} + +void CancelTabReorder( + UIEditorTabStripInteractionState& state, + UIEditorTabStripInteractionResult& result, + const std::vector& items) { + if (!HasReorderInteraction(state)) { + return; + } + + result.dragCanceled = true; + result.consumed = state.reorderCaptureActive || state.reorderDragState.active; + result.dragSourceIndex = state.reorderSourceIndex; + result.reorderPreviewIndex = state.reorderPreviewIndex; + if (state.reorderSourceIndex < items.size()) { + result.draggedTabId = items[state.reorderSourceIndex].tabId; + } + if (state.reorderCaptureActive) { + result.releasePointerCapture = true; + } + + UIDragDropResult dragDropResult = {}; + CancelUIDragDrop(state.reorderDragState, &dragDropResult); + ClearReorderState(state); +} + } // namespace UIEditorTabStripInteractionFrame UpdateUIEditorTabStripInteraction( @@ -184,11 +400,33 @@ UIEditorTabStripInteractionFrame UpdateUIEditorTabStripInteraction( state.hasPointerPosition = false; state.pressedTarget = {}; ClearHoverState(state); + CancelTabReorder(state, eventResult, items); break; case UIInputEventType::PointerMove: case UIInputEventType::PointerEnter: + if (HasReorderInteraction(state)) { + UIDragDropResult dragDropResult = {}; + UpdateUIDragDropPointer(state.reorderDragState, state.pointerPosition, &dragDropResult); + if (dragDropResult.activated && !state.reorderCaptureActive) { + state.reorderCaptureActive = true; + eventResult.requestPointerCapture = true; + eventResult.dragStarted = true; + eventResult.consumed = true; + } + + SyncReorderPreview(state, layout, items, eventResult); + if (state.reorderDragState.active) { + eventResult.consumed = true; + } + } + break; + case UIInputEventType::PointerLeave: + if (state.reorderDragState.active) { + SyncReorderPreview(state, layout, items, eventResult); + eventResult.consumed = true; + } break; case UIInputEventType::PointerButtonDown: { @@ -201,6 +439,9 @@ UIEditorTabStripInteractionFrame UpdateUIEditorTabStripInteraction( (state.hasPointerPosition && IsPointInside(layout.bounds, state.pointerPosition))) { state.tabStripState.focused = true; eventResult.consumed = true; + if (eventResult.hitTarget.kind == UIEditorTabStripHitTargetKind::Tab) { + BeginTabReorder(state, items, eventResult.hitTarget.index); + } } else { state.tabStripState.focused = false; state.pressedTarget = {}; @@ -218,22 +459,49 @@ UIEditorTabStripInteractionFrame UpdateUIEditorTabStripInteraction( const bool matchedPressedTarget = AreEquivalentTargets(state.pressedTarget, eventResult.hitTarget); + if (state.reorderDragState.active || state.reorderCaptureActive) { + UIDragDropResult dragDropResult = {}; + EndUIDragDrop(state.reorderDragState, dragDropResult); + + eventResult.dragEnded = state.reorderCaptureActive; + eventResult.releasePointerCapture = state.reorderCaptureActive; + eventResult.consumed = true; + eventResult.dragSourceIndex = state.reorderSourceIndex; + if (state.reorderSourceIndex < items.size()) { + eventResult.draggedTabId = items[state.reorderSourceIndex].tabId; + } + eventResult.dropInsertionIndex = + ResolveCommittedReorderInsertionIndex(state, layout); + eventResult.reorderToIndex = + ResolveReorderTargetIndex( + state.reorderSourceIndex, + eventResult.dropInsertionIndex, + items.size()); + eventResult.reorderPreviewIndex = state.reorderPreviewIndex; + if (dragDropResult.completed && + eventResult.dropInsertionIndex != UIEditorTabStripInvalidIndex && + eventResult.reorderToIndex != UIEditorTabStripInvalidIndex) { + eventResult.reorderRequested = + eventResult.dropInsertionIndex != state.reorderSourceIndex && + eventResult.dropInsertionIndex != state.reorderSourceIndex + 1u; + } else { + eventResult.dragCanceled = true; + } + + ClearReorderState(state); + state.pressedTarget = {}; + break; + } + + if (state.reorderDragState.armed) { + UIDragDropResult dragDropResult = {}; + CancelUIDragDrop(state.reorderDragState, &dragDropResult); + state.reorderSourceIndex = UIEditorTabStripInvalidIndex; + state.reorderPreviewIndex = UIEditorTabStripInvalidIndex; + } + if (matchedPressedTarget) { switch (eventResult.hitTarget.kind) { - case UIEditorTabStripHitTargetKind::CloseButton: - if (eventResult.hitTarget.index < items.size() && - items[eventResult.hitTarget.index].closable) { - eventResult.closeRequested = true; - eventResult.closedTabId = items[eventResult.hitTarget.index].tabId; - eventResult.closedIndex = eventResult.hitTarget.index; - eventResult.consumed = true; - state.tabStripState.focused = true; - } else if (insideStrip) { - state.tabStripState.focused = true; - eventResult.consumed = true; - } - break; - case UIEditorTabStripHitTargetKind::Tab: SelectTab( state, @@ -271,6 +539,12 @@ UIEditorTabStripInteractionFrame UpdateUIEditorTabStripInteraction( } case UIInputEventType::KeyDown: + if (HasReorderInteraction(state) && + static_cast(event.keyCode) == KeyCode::Escape) { + CancelTabReorder(state, eventResult, items); + break; + } + if (state.tabStripState.focused && !HasNavigationModifiers(event.modifiers) && ApplyKeyboardNavigation(state, event.keyCode) && @@ -299,13 +573,25 @@ UIEditorTabStripInteractionFrame UpdateUIEditorTabStripInteraction( HitTestUIEditorTabStrip(layout, state.tabStripState, state.pointerPosition); } + if (state.reorderDragState.active) { + SyncReorderPreview(state, layout, items, eventResult); + } + if (eventResult.consumed || eventResult.selectionChanged || eventResult.closeRequested || eventResult.keyboardNavigated || + eventResult.requestPointerCapture || + eventResult.releasePointerCapture || + eventResult.dragStarted || + eventResult.dragEnded || + eventResult.dragCanceled || + eventResult.reorderRequested || + eventResult.reorderPreviewActive || eventResult.hitTarget.kind != UIEditorTabStripHitTargetKind::None || !eventResult.selectedTabId.empty() || - !eventResult.closedTabId.empty()) { + !eventResult.closedTabId.empty() || + !eventResult.draggedTabId.empty()) { interactionResult = std::move(eventResult); } } @@ -318,6 +604,9 @@ UIEditorTabStripInteractionFrame UpdateUIEditorTabStripInteraction( interactionResult.hitTarget = HitTestUIEditorTabStrip(layout, state.tabStripState, state.pointerPosition); } + if (state.reorderDragState.active) { + SyncReorderPreview(state, layout, items, interactionResult); + } return { std::move(layout), diff --git a/new_editor/src/Foundation/UIEditorRuntimeTrace.cpp b/new_editor/src/Foundation/UIEditorRuntimeTrace.cpp new file mode 100644 index 00000000..630e7182 --- /dev/null +++ b/new_editor/src/Foundation/UIEditorRuntimeTrace.cpp @@ -0,0 +1,130 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace XCEngine::UI::Editor { + +namespace { + +std::mutex g_traceMutex = {}; +std::filesystem::path g_logRoot = {}; +std::filesystem::path g_runtimeTracePath = {}; +std::filesystem::path g_crashTracePath = {}; +bool g_traceInitialized = false; + +std::string BuildTimestampString() { + const auto now = std::chrono::system_clock::now(); + const std::time_t currentTime = std::chrono::system_clock::to_time_t(now); + std::tm localTime = {}; + localtime_s(&localTime, ¤tTime); + + const auto milliseconds = + std::chrono::duration_cast( + now.time_since_epoch()) % + 1000; + + std::ostringstream stream = {}; + stream << std::put_time(&localTime, "%Y-%m-%d %H:%M:%S") + << '.' + << std::setw(3) + << std::setfill('0') + << milliseconds.count(); + return stream.str(); +} + +void AppendTraceLine( + const std::filesystem::path& path, + std::string_view channel, + std::string_view message) { + if (path.empty()) { + return; + } + + std::error_code errorCode = {}; + std::filesystem::create_directories(path.parent_path(), errorCode); + std::ofstream stream(path, std::ios::out | std::ios::app); + if (!stream.is_open()) { + return; + } + + stream << '[' + << BuildTimestampString() + << "] [" + << channel + << "] " + << message + << '\n'; +} + +} // namespace + +void InitializeUIEditorRuntimeTrace(const std::filesystem::path& logRoot) { + std::lock_guard lock(g_traceMutex); + g_logRoot = logRoot.lexically_normal(); + g_runtimeTracePath = (g_logRoot / "runtime.log").lexically_normal(); + g_crashTracePath = (g_logRoot / "crash.log").lexically_normal(); + g_traceInitialized = true; + + std::error_code errorCode = {}; + std::filesystem::create_directories(g_logRoot, errorCode); + AppendTraceLine(g_runtimeTracePath, "trace", "trace session started"); +} + +void ShutdownUIEditorRuntimeTrace() { + std::lock_guard lock(g_traceMutex); + if (!g_traceInitialized) { + return; + } + + AppendTraceLine(g_runtimeTracePath, "trace", "trace session ended"); + g_traceInitialized = false; +} + +void AppendUIEditorRuntimeTrace( + std::string_view channel, + std::string_view message) { + std::lock_guard lock(g_traceMutex); + if (!g_traceInitialized) { + return; + } + + AppendTraceLine(g_runtimeTracePath, channel, message); +} + +void AppendUIEditorCrashTrace( + std::uint32_t exceptionCode, + const void* exceptionAddress) { + std::lock_guard lock(g_traceMutex); + if (!g_traceInitialized) { + return; + } + + char buffer[128] = {}; + std::snprintf( + buffer, + sizeof(buffer), + "Unhandled exception code=0x%08X address=%p", + exceptionCode, + exceptionAddress); + AppendTraceLine(g_crashTracePath, "crash", buffer); + AppendTraceLine(g_runtimeTracePath, "crash", buffer); +} + +std::filesystem::path GetUIEditorRuntimeTracePath() { + std::lock_guard lock(g_traceMutex); + return g_runtimeTracePath; +} + +std::filesystem::path GetUIEditorCrashTracePath() { + std::lock_guard lock(g_traceMutex); + return g_crashTracePath; +} + +} // namespace XCEngine::UI::Editor diff --git a/new_editor/src/Shell/UIEditorDockHost.cpp b/new_editor/src/Shell/UIEditorDockHost.cpp index 1f167fd6..5f3ee854 100644 --- a/new_editor/src/Shell/UIEditorDockHost.cpp +++ b/new_editor/src/Shell/UIEditorDockHost.cpp @@ -501,6 +501,91 @@ UIColor ResolveSplitterColor(const UIEditorDockHostSplitterLayout& splitter, con return palette.splitterColor; } +const UIEditorDockHostTabStackLayout* FindTabStackLayoutByNodeId( + const UIEditorDockHostLayout& layout, + std::string_view nodeId) { + for (const UIEditorDockHostTabStackLayout& tabStack : layout.tabStacks) { + if (tabStack.nodeId == nodeId) { + return &tabStack; + } + } + + return nullptr; +} + +UIRect InsetRect(const UIRect& rect, float inset) { + const float clampedInset = ClampNonNegative(inset); + const float insetX = (std::min)(clampedInset, rect.width * 0.5f); + const float insetY = (std::min)(clampedInset, rect.height * 0.5f); + return UIRect( + rect.x + insetX, + rect.y + insetY, + (std::max)(0.0f, rect.width - insetX * 2.0f), + (std::max)(0.0f, rect.height - insetY * 2.0f)); +} + +UIRect ResolveDropPreviewRect( + const UIEditorDockHostTabStackLayout& tabStack, + UIEditorWorkspaceDockPlacement placement) { + const UIRect bounds = tabStack.bounds; + switch (placement) { + case UIEditorWorkspaceDockPlacement::Left: + return UIRect( + bounds.x, + bounds.y, + bounds.width * 0.35f, + bounds.height); + case UIEditorWorkspaceDockPlacement::Right: + return UIRect( + bounds.x + bounds.width * 0.65f, + bounds.y, + bounds.width * 0.35f, + bounds.height); + case UIEditorWorkspaceDockPlacement::Top: + return UIRect( + bounds.x, + bounds.y, + bounds.width, + bounds.height * 0.35f); + case UIEditorWorkspaceDockPlacement::Bottom: + return UIRect( + bounds.x, + bounds.y + bounds.height * 0.65f, + bounds.width, + bounds.height * 0.35f); + case UIEditorWorkspaceDockPlacement::Center: + default: + return InsetRect(bounds, 4.0f); + } +} + +UIEditorDockHostDropPreviewLayout ResolveDropPreviewLayout( + const UIEditorDockHostLayout& layout, + const UIEditorDockHostState& state) { + UIEditorDockHostDropPreviewLayout preview = {}; + if (!state.dropPreview.visible || state.dropPreview.targetNodeId.empty()) { + return preview; + } + + const UIEditorDockHostTabStackLayout* targetTabStack = + FindTabStackLayoutByNodeId(layout, state.dropPreview.targetNodeId); + if (targetTabStack == nullptr) { + return preview; + } + + preview.visible = true; + preview.targetNodeId = state.dropPreview.targetNodeId; + preview.placement = state.dropPreview.placement; + preview.insertionIndex = state.dropPreview.insertionIndex; + preview.previewRect = + ResolveDropPreviewRect(*targetTabStack, state.dropPreview.placement); + if (preview.previewRect.width <= 0.0f || preview.previewRect.height <= 0.0f) { + preview = {}; + } + + return preview; +} + } // namespace const UIEditorDockHostSplitterLayout* FindUIEditorDockHostSplitterLayout( @@ -539,6 +624,7 @@ UIEditorDockHostLayout BuildUIEditorDockHostLayout( state, metrics, layout); + layout.dropPreview = ResolveDropPreviewLayout(layout, state); return layout; } @@ -607,8 +693,6 @@ void AppendUIEditorDockHostBackground( const UIEditorDockHostLayout& layout, const UIEditorDockHostPalette& palette, const UIEditorDockHostMetrics& metrics) { - const UIEditorPanelFrameMetrics tabContentFrameMetrics = - BuildTabContentFrameMetrics(metrics); for (const UIEditorDockHostTabStackLayout& tabStack : layout.tabStacks) { std::vector tabItems = {}; tabItems.reserve(tabStack.items.size()); @@ -626,12 +710,6 @@ void AppendUIEditorDockHostBackground( tabStack.tabStripState, palette.tabStripPalette, metrics.tabStripMetrics); - AppendUIEditorPanelFrameBackground( - drawList, - tabStack.contentFrameLayout, - tabStack.contentFrameState, - palette.panelFramePalette, - tabContentFrameMetrics); } for (const UIEditorDockHostSplitterLayout& splitter : layout.splitters) { @@ -648,8 +726,6 @@ void AppendUIEditorDockHostForeground( const UIEditorDockHostForegroundOptions& options, const UIEditorDockHostPalette& palette, const UIEditorDockHostMetrics& metrics) { - const UIEditorPanelFrameMetrics tabContentFrameMetrics = - BuildTabContentFrameMetrics(metrics); for (const UIEditorDockHostTabStackLayout& tabStack : layout.tabStacks) { std::vector tabItems = {}; tabItems.reserve(tabStack.items.size()); @@ -668,18 +744,21 @@ void AppendUIEditorDockHostForeground( tabStack.tabStripState, palette.tabStripPalette, metrics.tabStripMetrics); - AppendUIEditorPanelFrameForeground( - drawList, - tabStack.contentFrameLayout, - tabStack.contentFrameState, - {}, - palette.panelFramePalette, - tabContentFrameMetrics); if (UsesExternalBodyPresentation(options, tabStack.selectedPanelId)) { continue; } } + + if (layout.dropPreview.visible) { + drawList.AddFilledRect( + layout.dropPreview.previewRect, + palette.dropPreviewFillColor); + drawList.AddRectOutline( + layout.dropPreview.previewRect, + palette.dropPreviewBorderColor, + 1.0f); + } } void AppendUIEditorDockHostForeground( diff --git a/new_editor/src/Shell/UIEditorDockHostInteraction.cpp b/new_editor/src/Shell/UIEditorDockHostInteraction.cpp index 8d200326..5fd152bc 100644 --- a/new_editor/src/Shell/UIEditorDockHostInteraction.cpp +++ b/new_editor/src/Shell/UIEditorDockHostInteraction.cpp @@ -1,8 +1,10 @@ #include +#include #include #include +#include #include #include #include @@ -13,6 +15,7 @@ namespace { using ::XCEngine::UI::UIInputEvent; using ::XCEngine::UI::UIInputEventType; +using ::XCEngine::UI::UIPoint; using ::XCEngine::UI::UIRect; using ::XCEngine::UI::Widgets::BeginUISplitterDrag; using ::XCEngine::UI::Widgets::EndUISplitterDrag; @@ -30,8 +33,17 @@ using Widgets::UIEditorTabStripItem; struct DockHostTabStripEventResult { bool consumed = false; bool commandRequested = false; + bool reorderRequested = false; + bool dragStarted = false; + bool dragEnded = false; + bool dragCanceled = false; + bool requestPointerCapture = false; + bool releasePointerCapture = false; UIEditorWorkspaceCommandKind commandKind = UIEditorWorkspaceCommandKind::ActivatePanel; + std::size_t dropInsertionIndex = Widgets::UIEditorTabStripInvalidIndex; std::string panelId = {}; + std::string nodeId = {}; + std::string draggedTabId = {}; UIEditorDockHostHitTarget hitTarget = {}; int priority = 0; }; @@ -130,6 +142,13 @@ void PruneTabStripInteractionEntries( return !isVisibleNodeId(entry.nodeId); }), state.dockHostState.tabStripStates.end()); + + if (!state.activeTabDragNodeId.empty() && + !isVisibleNodeId(state.activeTabDragNodeId)) { + state.activeTabDragNodeId.clear(); + state.activeTabDragPanelId.clear(); + state.dockHostState.dropPreview = {}; + } } void SyncDockHostTabStripVisualStates(UIEditorDockHostInteractionState& state) { @@ -152,6 +171,33 @@ bool HasFocusedTabStrip(const UIEditorDockHostInteractionState& state) { }) != state.tabStripInteractions.end(); } +const UIEditorDockHostTabStackLayout* FindTabStackLayoutByNodeId( + const Widgets::UIEditorDockHostLayout& layout, + std::string_view nodeId) { + for (const UIEditorDockHostTabStackLayout& tabStack : layout.tabStacks) { + if (tabStack.nodeId == nodeId) { + return &tabStack; + } + } + + return nullptr; +} + +bool IsPointInsideRect( + const UIRect& rect, + const UIPoint& point) { + return point.x >= rect.x && + point.x <= rect.x + rect.width && + point.y >= rect.y && + point.y <= rect.y + rect.height; +} + +void ClearTabDockDragState(UIEditorDockHostInteractionState& state) { + state.activeTabDragNodeId.clear(); + state.activeTabDragPanelId.clear(); + state.dockHostState.dropPreview = {}; +} + std::vector BuildTabStripItems( const UIEditorDockHostTabStackLayout& tabStack) { std::vector items = {}; @@ -198,6 +244,13 @@ UIEditorDockHostHitTarget MapTabStripHitTarget( } int ResolveTabStripPriority(const UIEditorTabStripInteractionResult& result) { + if (result.reorderRequested || + result.dragStarted || + result.dragEnded || + result.dragCanceled) { + return 5; + } + if (result.closeRequested) { return 4; } @@ -221,6 +274,11 @@ DockHostTabStripEventResult ProcessTabStripEvent( DockHostTabStripEventResult resolved = {}; for (const UIEditorDockHostTabStackLayout& tabStack : layout.tabStacks) { + if (!state.activeTabDragNodeId.empty() && + tabStack.nodeId != state.activeTabDragNodeId) { + continue; + } + UIEditorDockHostTabStripInteractionEntry& entry = FindOrCreateTabStripInteractionEntry(state, tabStack.nodeId); std::string selectedTabId = tabStack.selectedPanelId; @@ -238,7 +296,15 @@ DockHostTabStripEventResult ProcessTabStripEvent( continue; } + resolved.nodeId = tabStack.nodeId; resolved.hitTarget = MapTabStripHitTarget(tabStack, frame.result); + resolved.requestPointerCapture = frame.result.requestPointerCapture; + resolved.releasePointerCapture = frame.result.releasePointerCapture; + resolved.dragStarted = frame.result.dragStarted; + resolved.dragEnded = frame.result.dragEnded; + resolved.dragCanceled = frame.result.dragCanceled; + resolved.dropInsertionIndex = frame.result.dropInsertionIndex; + resolved.draggedTabId = frame.result.draggedTabId; if ((frame.result.closeRequested && !frame.result.closedTabId.empty()) || (event.type == UIInputEventType::PointerButtonUp && frame.result.consumed && @@ -250,6 +316,12 @@ DockHostTabStripEventResult ProcessTabStripEvent( !frame.result.closedTabId.empty() ? frame.result.closedTabId : resolved.hitTarget.panelId; + } else if (frame.result.reorderRequested && + !frame.result.draggedTabId.empty() && + frame.result.dropInsertionIndex != Widgets::UIEditorTabStripInvalidIndex) { + resolved.reorderRequested = true; + resolved.panelId = frame.result.draggedTabId; + resolved.dropInsertionIndex = frame.result.dropInsertionIndex; } else if ((frame.result.selectionChanged || frame.result.keyboardNavigated || (event.type == UIInputEventType::PointerButtonUp && @@ -266,6 +338,7 @@ DockHostTabStripEventResult ProcessTabStripEvent( continue; } else { resolved.commandRequested = false; + resolved.reorderRequested = false; resolved.panelId.clear(); } @@ -277,6 +350,103 @@ DockHostTabStripEventResult ProcessTabStripEvent( return resolved; } +std::size_t ResolveTabHeaderDropInsertionIndex( + const UIEditorDockHostTabStackLayout& tabStack, + const UIPoint& point) { + if (!IsPointInsideRect(tabStack.tabStripLayout.headerRect, point)) { + return Widgets::UIEditorTabStripInvalidIndex; + } + + std::size_t insertionIndex = 0u; + for (const UIRect& rect : tabStack.tabStripLayout.tabHeaderRects) { + const float midpoint = rect.x + rect.width * 0.5f; + if (point.x > midpoint) { + ++insertionIndex; + } + } + + return insertionIndex; +} + +UIEditorWorkspaceDockPlacement ResolveDockPlacement( + const UIEditorDockHostTabStackLayout& tabStack, + const UIPoint& point) { + if (IsPointInsideRect(tabStack.tabStripLayout.headerRect, point)) { + return UIEditorWorkspaceDockPlacement::Center; + } + + const float leftDistance = point.x - tabStack.bounds.x; + const float rightDistance = + tabStack.bounds.x + tabStack.bounds.width - point.x; + const float topDistance = point.y - tabStack.bounds.y; + const float bottomDistance = + tabStack.bounds.y + tabStack.bounds.height - point.y; + const float minHorizontalThreshold = tabStack.bounds.width * 0.25f; + const float minVerticalThreshold = tabStack.bounds.height * 0.25f; + const float nearestEdge = + (std::min)((std::min)(leftDistance, rightDistance), (std::min)(topDistance, bottomDistance)); + + if (nearestEdge == leftDistance && leftDistance <= minHorizontalThreshold) { + return UIEditorWorkspaceDockPlacement::Left; + } + if (nearestEdge == rightDistance && rightDistance <= minHorizontalThreshold) { + return UIEditorWorkspaceDockPlacement::Right; + } + if (nearestEdge == topDistance && topDistance <= minVerticalThreshold) { + return UIEditorWorkspaceDockPlacement::Top; + } + if (nearestEdge == bottomDistance && bottomDistance <= minVerticalThreshold) { + return UIEditorWorkspaceDockPlacement::Bottom; + } + + return UIEditorWorkspaceDockPlacement::Center; +} + +void SyncDockPreview( + UIEditorDockHostInteractionState& state, + const Widgets::UIEditorDockHostLayout& layout) { + state.dockHostState.dropPreview = {}; + if (state.activeTabDragNodeId.empty() || + state.activeTabDragPanelId.empty() || + !state.hasPointerPosition) { + return; + } + + for (const UIEditorDockHostTabStackLayout& tabStack : layout.tabStacks) { + if (!IsPointInsideRect(tabStack.bounds, state.pointerPosition)) { + continue; + } + + const UIEditorWorkspaceDockPlacement placement = + ResolveDockPlacement(tabStack, state.pointerPosition); + if (tabStack.nodeId == state.activeTabDragNodeId && + placement == UIEditorWorkspaceDockPlacement::Center) { + return; + } + + if (tabStack.nodeId == state.activeTabDragNodeId && + tabStack.items.size() <= 1u) { + return; + } + + Widgets::UIEditorDockHostDropPreviewState preview = {}; + preview.visible = true; + preview.sourceNodeId = state.activeTabDragNodeId; + preview.sourcePanelId = state.activeTabDragPanelId; + preview.targetNodeId = tabStack.nodeId; + preview.placement = placement; + if (placement == UIEditorWorkspaceDockPlacement::Center) { + preview.insertionIndex = + ResolveTabHeaderDropInsertionIndex(tabStack, state.pointerPosition); + if (preview.insertionIndex == Widgets::UIEditorTabStripInvalidIndex) { + preview.insertionIndex = tabStack.items.size(); + } + } + state.dockHostState.dropPreview = std::move(preview); + return; + } +} + void SyncHoverTarget( UIEditorDockHostInteractionState& state, const Widgets::UIEditorDockHostLayout& layout) { @@ -290,6 +460,11 @@ void SyncHoverTarget( return; } + if (!state.activeTabDragNodeId.empty()) { + state.dockHostState.hoveredTarget = {}; + return; + } + if (!state.hasPointerPosition) { state.dockHostState.hoveredTarget = {}; return; @@ -340,6 +515,20 @@ UIEditorDockHostInteractionFrame UpdateUIEditorDockHostInteraction( ShouldDispatchTabStripEvent(event, state.splitterDragState.active) ? ProcessTabStripEvent(state, layout, event, metrics) : DockHostTabStripEventResult {}; + eventResult.requestPointerCapture = tabStripResult.requestPointerCapture; + eventResult.releasePointerCapture = tabStripResult.releasePointerCapture; + if (!tabStripResult.draggedTabId.empty() && + !state.activeTabDragNodeId.empty()) { + state.activeTabDragPanelId = tabStripResult.draggedTabId; + } + if (!state.activeTabDragNodeId.empty() && + !state.activeTabDragPanelId.empty() && + !state.splitterDragState.active) { + SyncDockPreview(state, layout); + } else if (event.type == UIInputEventType::PointerLeave || + state.activeTabDragNodeId.empty()) { + state.dockHostState.dropPreview = {}; + } switch (event.type) { case UIInputEventType::FocusGained: @@ -355,6 +544,12 @@ UIEditorDockHostInteractionFrame UpdateUIEditorDockHostInteraction( eventResult.consumed = true; eventResult.releasePointerCapture = true; } + if (!state.activeTabDragNodeId.empty() || tabStripResult.dragCanceled) { + ClearTabDockDragState(state); + eventResult.consumed = true; + eventResult.releasePointerCapture = + eventResult.releasePointerCapture || tabStripResult.releasePointerCapture; + } break; case UIInputEventType::PointerMove: @@ -381,6 +576,21 @@ UIEditorDockHostInteractionFrame UpdateUIEditorDockHostInteraction( eventResult.hitTarget.kind = UIEditorDockHostHitTargetKind::SplitterHandle; eventResult.hitTarget.nodeId = state.dockHostState.activeSplitterNodeId; } + } else if (tabStripResult.priority > 0) { + eventResult.consumed = tabStripResult.consumed || tabStripResult.dragStarted; + eventResult.hitTarget = tabStripResult.hitTarget; + if (tabStripResult.dragStarted) { + state.activeTabDragNodeId = tabStripResult.nodeId; + state.activeTabDragPanelId = tabStripResult.draggedTabId; + SyncDockPreview(state, layout); + } + if (tabStripResult.dragEnded || tabStripResult.dragCanceled) { + ClearTabDockDragState(state); + } + if (eventResult.consumed || + eventResult.hitTarget.kind != UIEditorDockHostHitTargetKind::None) { + state.dockHostState.focused = true; + } } break; @@ -388,6 +598,7 @@ UIEditorDockHostInteractionFrame UpdateUIEditorDockHostInteraction( if (!state.splitterDragState.active) { state.dockHostState.hoveredTarget = {}; } + state.dockHostState.dropPreview = {}; if (!HasFocusedTabStrip(state)) { state.dockHostState.focused = false; } @@ -464,6 +675,96 @@ UIEditorDockHostInteractionFrame UpdateUIEditorDockHostInteraction( break; } + if (tabStripResult.reorderRequested && + !tabStripResult.nodeId.empty() && + !tabStripResult.draggedTabId.empty() && + tabStripResult.dropInsertionIndex != Widgets::UIEditorTabStripInvalidIndex) { + { + std::ostringstream trace = {}; + trace << "same-stack reorder node=" << tabStripResult.nodeId + << " panel=" << tabStripResult.draggedTabId + << " insertion=" << tabStripResult.dropInsertionIndex; + AppendUIEditorRuntimeTrace("dock", trace.str()); + } + eventResult.layoutResult = controller.ReorderTab( + tabStripResult.nodeId, + tabStripResult.draggedTabId, + tabStripResult.dropInsertionIndex); + eventResult.layoutChanged = + eventResult.layoutResult.status == + UIEditorWorkspaceLayoutOperationStatus::Changed; + eventResult.consumed = true; + eventResult.hitTarget = tabStripResult.hitTarget; + ClearTabDockDragState(state); + state.dockHostState.focused = true; + break; + } + + if (state.dockHostState.dropPreview.visible && + !state.activeTabDragNodeId.empty() && + !state.activeTabDragPanelId.empty()) { + const Widgets::UIEditorDockHostDropPreviewState preview = + state.dockHostState.dropPreview; + { + std::ostringstream trace = {}; + trace << "drop commit sourceNode=" << state.activeTabDragNodeId + << " panel=" << state.activeTabDragPanelId + << " targetNode=" << preview.targetNodeId + << " placement=" << static_cast(preview.placement) + << " insertion=" << preview.insertionIndex; + AppendUIEditorRuntimeTrace("dock", trace.str()); + } + if (preview.placement == UIEditorWorkspaceDockPlacement::Center) { + std::size_t insertionIndex = preview.insertionIndex; + if (insertionIndex == Widgets::UIEditorTabStripInvalidIndex) { + if (const UIEditorDockHostTabStackLayout* targetTabStack = + FindTabStackLayoutByNodeId(layout, preview.targetNodeId); + targetTabStack != nullptr) { + insertionIndex = targetTabStack->items.size(); + } else { + insertionIndex = 0u; + } + } + + eventResult.layoutResult = controller.MoveTabToStack( + state.activeTabDragNodeId, + state.activeTabDragPanelId, + preview.targetNodeId, + insertionIndex); + } else { + eventResult.layoutResult = controller.DockTabRelative( + state.activeTabDragNodeId, + state.activeTabDragPanelId, + preview.targetNodeId, + preview.placement); + } + eventResult.layoutChanged = + eventResult.layoutResult.status == + UIEditorWorkspaceLayoutOperationStatus::Changed; + AppendUIEditorRuntimeTrace( + "dock", + "drop result status=" + + std::string( + GetUIEditorWorkspaceLayoutOperationStatusName( + eventResult.layoutResult.status)) + + " message=" + eventResult.layoutResult.message); + eventResult.consumed = true; + eventResult.hitTarget.nodeId = preview.targetNodeId; + eventResult.releasePointerCapture = + eventResult.releasePointerCapture || tabStripResult.releasePointerCapture; + ClearTabDockDragState(state); + state.dockHostState.focused = true; + break; + } + + if (tabStripResult.dragEnded || tabStripResult.dragCanceled) { + eventResult.consumed = tabStripResult.consumed; + eventResult.hitTarget = tabStripResult.hitTarget; + ClearTabDockDragState(state); + state.dockHostState.focused = true; + break; + } + if (tabStripResult.commandRequested && !tabStripResult.panelId.empty()) { eventResult.commandResult = DispatchPanelCommand( controller, @@ -534,6 +835,14 @@ UIEditorDockHostInteractionFrame UpdateUIEditorDockHostInteraction( break; case UIInputEventType::KeyDown: + if (tabStripResult.dragCanceled) { + eventResult.consumed = true; + eventResult.hitTarget = tabStripResult.hitTarget; + ClearTabDockDragState(state); + state.dockHostState.focused = true; + break; + } + if (tabStripResult.commandRequested && !tabStripResult.panelId.empty()) { eventResult.commandResult = DispatchPanelCommand( controller, diff --git a/new_editor/src/Shell/UIEditorMenuBar.cpp b/new_editor/src/Shell/UIEditorMenuBar.cpp index 31e50bae..52a58dd5 100644 --- a/new_editor/src/Shell/UIEditorMenuBar.cpp +++ b/new_editor/src/Shell/UIEditorMenuBar.cpp @@ -11,8 +11,6 @@ using ::XCEngine::UI::UIDrawList; using ::XCEngine::UI::UIPoint; using ::XCEngine::UI::UIRect; -constexpr float kMenuBarFontSize = 13.0f; - float ClampNonNegative(float value) { return (std::max)(value, 0.0f); } @@ -24,6 +22,10 @@ bool IsPointInsideRect(const UIRect& rect, const UIPoint& point) { point.y <= rect.y + rect.height; } +bool IsVisibleColor(const UIColor& color) { + return color.a > 0.0f; +} + float ResolveEstimatedLabelWidth( const UIEditorMenuBarItem& item, const UIEditorMenuBarMetrics& metrics) { @@ -35,7 +37,9 @@ float ResolveEstimatedLabelWidth( } float ResolveLabelTop(const UIRect& rect, const UIEditorMenuBarMetrics& metrics) { - return rect.y + (std::max)(0.0f, (rect.height - kMenuBarFontSize) * 0.5f) + metrics.labelInsetY; + return rect.y + + (std::max)(0.0f, (rect.height - ClampNonNegative(metrics.labelFontSize)) * 0.5f) + + metrics.labelInsetY; } bool IsButtonFocused( @@ -164,25 +168,38 @@ void AppendUIEditorMenuBarBackground( const UIEditorMenuBarPalette& palette, const UIEditorMenuBarMetrics& metrics) { drawList.AddFilledRect(layout.bounds, palette.barColor, metrics.barCornerRounding); - drawList.AddRectOutline( - layout.bounds, - state.focused ? palette.focusedBorderColor : palette.borderColor, - state.focused ? metrics.focusedBorderThickness : metrics.baseBorderThickness, - metrics.barCornerRounding); + const UIColor barBorderColor = + state.focused ? palette.focusedBorderColor : palette.borderColor; + const float barBorderThickness = + state.focused ? metrics.focusedBorderThickness : metrics.baseBorderThickness; + if (IsVisibleColor(barBorderColor) && barBorderThickness > 0.0f) { + drawList.AddRectOutline( + layout.bounds, + barBorderColor, + barBorderThickness, + metrics.barCornerRounding); + } for (std::size_t index = 0; index < layout.buttonRects.size() && index < items.size(); ++index) { const bool open = state.openIndex == index; const bool hovered = state.hoveredIndex == index; const bool focused = IsButtonFocused(state, index); - drawList.AddFilledRect( - layout.buttonRects[index], - ResolveButtonFillColor(open, hovered, palette), - metrics.buttonCornerRounding); - drawList.AddRectOutline( - layout.buttonRects[index], - ResolveButtonBorderColor(open, focused, palette), - ResolveButtonBorderThickness(open, focused, metrics), - metrics.buttonCornerRounding); + const UIColor buttonFillColor = ResolveButtonFillColor(open, hovered, palette); + const UIColor buttonBorderColor = ResolveButtonBorderColor(open, focused, palette); + const float buttonBorderThickness = ResolveButtonBorderThickness(open, focused, metrics); + if (IsVisibleColor(buttonFillColor)) { + drawList.AddFilledRect( + layout.buttonRects[index], + buttonFillColor, + metrics.buttonCornerRounding); + } + if (IsVisibleColor(buttonBorderColor) && buttonBorderThickness > 0.0f) { + drawList.AddRectOutline( + layout.buttonRects[index], + buttonBorderColor, + buttonBorderThickness, + metrics.buttonCornerRounding); + } } } @@ -206,7 +223,7 @@ void AppendUIEditorMenuBarForeground( UIPoint(textLeft, ResolveLabelTop(rect, metrics)), items[index].label, items[index].enabled ? palette.textPrimary : palette.textDisabled, - kMenuBarFontSize); + ClampNonNegative(metrics.labelFontSize)); drawList.PopClipRect(); } } diff --git a/new_editor/src/Shell/UIEditorMenuPopup.cpp b/new_editor/src/Shell/UIEditorMenuPopup.cpp index a8a2309b..9c5304dc 100644 --- a/new_editor/src/Shell/UIEditorMenuPopup.cpp +++ b/new_editor/src/Shell/UIEditorMenuPopup.cpp @@ -171,8 +171,14 @@ void AppendUIEditorMenuPopupBackground( const UIRect& rect = layout.itemRects[index]; if (item.kind == UIEditorMenuItemKind::Separator) { const float lineY = rect.y + rect.height * 0.5f; + const float separatorInset = + ClampNonNegative(metrics.contentPaddingX) + 3.0f; drawList.AddFilledRect( - UIRect(rect.x + 8.0f, lineY, (std::max)(rect.width - 16.0f, 0.0f), metrics.separatorThickness), + UIRect( + rect.x + separatorInset, + lineY, + (std::max)(rect.width - separatorInset * 2.0f, 0.0f), + metrics.separatorThickness), palette.separatorColor); continue; } diff --git a/new_editor/src/Shell/UIEditorShellCompose.cpp b/new_editor/src/Shell/UIEditorShellCompose.cpp index e5997b97..e91d8280 100644 --- a/new_editor/src/Shell/UIEditorShellCompose.cpp +++ b/new_editor/src/Shell/UIEditorShellCompose.cpp @@ -191,6 +191,19 @@ void AppendUIEditorShellToolbar( } // namespace +UIEditorShellComposeLayout BuildUIEditorShellComposeLayout( + const UIRect& bounds, + const std::vector& menuBarItems, + const std::vector& statusSegments, + const UIEditorShellComposeMetrics& metrics) { + return BuildUIEditorShellComposeLayout( + bounds, + menuBarItems, + {}, + statusSegments, + metrics); +} + UIEditorShellComposeLayout BuildUIEditorShellComposeLayout( const UIRect& bounds, const std::vector& menuBarItems, diff --git a/new_editor/src/Shell/UIEditorShellInteraction.cpp b/new_editor/src/Shell/UIEditorShellInteraction.cpp index 7186a77a..fd3e62be 100644 --- a/new_editor/src/Shell/UIEditorShellInteraction.cpp +++ b/new_editor/src/Shell/UIEditorShellInteraction.cpp @@ -148,7 +148,9 @@ const std::vector* ResolvePopupItems( } std::vector BuildMenuBarItems( - const UIEditorResolvedMenuModel& model) { + const UIEditorResolvedMenuModel& model, + const UIEditorShellInteractionServices& services, + const Widgets::UIEditorMenuBarMetrics& metrics) { std::vector items = {}; items.reserve(model.menus.size()); @@ -157,6 +159,10 @@ std::vector BuildMenuBarItems( item.menuId = menu.menuId; item.label = menu.label; item.enabled = !menu.items.empty(); + if (services.textMeasurer != nullptr && !item.label.empty()) { + item.desiredLabelWidth = services.textMeasurer->MeasureTextWidth( + UIEditorTextMeasureRequest { item.label, metrics.labelFontSize }); + } items.push_back(std::move(item)); } @@ -175,7 +181,9 @@ UIEditorShellComposeModel BuildShellComposeModel( } std::vector BuildPopupWidgetItems( - const std::vector& items) { + const std::vector& items, + const UIEditorShellInteractionServices& services, + const Widgets::UIEditorMenuPopupMetrics& metrics) { std::vector widgetItems = {}; widgetItems.reserve(items.size()); @@ -188,6 +196,16 @@ std::vector BuildPopupWidgetItems( widgetItem.enabled = item.enabled; widgetItem.checked = item.checked; widgetItem.hasSubmenu = item.kind == UIEditorMenuItemKind::Submenu && !item.children.empty(); + if (services.textMeasurer != nullptr) { + if (!widgetItem.label.empty()) { + widgetItem.desiredLabelWidth = services.textMeasurer->MeasureTextWidth( + UIEditorTextMeasureRequest { widgetItem.label, metrics.labelFontSize }); + } + if (!widgetItem.shortcutText.empty()) { + widgetItem.desiredShortcutWidth = services.textMeasurer->MeasureTextWidth( + UIEditorTextMeasureRequest { widgetItem.shortcutText, metrics.labelFontSize }); + } + } widgetItems.push_back(std::move(widgetItem)); } @@ -244,10 +262,14 @@ BuildRequestOutput BuildRequest( const UIEditorWorkspaceController& controller, const UIEditorShellInteractionModel& model, const UIEditorShellInteractionState& state, - const UIEditorShellInteractionMetrics& metrics) { + const UIEditorShellInteractionMetrics& metrics, + const UIEditorShellInteractionServices& services) { BuildRequestOutput output = {}; UIEditorShellInteractionRequest& request = output.request; - request.menuBarItems = BuildMenuBarItems(model.resolvedMenuModel); + request.menuBarItems = BuildMenuBarItems( + model.resolvedMenuModel, + services, + metrics.shellMetrics.menuBarMetrics); const UIEditorShellComposeModel shellModel = BuildShellComposeModel(model, request.menuBarItems); @@ -293,7 +315,10 @@ BuildRequestOutput BuildRequest( popupRequest.sourceItemId = popupState.itemId; popupRequest.overlayEntry = *overlayEntry; popupRequest.resolvedItems = *resolvedItems; - popupRequest.widgetItems = BuildPopupWidgetItems(popupRequest.resolvedItems); + popupRequest.widgetItems = BuildPopupWidgetItems( + popupRequest.resolvedItems, + services, + metrics.popupMetrics); const float popupWidth = ResolveUIEditorMenuPopupDesiredWidth(popupRequest.widgetItems, metrics.popupMetrics); @@ -506,7 +531,8 @@ UIEditorShellInteractionRequest ResolveUIEditorShellInteractionRequest( controller, model, state, - metrics).request; + metrics, + services).request; } UIEditorShellInteractionFrame UpdateUIEditorShellInteraction( @@ -525,7 +551,8 @@ UIEditorShellInteractionFrame UpdateUIEditorShellInteraction( controller, model, state, - metrics); + metrics, + services); UIEditorShellInteractionRequest request = std::move(requestBuild.request); if (requestBuild.hadInvalidPopupState && state.menuSession.HasOpenMenu()) { @@ -539,7 +566,8 @@ UIEditorShellInteractionFrame UpdateUIEditorShellInteraction( controller, model, state, - metrics); + metrics, + services); request = std::move(requestBuild.request); } @@ -691,7 +719,8 @@ UIEditorShellInteractionFrame UpdateUIEditorShellInteraction( controller, model, state, - metrics).request; + metrics, + services).request; } } @@ -714,7 +743,8 @@ UIEditorShellInteractionFrame UpdateUIEditorShellInteraction( controller, model, state, - metrics).request; + metrics, + services).request; const RequestHit finalHit = HitTestRequest(request, state.pointerPosition, state.hasPointerPosition); diff --git a/new_editor/src/Shell/UIEditorViewportSlot.cpp b/new_editor/src/Shell/UIEditorViewportSlot.cpp index 5bca6145..78d7e5d0 100644 --- a/new_editor/src/Shell/UIEditorViewportSlot.cpp +++ b/new_editor/src/Shell/UIEditorViewportSlot.cpp @@ -110,21 +110,13 @@ UIColor ResolveSurfaceBorderColor( return palette.surfaceCapturedBorderColor; } - if (state.surfaceActive) { - return palette.surfaceActiveBorderColor; - } - - if (state.surfaceHovered) { - return palette.surfaceHoveredBorderColor; - } - return palette.surfaceBorderColor; } float ResolveSurfaceBorderThickness( const UIEditorViewportSlotState& state, const UIEditorViewportSlotMetrics& metrics) { - if (state.inputCaptured || state.focused) { + if (state.inputCaptured) { return metrics.focusedSurfaceBorderThickness; } @@ -356,8 +348,8 @@ void AppendUIEditorViewportSlotBackground( drawList.AddFilledRect(layout.bounds, palette.frameColor, metrics.cornerRounding); drawList.AddRectOutline( layout.bounds, - state.focused ? palette.focusedBorderColor : palette.borderColor, - state.focused ? metrics.focusedBorderThickness : metrics.outerBorderThickness, + palette.borderColor, + metrics.outerBorderThickness, metrics.cornerRounding); if (layout.hasTopBar) { @@ -366,12 +358,6 @@ void AppendUIEditorViewportSlotBackground( drawList.AddFilledRect(layout.surfaceRect, palette.surfaceColor); - if (state.surfaceHovered) { - drawList.AddFilledRect(layout.inputRect, palette.surfaceHoverOverlayColor); - } - if (state.surfaceActive) { - drawList.AddFilledRect(layout.inputRect, palette.surfaceActiveOverlayColor); - } if (state.inputCaptured) { drawList.AddFilledRect(layout.inputRect, palette.captureOverlayColor); } diff --git a/new_editor/src/Shell/UIEditorWorkspaceController.cpp b/new_editor/src/Shell/UIEditorWorkspaceController.cpp index 33f63d09..ea7406e0 100644 --- a/new_editor/src/Shell/UIEditorWorkspaceController.cpp +++ b/new_editor/src/Shell/UIEditorWorkspaceController.cpp @@ -1,12 +1,22 @@ #include +#include #include +#include #include namespace XCEngine::UI::Editor { namespace { +bool IsPanelOpenAndVisible( + const UIEditorWorkspaceSession& session, + std::string_view panelId) { + const UIEditorPanelSessionState* panelState = + FindUIEditorPanelSessionState(session, panelId); + return panelState != nullptr && panelState->open && panelState->visible; +} + std::vector CollectVisiblePanelIds( const UIEditorWorkspaceModel& workspace, const UIEditorWorkspaceSession& session) { @@ -22,6 +32,58 @@ std::vector CollectVisiblePanelIds( return ids; } +struct VisibleTabStackInfo { + bool panelExists = false; + bool panelVisible = false; + std::size_t currentVisibleIndex = 0u; + std::size_t visibleTabCount = 0u; +}; + +VisibleTabStackInfo ResolveVisibleTabStackInfo( + const UIEditorWorkspaceNode& node, + const UIEditorWorkspaceSession& session, + std::string_view panelId) { + VisibleTabStackInfo info = {}; + for (const UIEditorWorkspaceNode& child : node.children) { + if (child.kind != UIEditorWorkspaceNodeKind::Panel) { + continue; + } + + const bool visible = IsPanelOpenAndVisible(session, child.panel.panelId); + if (child.panel.panelId == panelId) { + info.panelExists = true; + info.panelVisible = visible; + if (visible) { + info.currentVisibleIndex = info.visibleTabCount; + } + } + + if (visible) { + ++info.visibleTabCount; + } + } + + return info; +} + +std::size_t CountVisibleTabs( + const UIEditorWorkspaceNode& node, + const UIEditorWorkspaceSession& session) { + if (node.kind != UIEditorWorkspaceNodeKind::TabStack) { + return 0u; + } + + std::size_t visibleCount = 0u; + for (const UIEditorWorkspaceNode& child : node.children) { + if (child.kind == UIEditorWorkspaceNodeKind::Panel && + IsPanelOpenAndVisible(session, child.panel.panelId)) { + ++visibleCount; + } + } + + return visibleCount; +} + } // namespace std::string_view GetUIEditorWorkspaceCommandKindName(UIEditorWorkspaceCommandKind kind) { @@ -297,6 +359,290 @@ UIEditorWorkspaceLayoutOperationResult UIEditorWorkspaceController::SetSplitRati "Split ratio updated."); } +UIEditorWorkspaceLayoutOperationResult UIEditorWorkspaceController::ReorderTab( + std::string_view nodeId, + std::string_view panelId, + std::size_t targetVisibleInsertionIndex) { + const UIEditorWorkspaceControllerValidationResult validation = ValidateState(); + if (!validation.IsValid()) { + return BuildLayoutOperationResult( + UIEditorWorkspaceLayoutOperationStatus::Rejected, + "Controller state invalid: " + validation.message); + } + + if (nodeId.empty()) { + return BuildLayoutOperationResult( + UIEditorWorkspaceLayoutOperationStatus::Rejected, + "ReorderTab requires a tab stack node id."); + } + + if (panelId.empty()) { + return BuildLayoutOperationResult( + UIEditorWorkspaceLayoutOperationStatus::Rejected, + "ReorderTab requires a panel id."); + } + + const UIEditorWorkspaceNode* tabStack = FindUIEditorWorkspaceNode(m_workspace, nodeId); + if (tabStack == nullptr || tabStack->kind != UIEditorWorkspaceNodeKind::TabStack) { + return BuildLayoutOperationResult( + UIEditorWorkspaceLayoutOperationStatus::Rejected, + "ReorderTab target tab stack is missing."); + } + + const VisibleTabStackInfo tabInfo = + ResolveVisibleTabStackInfo(*tabStack, m_session, panelId); + if (!tabInfo.panelExists) { + return BuildLayoutOperationResult( + UIEditorWorkspaceLayoutOperationStatus::Rejected, + "ReorderTab target panel is missing from the specified tab stack."); + } + + if (!tabInfo.panelVisible) { + return BuildLayoutOperationResult( + UIEditorWorkspaceLayoutOperationStatus::Rejected, + "ReorderTab only supports open and visible tabs."); + } + + if (targetVisibleInsertionIndex > tabInfo.visibleTabCount) { + return BuildLayoutOperationResult( + UIEditorWorkspaceLayoutOperationStatus::Rejected, + "ReorderTab target visible insertion index is out of range."); + } + + if (targetVisibleInsertionIndex == tabInfo.currentVisibleIndex || + targetVisibleInsertionIndex == tabInfo.currentVisibleIndex + 1u) { + return BuildLayoutOperationResult( + UIEditorWorkspaceLayoutOperationStatus::NoOp, + "Visible tab order already matches the requested insertion."); + } + + const UIEditorWorkspaceModel previousWorkspace = m_workspace; + if (!TryReorderUIEditorWorkspaceTab( + m_workspace, + m_session, + nodeId, + panelId, + targetVisibleInsertionIndex)) { + return BuildLayoutOperationResult( + UIEditorWorkspaceLayoutOperationStatus::Rejected, + "Tab reorder rejected."); + } + + if (AreUIEditorWorkspaceModelsEquivalent(previousWorkspace, m_workspace)) { + return BuildLayoutOperationResult( + UIEditorWorkspaceLayoutOperationStatus::NoOp, + "Visible tab order already matches the requested insertion."); + } + + const UIEditorWorkspaceControllerValidationResult postValidation = ValidateState(); + if (!postValidation.IsValid()) { + m_workspace = previousWorkspace; + return BuildLayoutOperationResult( + UIEditorWorkspaceLayoutOperationStatus::Rejected, + "Tab reorder produced invalid controller state: " + postValidation.message); + } + + return BuildLayoutOperationResult( + UIEditorWorkspaceLayoutOperationStatus::Changed, + "Tab reordered."); +} + +UIEditorWorkspaceLayoutOperationResult UIEditorWorkspaceController::MoveTabToStack( + std::string_view sourceNodeId, + std::string_view panelId, + std::string_view targetNodeId, + std::size_t targetVisibleInsertionIndex) { + { + std::ostringstream trace = {}; + trace << "MoveTabToStack begin sourceNode=" << sourceNodeId + << " panel=" << panelId + << " targetNode=" << targetNodeId + << " insertion=" << targetVisibleInsertionIndex; + AppendUIEditorRuntimeTrace("workspace", trace.str()); + } + const UIEditorWorkspaceControllerValidationResult validation = ValidateState(); + if (!validation.IsValid()) { + return BuildLayoutOperationResult( + UIEditorWorkspaceLayoutOperationStatus::Rejected, + "Controller state invalid: " + validation.message); + } + + if (sourceNodeId.empty() || targetNodeId.empty()) { + return BuildLayoutOperationResult( + UIEditorWorkspaceLayoutOperationStatus::Rejected, + "MoveTabToStack requires both source and target tab stack ids."); + } + + if (panelId.empty()) { + return BuildLayoutOperationResult( + UIEditorWorkspaceLayoutOperationStatus::Rejected, + "MoveTabToStack requires a panel id."); + } + + if (sourceNodeId == targetNodeId) { + return ReorderTab(sourceNodeId, panelId, targetVisibleInsertionIndex); + } + + const UIEditorWorkspaceNode* sourceTabStack = + FindUIEditorWorkspaceNode(m_workspace, sourceNodeId); + const UIEditorWorkspaceNode* targetTabStack = + FindUIEditorWorkspaceNode(m_workspace, targetNodeId); + if (sourceTabStack == nullptr || + targetTabStack == nullptr || + sourceTabStack->kind != UIEditorWorkspaceNodeKind::TabStack || + targetTabStack->kind != UIEditorWorkspaceNodeKind::TabStack) { + return BuildLayoutOperationResult( + UIEditorWorkspaceLayoutOperationStatus::Rejected, + "MoveTabToStack source or target tab stack is missing."); + } + + const VisibleTabStackInfo sourceInfo = + ResolveVisibleTabStackInfo(*sourceTabStack, m_session, panelId); + if (!sourceInfo.panelExists) { + return BuildLayoutOperationResult( + UIEditorWorkspaceLayoutOperationStatus::Rejected, + "MoveTabToStack target panel is missing from the source tab stack."); + } + + if (!sourceInfo.panelVisible) { + return BuildLayoutOperationResult( + UIEditorWorkspaceLayoutOperationStatus::Rejected, + "MoveTabToStack only supports open and visible tabs."); + } + + const std::size_t visibleTargetCount = CountVisibleTabs(*targetTabStack, m_session); + if (targetVisibleInsertionIndex > visibleTargetCount) { + return BuildLayoutOperationResult( + UIEditorWorkspaceLayoutOperationStatus::Rejected, + "MoveTabToStack target visible insertion index is out of range."); + } + + const UIEditorWorkspaceModel previousWorkspace = m_workspace; + if (!TryMoveUIEditorWorkspaceTabToStack( + m_workspace, + m_session, + sourceNodeId, + panelId, + targetNodeId, + targetVisibleInsertionIndex)) { + return BuildLayoutOperationResult( + UIEditorWorkspaceLayoutOperationStatus::Rejected, + "MoveTabToStack rejected."); + } + + if (AreUIEditorWorkspaceModelsEquivalent(previousWorkspace, m_workspace)) { + return BuildLayoutOperationResult( + UIEditorWorkspaceLayoutOperationStatus::NoOp, + "Tab already matches the requested target stack insertion."); + } + + const UIEditorWorkspaceControllerValidationResult postValidation = ValidateState(); + if (!postValidation.IsValid()) { + m_workspace = previousWorkspace; + return BuildLayoutOperationResult( + UIEditorWorkspaceLayoutOperationStatus::Rejected, + "MoveTabToStack produced invalid controller state: " + postValidation.message); + } + + return BuildLayoutOperationResult( + UIEditorWorkspaceLayoutOperationStatus::Changed, + "Tab moved to target stack."); +} + +UIEditorWorkspaceLayoutOperationResult UIEditorWorkspaceController::DockTabRelative( + std::string_view sourceNodeId, + std::string_view panelId, + std::string_view targetNodeId, + UIEditorWorkspaceDockPlacement placement, + float splitRatio) { + { + std::ostringstream trace = {}; + trace << "DockTabRelative begin sourceNode=" << sourceNodeId + << " panel=" << panelId + << " targetNode=" << targetNodeId + << " placement=" << static_cast(placement) + << " splitRatio=" << splitRatio; + AppendUIEditorRuntimeTrace("workspace", trace.str()); + } + const UIEditorWorkspaceControllerValidationResult validation = ValidateState(); + if (!validation.IsValid()) { + return BuildLayoutOperationResult( + UIEditorWorkspaceLayoutOperationStatus::Rejected, + "Controller state invalid: " + validation.message); + } + + if (sourceNodeId.empty() || targetNodeId.empty()) { + return BuildLayoutOperationResult( + UIEditorWorkspaceLayoutOperationStatus::Rejected, + "DockTabRelative requires both source and target tab stack ids."); + } + + if (panelId.empty()) { + return BuildLayoutOperationResult( + UIEditorWorkspaceLayoutOperationStatus::Rejected, + "DockTabRelative requires a panel id."); + } + + const UIEditorWorkspaceNode* sourceTabStack = + FindUIEditorWorkspaceNode(m_workspace, sourceNodeId); + const UIEditorWorkspaceNode* targetTabStack = + FindUIEditorWorkspaceNode(m_workspace, targetNodeId); + if (sourceTabStack == nullptr || + targetTabStack == nullptr || + sourceTabStack->kind != UIEditorWorkspaceNodeKind::TabStack || + targetTabStack->kind != UIEditorWorkspaceNodeKind::TabStack) { + return BuildLayoutOperationResult( + UIEditorWorkspaceLayoutOperationStatus::Rejected, + "DockTabRelative source or target tab stack is missing."); + } + + const VisibleTabStackInfo sourceInfo = + ResolveVisibleTabStackInfo(*sourceTabStack, m_session, panelId); + if (!sourceInfo.panelExists) { + return BuildLayoutOperationResult( + UIEditorWorkspaceLayoutOperationStatus::Rejected, + "DockTabRelative target panel is missing from the source tab stack."); + } + + if (!sourceInfo.panelVisible) { + return BuildLayoutOperationResult( + UIEditorWorkspaceLayoutOperationStatus::Rejected, + "DockTabRelative only supports open and visible tabs."); + } + + const UIEditorWorkspaceModel previousWorkspace = m_workspace; + if (!TryDockUIEditorWorkspaceTabRelative( + m_workspace, + m_session, + sourceNodeId, + panelId, + targetNodeId, + placement, + splitRatio)) { + return BuildLayoutOperationResult( + UIEditorWorkspaceLayoutOperationStatus::Rejected, + "DockTabRelative rejected."); + } + + if (AreUIEditorWorkspaceModelsEquivalent(previousWorkspace, m_workspace)) { + return BuildLayoutOperationResult( + UIEditorWorkspaceLayoutOperationStatus::NoOp, + "Dock layout already matches the requested placement."); + } + + const UIEditorWorkspaceControllerValidationResult postValidation = ValidateState(); + if (!postValidation.IsValid()) { + m_workspace = previousWorkspace; + return BuildLayoutOperationResult( + UIEditorWorkspaceLayoutOperationStatus::Rejected, + "DockTabRelative produced invalid controller state: " + postValidation.message); + } + + return BuildLayoutOperationResult( + UIEditorWorkspaceLayoutOperationStatus::Changed, + "Tab docked relative to target stack."); +} + UIEditorWorkspaceCommandResult UIEditorWorkspaceController::Dispatch( const UIEditorWorkspaceCommand& command) { const UIEditorWorkspaceControllerValidationResult validation = ValidateState(); diff --git a/new_editor/src/Shell/UIEditorWorkspaceModel.cpp b/new_editor/src/Shell/UIEditorWorkspaceModel.cpp index 21de2318..e1894df8 100644 --- a/new_editor/src/Shell/UIEditorWorkspaceModel.cpp +++ b/new_editor/src/Shell/UIEditorWorkspaceModel.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include @@ -43,6 +44,19 @@ UIEditorWorkspaceNode WrapStandalonePanelAsTabStack(UIEditorWorkspaceNode panelN return tabStack; } +void CollapseSplitNodeToOnlyChild(UIEditorWorkspaceNode& node) { + if (node.kind != UIEditorWorkspaceNodeKind::Split || + node.children.size() != 1u) { + return; + } + + // Move the remaining child through a temporary object first. Assigning + // directly from node.children.front() aliases a subobject of node and can + // trigger use-after-move when the vector storage is torn down. + UIEditorWorkspaceNode remainingChild = std::move(node.children.front()); + node = std::move(remainingChild); +} + void CanonicalizeNodeRecursive( UIEditorWorkspaceNode& node, bool allowStandalonePanelLeaf) { @@ -67,7 +81,7 @@ void CanonicalizeNodeRecursive( if (node.kind == UIEditorWorkspaceNodeKind::Split && node.children.size() == 1u) { - node = std::move(node.children.front()); + CollapseSplitNodeToOnlyChild(node); } } @@ -131,6 +145,256 @@ UIEditorWorkspaceNode* FindMutableNodeRecursive( return nullptr; } +bool FindNodePathRecursive( + const UIEditorWorkspaceNode& node, + std::string_view nodeId, + std::vector& path) { + if (node.nodeId == nodeId) { + return true; + } + + for (std::size_t index = 0; index < node.children.size(); ++index) { + path.push_back(index); + if (FindNodePathRecursive(node.children[index], nodeId, path)) { + return true; + } + path.pop_back(); + } + + return false; +} + +UIEditorWorkspaceNode* ResolveMutableNodeByPath( + UIEditorWorkspaceNode& node, + const std::vector& path) { + UIEditorWorkspaceNode* current = &node; + for (const std::size_t childIndex : path) { + if (childIndex >= current->children.size()) { + return nullptr; + } + current = ¤t->children[childIndex]; + } + + return current; +} + +bool IsPanelOpenAndVisibleInSession( + const UIEditorWorkspaceSession& session, + std::string_view panelId) { + const UIEditorPanelSessionState* state = FindUIEditorPanelSessionState(session, panelId); + return state != nullptr && state->open && state->visible; +} + +std::size_t CountVisibleChildren( + const UIEditorWorkspaceNode& node, + const UIEditorWorkspaceSession& session) { + if (node.kind != UIEditorWorkspaceNodeKind::TabStack) { + return 0u; + } + + std::size_t visibleCount = 0u; + for (const UIEditorWorkspaceNode& child : node.children) { + if (child.kind == UIEditorWorkspaceNodeKind::Panel && + IsPanelOpenAndVisibleInSession(session, child.panel.panelId)) { + ++visibleCount; + } + } + + return visibleCount; +} + +std::size_t ResolveActualInsertionIndexForVisibleInsertion( + const UIEditorWorkspaceNode& node, + const UIEditorWorkspaceSession& session, + std::size_t targetVisibleInsertionIndex) { + std::vector visibleIndices = {}; + visibleIndices.reserve(node.children.size()); + for (std::size_t index = 0; index < node.children.size(); ++index) { + const UIEditorWorkspaceNode& child = node.children[index]; + if (child.kind == UIEditorWorkspaceNodeKind::Panel && + IsPanelOpenAndVisibleInSession(session, child.panel.panelId)) { + visibleIndices.push_back(index); + } + } + + if (targetVisibleInsertionIndex == 0u) { + return visibleIndices.empty() ? 0u : visibleIndices.front(); + } + + if (visibleIndices.empty()) { + return 0u; + } + + if (targetVisibleInsertionIndex >= visibleIndices.size()) { + return visibleIndices.back() + 1u; + } + + return visibleIndices[targetVisibleInsertionIndex]; +} + +void FixTabStackSelectedIndex( + UIEditorWorkspaceNode& node, + std::string_view preferredPanelId) { + if (node.kind != UIEditorWorkspaceNodeKind::TabStack || node.children.empty()) { + return; + } + + for (std::size_t index = 0; index < node.children.size(); ++index) { + if (node.children[index].kind == UIEditorWorkspaceNodeKind::Panel && + node.children[index].panel.panelId == preferredPanelId) { + node.selectedTabIndex = index; + return; + } + } + + if (node.selectedTabIndex >= node.children.size()) { + node.selectedTabIndex = node.children.size() - 1u; + } +} + +bool RemoveNodeByIdRecursive( + UIEditorWorkspaceNode& node, + std::string_view nodeId) { + if (node.kind != UIEditorWorkspaceNodeKind::Split) { + return false; + } + + for (std::size_t index = 0; index < node.children.size(); ++index) { + if (node.children[index].nodeId == nodeId) { + node.children.erase(node.children.begin() + static_cast(index)); + if (node.children.size() == 1u) { + CollapseSplitNodeToOnlyChild(node); + } + return true; + } + } + + for (UIEditorWorkspaceNode& child : node.children) { + if (RemoveNodeByIdRecursive(child, nodeId)) { + if (node.kind == UIEditorWorkspaceNodeKind::Split && + node.children.size() == 1u) { + CollapseSplitNodeToOnlyChild(node); + } + return true; + } + } + + return false; +} + +float ClampDockSplitRatio(float value) { + constexpr float kMinRatio = 0.1f; + constexpr float kMaxRatio = 0.9f; + return (std::min)(kMaxRatio, (std::max)(kMinRatio, value)); +} + +bool IsLeadingDockPlacement(UIEditorWorkspaceDockPlacement placement) { + return placement == UIEditorWorkspaceDockPlacement::Left || + placement == UIEditorWorkspaceDockPlacement::Top; +} + +UIEditorWorkspaceSplitAxis ResolveDockSplitAxis(UIEditorWorkspaceDockPlacement placement) { + return placement == UIEditorWorkspaceDockPlacement::Left || + placement == UIEditorWorkspaceDockPlacement::Right + ? UIEditorWorkspaceSplitAxis::Horizontal + : UIEditorWorkspaceSplitAxis::Vertical; +} + +std::string MakeUniqueNodeId( + const UIEditorWorkspaceModel& workspace, + std::string base) { + if (base.empty()) { + base = "workspace-node"; + } + + if (FindUIEditorWorkspaceNode(workspace, base) == nullptr) { + return base; + } + + for (std::size_t suffix = 1u; suffix < 1024u; ++suffix) { + const std::string candidate = base + "-" + std::to_string(suffix); + if (FindUIEditorWorkspaceNode(workspace, candidate) == nullptr) { + return candidate; + } + } + + return base + "-overflow"; +} + +bool TryExtractVisiblePanelFromTabStack( + UIEditorWorkspaceModel& workspace, + const UIEditorWorkspaceSession& session, + std::string_view sourceNodeId, + std::string_view panelId, + UIEditorWorkspaceNode& extractedPanel) { + std::vector sourcePath = {}; + if (!FindNodePathRecursive(workspace.root, sourceNodeId, sourcePath)) { + return false; + } + + UIEditorWorkspaceNode* sourceStack = + ResolveMutableNodeByPath(workspace.root, sourcePath); + if (sourceStack == nullptr || + sourceStack->kind != UIEditorWorkspaceNodeKind::TabStack) { + return false; + } + + std::size_t panelIndex = sourceStack->children.size(); + for (std::size_t index = 0; index < sourceStack->children.size(); ++index) { + const UIEditorWorkspaceNode& child = sourceStack->children[index]; + if (child.kind != UIEditorWorkspaceNodeKind::Panel) { + return false; + } + + if (child.panel.panelId == panelId) { + if (!IsPanelOpenAndVisibleInSession(session, panelId)) { + return false; + } + panelIndex = index; + break; + } + } + + if (panelIndex >= sourceStack->children.size()) { + return false; + } + + if (sourcePath.empty() && sourceStack->children.size() == 1u) { + return false; + } + + std::string fallbackSelectedPanelId = {}; + if (sourceStack->selectedTabIndex < sourceStack->children.size()) { + fallbackSelectedPanelId = + sourceStack->children[sourceStack->selectedTabIndex].panel.panelId; + } + + extractedPanel = std::move(sourceStack->children[panelIndex]); + sourceStack->children.erase( + sourceStack->children.begin() + static_cast(panelIndex)); + + if (sourceStack->children.empty()) { + if (sourcePath.empty()) { + return false; + } + + if (!RemoveNodeByIdRecursive(workspace.root, sourceNodeId)) { + return false; + } + } else { + if (fallbackSelectedPanelId == panelId) { + const std::size_t nextIndex = + (std::min)(panelIndex, sourceStack->children.size() - 1u); + fallbackSelectedPanelId = + sourceStack->children[nextIndex].panel.panelId; + } + FixTabStackSelectedIndex(*sourceStack, fallbackSelectedPanelId); + } + + workspace = CanonicalizeUIEditorWorkspaceModel(std::move(workspace)); + return true; +} + bool TryActivateRecursive( UIEditorWorkspaceNode& node, std::string_view panelId) { @@ -484,4 +748,266 @@ bool TrySetUIEditorWorkspaceSplitRatio( return true; } +bool TryReorderUIEditorWorkspaceTab( + UIEditorWorkspaceModel& workspace, + const UIEditorWorkspaceSession& session, + std::string_view nodeId, + std::string_view panelId, + std::size_t targetVisibleInsertionIndex) { + UIEditorWorkspaceNode* node = FindMutableNodeRecursive(workspace.root, nodeId); + if (node == nullptr || node->kind != UIEditorWorkspaceNodeKind::TabStack) { + return false; + } + + std::vector visibleChildIndices = {}; + std::vector reorderedVisibleChildren = {}; + visibleChildIndices.reserve(node->children.size()); + reorderedVisibleChildren.reserve(node->children.size()); + + std::size_t sourceVisibleIndex = node->children.size(); + for (std::size_t index = 0; index < node->children.size(); ++index) { + const UIEditorWorkspaceNode& child = node->children[index]; + if (child.kind != UIEditorWorkspaceNodeKind::Panel) { + return false; + } + + if (!IsPanelOpenAndVisibleInSession(session, child.panel.panelId)) { + continue; + } + + if (child.panel.panelId == panelId) { + sourceVisibleIndex = visibleChildIndices.size(); + } + + visibleChildIndices.push_back(index); + reorderedVisibleChildren.push_back(child); + } + + if (sourceVisibleIndex >= reorderedVisibleChildren.size() || + targetVisibleInsertionIndex > reorderedVisibleChildren.size()) { + return false; + } + + if (targetVisibleInsertionIndex == sourceVisibleIndex || + targetVisibleInsertionIndex == sourceVisibleIndex + 1u) { + return false; + } + + UIEditorWorkspaceNode movedChild = + std::move(reorderedVisibleChildren[sourceVisibleIndex]); + reorderedVisibleChildren.erase( + reorderedVisibleChildren.begin() + static_cast(sourceVisibleIndex)); + + std::size_t adjustedInsertionIndex = targetVisibleInsertionIndex; + if (adjustedInsertionIndex > sourceVisibleIndex) { + --adjustedInsertionIndex; + } + if (adjustedInsertionIndex > reorderedVisibleChildren.size()) { + adjustedInsertionIndex = reorderedVisibleChildren.size(); + } + + reorderedVisibleChildren.insert( + reorderedVisibleChildren.begin() + + static_cast(adjustedInsertionIndex), + std::move(movedChild)); + + std::string selectedPanelId = {}; + if (node->selectedTabIndex < node->children.size()) { + selectedPanelId = node->children[node->selectedTabIndex].panel.panelId; + } + + const std::vector originalChildren = node->children; + std::size_t nextVisibleIndex = 0u; + for (std::size_t index = 0; index < originalChildren.size(); ++index) { + const UIEditorWorkspaceNode& originalChild = originalChildren[index]; + if (!IsPanelOpenAndVisibleInSession(session, originalChild.panel.panelId)) { + node->children[index] = originalChild; + continue; + } + + node->children[index] = reorderedVisibleChildren[nextVisibleIndex]; + ++nextVisibleIndex; + } + + for (std::size_t index = 0; index < node->children.size(); ++index) { + if (node->children[index].panel.panelId == selectedPanelId) { + node->selectedTabIndex = index; + break; + } + } + + return true; +} + +bool TryMoveUIEditorWorkspaceTabToStack( + UIEditorWorkspaceModel& workspace, + const UIEditorWorkspaceSession& session, + std::string_view sourceNodeId, + std::string_view panelId, + std::string_view targetNodeId, + std::size_t targetVisibleInsertionIndex) { + if (sourceNodeId.empty() || + panelId.empty() || + targetNodeId.empty()) { + return false; + } + + if (sourceNodeId == targetNodeId) { + return TryReorderUIEditorWorkspaceTab( + workspace, + session, + sourceNodeId, + panelId, + targetVisibleInsertionIndex); + } + + const UIEditorWorkspaceNode* targetNode = + FindUIEditorWorkspaceNode(workspace, targetNodeId); + if (targetNode == nullptr || + targetNode->kind != UIEditorWorkspaceNodeKind::TabStack) { + return false; + } + + if (targetVisibleInsertionIndex > CountVisibleChildren(*targetNode, session)) { + return false; + } + + UIEditorWorkspaceNode extractedPanel = {}; + if (!TryExtractVisiblePanelFromTabStack( + workspace, + session, + sourceNodeId, + panelId, + extractedPanel)) { + return false; + } + + UIEditorWorkspaceNode* targetStack = + FindMutableNodeRecursive(workspace.root, targetNodeId); + if (targetStack == nullptr || + targetStack->kind != UIEditorWorkspaceNodeKind::TabStack) { + return false; + } + + const std::size_t actualInsertionIndex = + ResolveActualInsertionIndexForVisibleInsertion( + *targetStack, + session, + targetVisibleInsertionIndex); + if (actualInsertionIndex > targetStack->children.size()) { + return false; + } + + targetStack->children.insert( + targetStack->children.begin() + + static_cast(actualInsertionIndex), + std::move(extractedPanel)); + targetStack->selectedTabIndex = actualInsertionIndex; + workspace.activePanelId = std::string(panelId); + workspace = CanonicalizeUIEditorWorkspaceModel(std::move(workspace)); + return true; +} + +bool TryDockUIEditorWorkspaceTabRelative( + UIEditorWorkspaceModel& workspace, + const UIEditorWorkspaceSession& session, + std::string_view sourceNodeId, + std::string_view panelId, + std::string_view targetNodeId, + UIEditorWorkspaceDockPlacement placement, + float splitRatio) { + if (placement == UIEditorWorkspaceDockPlacement::Center) { + const UIEditorWorkspaceNode* targetNode = + FindUIEditorWorkspaceNode(workspace, targetNodeId); + if (targetNode == nullptr || + targetNode->kind != UIEditorWorkspaceNodeKind::TabStack) { + return false; + } + + return TryMoveUIEditorWorkspaceTabToStack( + workspace, + session, + sourceNodeId, + panelId, + targetNodeId, + CountVisibleChildren(*targetNode, session)); + } + + if (sourceNodeId.empty() || + panelId.empty() || + targetNodeId.empty()) { + return false; + } + + const UIEditorWorkspaceNode* sourceNode = + FindUIEditorWorkspaceNode(workspace, sourceNodeId); + const UIEditorWorkspaceNode* targetNode = + FindUIEditorWorkspaceNode(workspace, targetNodeId); + if (sourceNode == nullptr || + targetNode == nullptr || + sourceNode->kind != UIEditorWorkspaceNodeKind::TabStack || + targetNode->kind != UIEditorWorkspaceNodeKind::TabStack) { + return false; + } + + if (sourceNodeId == targetNodeId && + sourceNode->children.size() <= 1u) { + return false; + } + + UIEditorWorkspaceNode extractedPanel = {}; + if (!TryExtractVisiblePanelFromTabStack( + workspace, + session, + sourceNodeId, + panelId, + extractedPanel)) { + return false; + } + + UIEditorWorkspaceNode* targetStack = + FindMutableNodeRecursive(workspace.root, targetNodeId); + if (targetStack == nullptr || + targetStack->kind != UIEditorWorkspaceNodeKind::TabStack) { + return false; + } + + const std::string movedStackNodeId = MakeUniqueNodeId( + workspace, + std::string(targetNodeId) + "__dock_" + std::string(panelId) + "_stack"); + UIEditorWorkspaceNode movedStack = {}; + movedStack.kind = UIEditorWorkspaceNodeKind::TabStack; + movedStack.nodeId = movedStackNodeId; + movedStack.selectedTabIndex = 0u; + movedStack.children.push_back(std::move(extractedPanel)); + + UIEditorWorkspaceNode existingTarget = std::move(*targetStack); + UIEditorWorkspaceNode primary = {}; + UIEditorWorkspaceNode secondary = {}; + if (IsLeadingDockPlacement(placement)) { + primary = std::move(movedStack); + secondary = std::move(existingTarget); + } else { + primary = std::move(existingTarget); + secondary = std::move(movedStack); + } + + const float requestedRatio = ClampDockSplitRatio(splitRatio); + const float resolvedSplitRatio = + IsLeadingDockPlacement(placement) + ? requestedRatio + : (1.0f - requestedRatio); + *targetStack = BuildUIEditorWorkspaceSplit( + MakeUniqueNodeId( + workspace, + std::string(targetNodeId) + "__dock_split"), + ResolveDockSplitAxis(placement), + resolvedSplitRatio, + std::move(primary), + std::move(secondary)); + workspace.activePanelId = std::string(panelId); + workspace = CanonicalizeUIEditorWorkspaceModel(std::move(workspace)); + return true; +} + } // namespace XCEngine::UI::Editor diff --git a/tests/UI/Editor/integration/CMakeLists.txt b/tests/UI/Editor/integration/CMakeLists.txt index 908ef04c..f21e0e70 100644 --- a/tests/UI/Editor/integration/CMakeLists.txt +++ b/tests/UI/Editor/integration/CMakeLists.txt @@ -148,6 +148,11 @@ if(TARGET editor_ui_scroll_view_basic_validation) editor_ui_scroll_view_basic_validation) endif() +if(TARGET editor_ui_dock_tab_reorder_same_stack_validation) + list(APPEND EDITOR_UI_INTEGRATION_TARGETS + editor_ui_dock_tab_reorder_same_stack_validation) +endif() + add_custom_target(editor_ui_integration_tests DEPENDS ${EDITOR_UI_INTEGRATION_TARGETS} diff --git a/tests/UI/Editor/integration/shell/CMakeLists.txt b/tests/UI/Editor/integration/shell/CMakeLists.txt index 4b6095fa..0e45003d 100644 --- a/tests/UI/Editor/integration/shell/CMakeLists.txt +++ b/tests/UI/Editor/integration/shell/CMakeLists.txt @@ -13,6 +13,9 @@ endif() if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/dock_host_basic/CMakeLists.txt") add_subdirectory(dock_host_basic) endif() +if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/dock_tab_reorder_same_stack/CMakeLists.txt") + add_subdirectory(dock_tab_reorder_same_stack) +endif() if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/panel_content_host_basic/CMakeLists.txt") add_subdirectory(panel_content_host_basic) endif() diff --git a/tests/UI/Editor/integration/shell/dock_host_basic/main.cpp b/tests/UI/Editor/integration/shell/dock_host_basic/main.cpp index 1cfa751c..8723d9b5 100644 --- a/tests/UI/Editor/integration/shell/dock_host_basic/main.cpp +++ b/tests/UI/Editor/integration/shell/dock_host_basic/main.cpp @@ -491,7 +491,7 @@ private: if (action == ActionId::Reset) { ResetScenario(); m_lastStatus = "Ready"; - m_lastMessage = "场景状态已重置。请重新检查 splitter drag / tab close / panel close / active panel sync。"; + m_lastMessage = "场景状态已重置。请重新检查 splitter drag / tab activate / panel close / active panel sync。"; m_lastColor = kWarning; return; } @@ -586,11 +586,11 @@ private: 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. 验证 unified dock:tab activate / tab close / single-tab body activate。", kTextPrimary, 12.0f); + drawList.AddText(UIPoint(m_introRect.x + 16.0f, m_introRect.y + 94.0f), "2. 验证 unified dock:tab activate / single-tab body 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); + drawList.AddText(UIPoint(m_introRect.x + 16.0f, m_introRect.y + 180.0f), "然后切换 Document A / B,最后点 Details 或 Console 的 X。", kTextWeak, 11.0f); DrawCard(drawList, m_controlsRect, "操作", "这里只保留当前场景必要按钮。"); for (const ButtonState& button : m_buttons) { diff --git a/tests/UI/Editor/integration/shell/dock_tab_reorder_same_stack/CMakeLists.txt b/tests/UI/Editor/integration/shell/dock_tab_reorder_same_stack/CMakeLists.txt new file mode 100644 index 00000000..8738a30e --- /dev/null +++ b/tests/UI/Editor/integration/shell/dock_tab_reorder_same_stack/CMakeLists.txt @@ -0,0 +1,30 @@ +add_executable(editor_ui_dock_tab_reorder_same_stack_validation WIN32 + main.cpp +) + +target_include_directories(editor_ui_dock_tab_reorder_same_stack_validation PRIVATE + ${XCENGINE_EDITOR_UI_TESTS_EDITOR_ROOT}/include + ${XCENGINE_EDITOR_UI_TESTS_EDITOR_ROOT} + ${CMAKE_SOURCE_DIR}/engine/include +) + +target_compile_definitions(editor_ui_dock_tab_reorder_same_stack_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_tab_reorder_same_stack_validation PRIVATE /utf-8 /FS) + set_property(TARGET editor_ui_dock_tab_reorder_same_stack_validation PROPERTY + MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>DLL") +endif() + +target_link_libraries(editor_ui_dock_tab_reorder_same_stack_validation PRIVATE + XCUIEditorLib + XCUIEditorHost +) + +set_target_properties(editor_ui_dock_tab_reorder_same_stack_validation PROPERTIES + OUTPUT_NAME "XCUIEditorDockTabReorderSameStackValidation" +) diff --git a/tests/UI/Editor/integration/shell/dock_tab_reorder_same_stack/main.cpp b/tests/UI/Editor/integration/shell/dock_tab_reorder_same_stack/main.cpp new file mode 100644 index 00000000..4f41876e --- /dev/null +++ b/tests/UI/Editor/integration/shell/dock_tab_reorder_same_stack/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 +#include + +#ifndef XCENGINE_EDITOR_UI_TESTS_REPO_ROOT +#define XCENGINE_EDITOR_UI_TESTS_REPO_ROOT "." +#endif + +namespace { + +using XCEngine::Input::KeyCode; +using XCEngine::UI::UIColor; +using XCEngine::UI::UIDrawData; +using XCEngine::UI::UIDrawList; +using XCEngine::UI::UIInputEvent; +using XCEngine::UI::UIInputEventType; +using XCEngine::UI::UIPoint; +using XCEngine::UI::UIPointerButton; +using XCEngine::UI::UIRect; +using XCEngine::UI::Editor::BuildDefaultUIEditorWorkspaceController; +using XCEngine::UI::Editor::BuildUIEditorWorkspacePanel; +using XCEngine::UI::Editor::BuildUIEditorWorkspaceSingleTabStack; +using XCEngine::UI::Editor::BuildUIEditorWorkspaceSplit; +using XCEngine::UI::Editor::BuildUIEditorWorkspaceTabStack; +using XCEngine::UI::Editor::FindUIEditorWorkspaceNode; +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::UIEditorDockHostTabStripInteractionEntry; +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::UpdateUIEditorDockHostInteraction; +using XCEngine::UI::Editor::Widgets::AppendUIEditorDockHostBackground; +using XCEngine::UI::Editor::Widgets::AppendUIEditorDockHostForeground; +using XCEngine::UI::Editor::Widgets::UIEditorDockHostHitTarget; +using XCEngine::UI::Editor::Widgets::UIEditorDockHostHitTargetKind; +using XCEngine::UI::Editor::Widgets::UIEditorDockHostTabStackLayout; + +constexpr const wchar_t* kWindowClassName = L"XCUIEditorDockTabReorderSameStackValidation"; +constexpr const wchar_t* kWindowTitle = L"XCUI Editor | Dock Tab Reorder Same Stack"; + +constexpr UIColor kWindowBg(0.10f, 0.10f, 0.10f, 1.0f); +constexpr UIColor kCardBg(0.16f, 0.16f, 0.16f, 1.0f); +constexpr UIColor kCardBorder(0.28f, 0.28f, 0.28f, 1.0f); +constexpr UIColor kPreviewBg(0.12f, 0.12f, 0.12f, 1.0f); +constexpr UIColor kTextPrimary(0.92f, 0.92f, 0.92f, 1.0f); +constexpr UIColor kTextMuted(0.74f, 0.74f, 0.74f, 1.0f); +constexpr UIColor kTextWeak(0.57f, 0.57f, 0.57f, 1.0f); +constexpr UIColor kButtonBg(0.24f, 0.24f, 0.24f, 1.0f); +constexpr UIColor kButtonHover(0.31f, 0.31f, 0.31f, 1.0f); +constexpr UIColor kButtonBorder(0.44f, 0.44f, 0.44f, 1.0f); +constexpr UIColor kOk(0.47f, 0.71f, 0.50f, 1.0f); +constexpr UIColor kWarn(0.85f, 0.68f, 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 ? "是" : "否"; +} + +std::string FormatOptionalIndex(std::size_t index) { + if (index == XCEngine::UI::Editor::Widgets::UIEditorTabStripInvalidIndex) { + return "无"; + } + + return std::to_string(index); +} + +std::string DescribeHitTarget(const UIEditorDockHostHitTarget& target) { + switch (target.kind) { + case UIEditorDockHostHitTargetKind::Tab: + return "标签: " + target.panelId; + case UIEditorDockHostHitTargetKind::TabStripBackground: + return "标签栏空白"; + case UIEditorDockHostHitTargetKind::PanelBody: + return "面板主体: " + target.panelId; + case UIEditorDockHostHitTargetKind::SplitterHandle: + return "分割条: " + target.nodeId; + case UIEditorDockHostHitTargetKind::None: + default: + return "无"; + } +} + +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 }, + { "doc-c", "Document C", {}, true, true, true }, + { "details", "Details", {}, true, true, true } + }; + return registry; +} + +UIEditorWorkspaceModel BuildWorkspace() { + UIEditorWorkspaceModel workspace = {}; + workspace.root = BuildUIEditorWorkspaceSplit( + "root-split", + UIEditorWorkspaceSplitAxis::Horizontal, + 0.72f, + BuildUIEditorWorkspaceTabStack( + "document-tabs", + { + BuildUIEditorWorkspacePanel("doc-a-node", "doc-a", "Document A", true), + BuildUIEditorWorkspacePanel("doc-b-node", "doc-b", "Document B", true), + BuildUIEditorWorkspacePanel("doc-c-node", "doc-c", "Document C", true) + }, + 0u), + BuildUIEditorWorkspaceSingleTabStack("details-node", "details", "Details", true)); + workspace.activePanelId = "doc-a"; + return workspace; +} + +std::string CollectDocumentTabOrder(const UIEditorWorkspaceController& controller) { + const auto* node = FindUIEditorWorkspaceNode(controller.GetWorkspace(), "document-tabs"); + if (node == nullptr) { + return "缺失"; + } + + std::ostringstream stream = {}; + for (std::size_t index = 0; index < node->children.size(); ++index) { + if (index > 0u) { + stream << " | "; + } + stream << node->children[index].panel.panelId; + } + return stream.str(); +} + +const XCEngine::UI::Editor::Widgets::UIEditorDockHostTabStackLayout* FindDocumentTabStackLayout( + const UIEditorDockHostInteractionFrame& frame) { + for (const auto& tabStack : frame.layout.tabStacks) { + if (tabStack.nodeId == "document-tabs") { + return &tabStack; + } + } + + return nullptr; +} + +const UIEditorDockHostTabStripInteractionEntry* FindDocumentTabStripInteractionEntry( + const UIEditorDockHostInteractionState& state) { + for (const UIEditorDockHostTabStripInteractionEntry& entry : state.tabStripInteractions) { + if (entry.nodeId == "document-tabs") { + return &entry; + } + } + + return nullptr; +} + +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); + app->m_lastInputCause = "focus_lost"; + return 0; + } + break; + case WM_CAPTURECHANGED: + if (app != nullptr && + !app->m_interactionState.activeTabDragNodeId.empty() && + reinterpret_cast(lParam) != hwnd) { + UIInputEvent event = {}; + event.type = UIInputEventType::FocusLost; + app->m_pendingInputEvents.push_back(event); + app->m_lastInputCause = "focus_lost"; + return 0; + } + break; + case WM_KEYDOWN: + case WM_SYSKEYDOWN: + if (app != nullptr) { + if (wParam == VK_F12) { + app->m_autoScreenshot.RequestCapture("manual_f12"); + return 0; + } + + UIInputEvent event = {}; + event.type = UIInputEventType::KeyDown; + event.keyCode = static_cast(wParam == VK_ESCAPE ? KeyCode::Escape : KeyCode::None); + app->m_pendingInputEvents.push_back(event); + return 0; + } + break; + case WM_ERASEBKGND: + return 1; + case WM_DESTROY: + PostQuitMessage(0); + return 0; + default: + break; + } + + return DefWindowProcW(hwnd, message, wParam, lParam); + } + + bool Initialize(HINSTANCE hInstance, int nCmdShow) { + m_captureRoot = + ResolveRepoRootPath() / "tests/UI/Editor/integration/shell/dock_tab_reorder_same_stack/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, + 1500, + 920, + 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_lastInputCause.clear(); + m_hoverText = "无"; + m_lastResult = "等待验证:请在同一标签栏内拖拽标签。"; + m_lastResultColor = kWarn; + } + + 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 sidebarWidth = 360.0f; + + m_introRect = UIRect(padding, padding, width - padding * 2.0f, 168.0f); + m_previewRect = UIRect( + padding, + m_introRect.y + m_introRect.height + 16.0f, + width - sidebarWidth - padding * 3.0f, + height - m_introRect.height - padding * 3.0f); + m_stateRect = UIRect( + m_previewRect.x + m_previewRect.width + 20.0f, + m_previewRect.y, + sidebarWidth, + m_previewRect.height); + m_dockHostRect = UIRect( + m_previewRect.x + 16.0f, + m_previewRect.y + 64.0f, + m_previewRect.width - 32.0f, + m_previewRect.height - 80.0f); + + const float buttonWidth = (m_stateRect.width - 44.0f) * 0.5f; + const float buttonY = m_stateRect.y + m_stateRect.height - 52.0f; + m_buttons = { + { ActionId::Reset, "重置", UIRect(m_stateRect.x + 16.0f, buttonY, buttonWidth, 36.0f), false }, + { ActionId::Capture, "截图(F12)", UIRect(m_stateRect.x + 28.0f + buttonWidth, buttonY, 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_lastResult = "已重置:请重新检查提交、取消和失焦取消。"; + m_lastResultColor = kWarn; + return; + } + + m_autoScreenshot.RequestCapture("manual_button"); + m_lastResult = "截图已排队:输出到 captures/。"; + m_lastResultColor = kWarn; + } + + void ApplyHostCaptureRequests(const UIEditorDockHostInteractionResult& result) { + if (result.requestPointerCapture && GetCapture() != m_hwnd) { + SetCapture(m_hwnd); + } + if (result.releasePointerCapture && GetCapture() == m_hwnd) { + ReleaseCapture(); + } + } + + void UpdateLastResult(const UIEditorDockHostInteractionResult& result) { + if (result.layoutResult.status == UIEditorWorkspaceLayoutOperationStatus::Changed) { + m_lastResult = "结果:同一标签栏内的标签重排已提交。"; + m_lastResultColor = kOk; + return; + } + + if (result.requestPointerCapture) { + m_lastResult = "结果:开始拖拽,宿主已拿到 pointer capture。"; + m_lastResultColor = kOk; + return; + } + + if (result.releasePointerCapture && !result.layoutChanged && !result.commandExecuted) { + if (m_lastInputCause == "focus_lost") { + m_lastResult = "结果:窗口失焦,本次重排已取消。"; + } else { + m_lastResult = "结果:拖拽已取消。拖到 body 区再松开时,标签顺序应保持不变。"; + } + m_lastResultColor = kWarn; + return; + } + } + + void RenderFrame() { + UpdateLayout(); + m_cachedFrame = UpdateUIEditorDockHostInteraction( + m_interactionState, + m_controller, + m_dockHostRect, + m_pendingInputEvents); + m_pendingInputEvents.clear(); + ApplyHostCaptureRequests(m_cachedFrame.result); + UpdateLastResult(m_cachedFrame.result); + m_lastInputCause.clear(); + + 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("DockTabReorderSameStack"); + drawList.AddFilledRect(UIRect(0.0f, 0.0f, width, height), kWindowBg); + + DrawCard(drawList, m_introRect, "这个测试验证什么功能?", "只验证同一标签栏内 tab 重排的基础 contract,不做 editor 业务。"); + drawList.AddText(UIPoint(m_introRect.x + 16.0f, m_introRect.y + 70.0f), "1. 拖动 Document A / B / C 的 tab header 到同一行其他位置。", kTextPrimary, 12.0f); + drawList.AddText(UIPoint(m_introRect.x + 16.0f, m_introRect.y + 92.0f), "2. 在 header 区松开:应提交重排,并更新右侧标签顺序。", kTextPrimary, 12.0f); + drawList.AddText(UIPoint(m_introRect.x + 16.0f, m_introRect.y + 114.0f), "3. 拖到 body 区再松开:应取消,本次标签顺序不能变化。", kTextPrimary, 12.0f); + drawList.AddText(UIPoint(m_introRect.x + 16.0f, m_introRect.y + 136.0f), "4. 拖拽中切走窗口或按 Esc:应释放 capture,并取消这次重排。", kTextWeak, 11.0f); + + DrawCard(drawList, m_previewRect, "预览区", "这里只保留同栏重排所需的最小工作区。"); + drawList.AddFilledRect(m_dockHostRect, kPreviewBg, 8.0f); + AppendUIEditorDockHostBackground(drawList, m_cachedFrame.layout); + AppendUIEditorDockHostForeground(drawList, m_cachedFrame.layout); + + DrawCard(drawList, m_stateRect, "状态", "拖拽时重点观察右侧状态是否和画面一致。"); + float y = m_stateRect.y + 68.0f; + auto addLine = [&](std::string text, const UIColor& color = kTextPrimary, float fontSize = 12.0f) mutable { + drawList.AddText(UIPoint(m_stateRect.x + 16.0f, y), std::move(text), color, fontSize); + y += 22.0f; + }; + + const auto* documentTabStack = FindDocumentTabStackLayout(m_cachedFrame); + const bool previewVisible = + documentTabStack != nullptr && + documentTabStack->tabStripLayout.insertionPreview.visible; + const std::size_t previewInsertionIndex = + documentTabStack != nullptr + ? documentTabStack->tabStripLayout.insertionPreview.insertionIndex + : XCEngine::UI::Editor::Widgets::UIEditorTabStripInvalidIndex; + + addLine("当前标签顺序: " + CollectDocumentTabOrder(m_controller), kTextPrimary, 11.0f); + addLine("活动面板: " + m_controller.GetWorkspace().activePanelId, kTextPrimary, 11.0f); + addLine("已聚焦: " + FormatBool(m_cachedFrame.focused), m_cachedFrame.focused ? kOk : kTextMuted, 11.0f); + addLine("宿主捕获: " + FormatBool(GetCapture() == m_hwnd), GetCapture() == m_hwnd ? kOk : kTextMuted, 11.0f); + addLine( + "拖拽标签栈: " + + (m_interactionState.activeTabDragNodeId.empty() + ? std::string("无") + : m_interactionState.activeTabDragNodeId), + kTextWeak, + 11.0f); + addLine("预览插入位激活: " + FormatBool(previewVisible), previewVisible ? kOk : kTextWeak, 11.0f); + addLine("预览插入索引: " + FormatOptionalIndex(previewInsertionIndex), kTextWeak, 11.0f); + addLine("当前 Hover: " + m_hoverText, kTextWeak, 11.0f); + + drawList.AddText( + UIPoint(m_stateRect.x + 16.0f, y + 8.0f), + "最近结果", + kTextPrimary, + 13.0f); + drawList.AddText( + UIPoint(m_stateRect.x + 16.0f, y + 32.0f), + m_lastResult, + m_lastResultColor, + 12.0f); + + const std::string captureSummary = + m_autoScreenshot.HasPendingCapture() + ? "截图排队中..." + : (m_autoScreenshot.GetLastCaptureSummary().empty() + ? "F12 或按钮 -> captures/" + : m_autoScreenshot.GetLastCaptureSummary()); + drawList.AddText( + UIPoint(m_stateRect.x + 16.0f, m_stateRect.y + m_stateRect.height - 86.0f), + captureSummary, + kTextWeak, + 11.0f); + for (const ButtonState& button : m_buttons) { + DrawButton(drawList, button); + } + + 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_previewRect = {}; + UIRect m_stateRect = {}; + UIRect m_dockHostRect = {}; + bool m_trackingMouseLeave = false; + std::string m_lastInputCause = {}; + std::string m_hoverText = {}; + std::string m_lastResult = {}; + UIColor m_lastResultColor = kTextMuted; +}; + +} // namespace + +int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) { + return ScenarioApp().Run(hInstance, nCmdShow); +} diff --git a/tests/UI/Editor/integration/shell/tab_strip_basic/main.cpp b/tests/UI/Editor/integration/shell/tab_strip_basic/main.cpp index 55a00e9f..eb0ce684 100644 --- a/tests/UI/Editor/integration/shell/tab_strip_basic/main.cpp +++ b/tests/UI/Editor/integration/shell/tab_strip_basic/main.cpp @@ -196,11 +196,6 @@ std::string DescribeHitTarget( return "Tab: " + items[target.index].title; } return "Tab"; - case UIEditorTabStripHitTargetKind::CloseButton: - if (target.index < items.size()) { - return "CloseButton: " + items[target.index].title; - } - return "CloseButton"; case UIEditorTabStripHitTargetKind::None: default: return "None"; @@ -218,9 +213,6 @@ std::string JoinTabTitles(const std::vector& items) { stream << " | "; } stream << items[index].title; - if (!items[index].closable) { - stream << " (locked)"; - } } return stream.str(); } @@ -502,15 +494,6 @@ private: void ApplyInteractionResult( const UIEditorTabStripInteractionResult& result, std::string_view source) { - if (result.closeRequested && !result.closedTabId.empty()) { - DispatchCommand( - UIEditorWorkspaceCommandKind::ClosePanel, - result.closedTabId, - std::string(source) + " Close -> " + result.closedTabId); - PumpTabStripEvents({}); - return; - } - if ((result.selectionChanged || result.keyboardNavigated) && !result.selectedTabId.empty()) { DispatchCommand( @@ -609,7 +592,7 @@ private: drawList, introRect, "这个测试验证什么功能?", - "验证 TabStrip 的 header 命中、选中切换、关闭请求和键盘导航,不接业务面板。"); + "验证 TabStrip 的 header 命中、选中切换和键盘导航,不接业务面板。"); drawList.AddText( UIPoint(introRect.x + 16.0f, introRect.y + 68.0f), "1. 点击 tab,检查 selected / active panel 是否同步。", @@ -617,7 +600,7 @@ private: 12.0f); drawList.AddText( UIPoint(introRect.x + 16.0f, introRect.y + 92.0f), - "2. 点击 X,只验证关闭请求;Document C 没有关闭按钮。", + "2. 所有 tab 都没有关闭按钮;这里只验证命中、focus 和选中同步。", kTextMuted, 12.0f); drawList.AddText( @@ -719,7 +702,7 @@ private: 12.0f); drawList.AddText( UIPoint(m_layout.contentRect.x + 20.0f, m_layout.contentRect.y + 100.0f), - "可点击 Document B 切换,或点击 Document C 验证不可关闭 tab。", + "可点击 Document B 切换,再用 Left / Right / Home / End 验证键盘导航。", kTextWeak, 12.0f); diff --git a/tests/UI/Editor/integration/shell/workspace_shell_compose/main.cpp b/tests/UI/Editor/integration/shell/workspace_shell_compose/main.cpp index 07919944..e43ac77a 100644 --- a/tests/UI/Editor/integration/shell/workspace_shell_compose/main.cpp +++ b/tests/UI/Editor/integration/shell/workspace_shell_compose/main.cpp @@ -606,7 +606,7 @@ private: 12.0f); drawList.AddText( UIPoint(m_introRect.x + 16.0f, m_introRect.y + 92.0f), - "2. tab 激活/关闭、panel 激活/关闭都必须统一回到 controller。", + "2. tab 激活、panel 激活/关闭都必须统一回到 controller。", kTextPrimary, 12.0f); drawList.AddText( diff --git a/tests/UI/Editor/unit/test_ui_editor_dock_host.cpp b/tests/UI/Editor/unit/test_ui_editor_dock_host.cpp index 1ded79f5..eb39c982 100644 --- a/tests/UI/Editor/unit/test_ui_editor_dock_host.cpp +++ b/tests/UI/Editor/unit/test_ui_editor_dock_host.cpp @@ -98,8 +98,8 @@ TEST(UIEditorDockHostTest, LayoutComposesOnlyUnifiedTabStacksFromWorkspaceTree) const auto* rootSplitter = FindUIEditorDockHostSplitterLayout(layout, "root-split"); ASSERT_NE(rootSplitter, nullptr); - EXPECT_FLOAT_EQ(rootSplitter->splitterLayout.handleRect.x, 395.0f); - EXPECT_FLOAT_EQ(rootSplitter->splitterLayout.handleRect.width, 10.0f); + EXPECT_FLOAT_EQ(rootSplitter->splitterLayout.handleRect.x, 399.5f); + EXPECT_FLOAT_EQ(rootSplitter->splitterLayout.handleRect.width, 1.0f); const auto& tabStack = layout.tabStacks.front(); EXPECT_EQ(tabStack.nodeId, "document-tabs"); @@ -138,7 +138,7 @@ TEST(UIEditorDockHostTest, HiddenBranchCollapsesAndVisibleBranchUsesFullBounds) EXPECT_FLOAT_EQ(layout.tabStacks.front().bounds.height, 480.0f); } -TEST(UIEditorDockHostTest, HitTestPrioritizesSplitterThenTabCloseThenPanelBody) { +TEST(UIEditorDockHostTest, HitTestPrioritizesSplitterThenTabThenPanelBody) { const UIEditorPanelRegistry registry = BuildPanelRegistry(); const UIEditorWorkspaceModel workspace = BuildWorkspace(); const UIEditorWorkspaceSession session = @@ -160,14 +160,14 @@ TEST(UIEditorDockHostTest, HitTestPrioritizesSplitterThenTabCloseThenPanelBody) EXPECT_EQ(splitterHit.nodeId, "root-split"); ASSERT_EQ(layout.tabStacks.size(), 3u); - const auto& closeRect = layout.tabStacks.front().tabStripLayout.closeButtonRects[1]; - const auto tabCloseHit = HitTestUIEditorDockHost( + const auto& tabRect = layout.tabStacks.front().tabStripLayout.tabHeaderRects[1]; + const auto tabHit = HitTestUIEditorDockHost( layout, - UIPoint(closeRect.x + closeRect.width * 0.5f, closeRect.y + closeRect.height * 0.5f)); - EXPECT_EQ(tabCloseHit.kind, UIEditorDockHostHitTargetKind::TabCloseButton); - EXPECT_EQ(tabCloseHit.nodeId, "document-tabs"); - EXPECT_EQ(tabCloseHit.panelId, "doc-b"); - EXPECT_EQ(tabCloseHit.index, 1u); + UIPoint(tabRect.x + tabRect.width - 6.0f, tabRect.y + tabRect.height * 0.5f)); + EXPECT_EQ(tabHit.kind, UIEditorDockHostHitTargetKind::Tab); + EXPECT_EQ(tabHit.nodeId, "document-tabs"); + EXPECT_EQ(tabHit.panelId, "doc-b"); + EXPECT_EQ(tabHit.index, 1u); const auto panelBodyHit = HitTestUIEditorDockHost( layout, @@ -186,7 +186,7 @@ TEST(UIEditorDockHostTest, BackgroundAndForegroundEmitStableCompositeCommands) { UIEditorDockHostState state = {}; state.focused = true; state.hoveredTarget = UIEditorDockHostHitTarget{ - UIEditorDockHostHitTargetKind::TabCloseButton, + UIEditorDockHostHitTargetKind::Tab, "document-tabs", "doc-b", 1u 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 index a2d58173..fb45d404 100644 --- a/tests/UI/Editor/unit/test_ui_editor_dock_host_interaction.cpp +++ b/tests/UI/Editor/unit/test_ui_editor_dock_host_interaction.cpp @@ -5,6 +5,9 @@ #include #include +#include +#include + namespace { using XCEngine::UI::UIInputEvent; @@ -19,6 +22,7 @@ using XCEngine::UI::Editor::BuildUIEditorWorkspaceSingleTabStack; using XCEngine::UI::Editor::BuildUIEditorWorkspaceSplit; using XCEngine::UI::Editor::BuildUIEditorWorkspaceTabStack; using XCEngine::UI::Editor::FindUIEditorPanelSessionState; +using XCEngine::UI::Editor::FindUIEditorWorkspaceNode; using XCEngine::UI::Editor::UIEditorDockHostInteractionState; using XCEngine::UI::Editor::UIEditorPanelRegistry; using XCEngine::UI::Editor::UIEditorWorkspaceModel; @@ -33,6 +37,7 @@ UIEditorPanelRegistry BuildPanelRegistry() { registry.panels = { { "doc-a", "Document A", {}, true, true, true }, { "doc-b", "Document B", {}, true, true, true }, + { "doc-c", "Document C", {}, true, true, true }, { "details", "Details", {}, true, true, true }, { "console", "Console", {}, true, true, true } }; @@ -62,6 +67,30 @@ UIEditorWorkspaceModel BuildWorkspace() { return workspace; } +UIEditorWorkspaceModel BuildThreeDocumentWorkspace() { + 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), + BuildUIEditorWorkspacePanel("doc-c-node", "doc-c", "Document C", true) + }, + 2u), + BuildUIEditorWorkspaceSplit( + "right-split", + UIEditorWorkspaceSplitAxis::Vertical, + 0.6f, + BuildUIEditorWorkspaceSingleTabStack("details-node", "details", "Details", true), + BuildUIEditorWorkspaceSingleTabStack("console-node", "console", "Console", true))); + workspace.activePanelId = "doc-c"; + return workspace; +} + UIInputEvent MakePointerMove(float x, float y) { UIInputEvent event = {}; event.type = UIInputEventType::PointerMove; @@ -230,60 +259,6 @@ TEST(UIEditorDockHostInteractionTest, ClickingTabActivatesTargetPanel) { EXPECT_EQ(documentStack->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), - {}); - const auto* documentStack = FindTabStackByNodeId(frame.layout, "document-tabs"); - ASSERT_NE(documentStack, nullptr); - const UIRect closeRect = documentStack->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), - { MakePointerDown(closeCenter.x, closeCenter.y) }); - EXPECT_TRUE(frame.result.consumed); - EXPECT_FALSE(frame.result.commandExecuted); - - frame = UpdateUIEditorDockHostInteraction( - state, - controller, - UIRect(0.0f, 0.0f, 800.0f, 600.0f), - { MakePointerDown(closeCenter.x, closeCenter.y) }); - EXPECT_TRUE(frame.result.consumed); - EXPECT_FALSE(frame.result.commandExecuted); - - 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, FocusedTabStripHandlesKeyboardNavigationThroughTabStripInteraction) { auto controller = BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace()); @@ -399,7 +374,7 @@ TEST(UIEditorDockHostInteractionTest, ClickingSingleTabStackBodyActivatesTargetP EXPECT_EQ(controller.GetWorkspace().activePanelId, "details"); } -TEST(UIEditorDockHostInteractionTest, ClickingSingleTabStackTabCloseClosesPanelThroughController) { +TEST(UIEditorDockHostInteractionTest, DraggingTabWithinSameStackRequestsCaptureAndCommitsReorder) { auto controller = BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace()); UIEditorDockHostInteractionState state = {}; @@ -409,29 +384,411 @@ TEST(UIEditorDockHostInteractionTest, ClickingSingleTabStackTabCloseClosesPanelT controller, UIRect(0.0f, 0.0f, 800.0f, 600.0f), {}); - const auto* consoleStack = FindTabStackByNodeId(frame.layout, "console-node"); - ASSERT_NE(consoleStack, nullptr); - const UIRect closeRect = consoleStack->tabStripLayout.closeButtonRects[0]; - const UIPoint closeCenter = RectCenter(closeRect); + const auto* documentStack = FindTabStackByNodeId(frame.layout, "document-tabs"); + ASSERT_NE(documentStack, nullptr); + const UIPoint sourceCenter = RectCenter(documentStack->tabStripLayout.tabHeaderRects[0]); + const UIPoint dropPoint( + documentStack->tabStripLayout.headerRect.x + + documentStack->tabStripLayout.headerRect.width - 2.0f, + sourceCenter.y); 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, "console"); - - frame = UpdateUIEditorDockHostInteraction( - state, - controller, - UIRect(0.0f, 0.0f, 800.0f, 600.0f), - { MakePointerUp(closeCenter.x, closeCenter.y) }); + { MakePointerDown(sourceCenter.x, sourceCenter.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); + frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + { MakePointerMove(dropPoint.x, dropPoint.y) }); + EXPECT_TRUE(frame.result.requestPointerCapture); + EXPECT_TRUE(frame.result.consumed); + + frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + { MakePointerUp(dropPoint.x, dropPoint.y) }); + EXPECT_TRUE(frame.result.releasePointerCapture); + EXPECT_TRUE(frame.result.layoutChanged) + << " status=" << static_cast(frame.result.layoutResult.status) + << " message=" << frame.result.layoutResult.message + << " commandExecuted=" << frame.result.commandExecuted; + + const auto* documentTabs = + FindUIEditorWorkspaceNode(controller.GetWorkspace(), "document-tabs"); + ASSERT_NE(documentTabs, nullptr); + ASSERT_EQ(documentTabs->children.size(), 2u); + EXPECT_EQ(documentTabs->children[0].panel.panelId, "doc-b"); + EXPECT_EQ(documentTabs->children[1].panel.panelId, "doc-a"); +} + +TEST(UIEditorDockHostInteractionTest, DraggingRightmostTabIntoMiddleCommitsSameStackReorder) { + auto controller = + BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildThreeDocumentWorkspace()); + UIEditorDockHostInteractionState state = {}; + + auto frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + {}); + const auto* documentStack = FindTabStackByNodeId(frame.layout, "document-tabs"); + ASSERT_NE(documentStack, nullptr); + const UIPoint sourceCenter = RectCenter(documentStack->tabStripLayout.tabHeaderRects[2]); + const UIPoint dropPoint( + documentStack->tabStripLayout.tabHeaderRects[1].x + 4.0f, + sourceCenter.y); + + frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + { MakePointerDown(sourceCenter.x, sourceCenter.y) }); + EXPECT_TRUE(frame.result.consumed); + + frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + { MakePointerMove(dropPoint.x, dropPoint.y) }); + EXPECT_TRUE(frame.result.requestPointerCapture); + EXPECT_TRUE(frame.result.consumed); + + frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + { MakePointerUp(dropPoint.x, dropPoint.y) }); + EXPECT_TRUE(frame.result.releasePointerCapture); + EXPECT_TRUE(frame.result.layoutChanged) + << " status=" << static_cast(frame.result.layoutResult.status) + << " message=" << frame.result.layoutResult.message + << " commandExecuted=" << frame.result.commandExecuted; + + const auto* documentTabs = + FindUIEditorWorkspaceNode(controller.GetWorkspace(), "document-tabs"); + ASSERT_NE(documentTabs, nullptr); + ASSERT_EQ(documentTabs->children.size(), 3u); + EXPECT_EQ(documentTabs->children[0].panel.panelId, "doc-a"); + EXPECT_EQ(documentTabs->children[1].panel.panelId, "doc-c"); + EXPECT_EQ(documentTabs->children[2].panel.panelId, "doc-b"); +} + +TEST(UIEditorDockHostInteractionTest, DraggingRightmostTabToFrontCommitsSameStackReorder) { + auto controller = + BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildThreeDocumentWorkspace()); + UIEditorDockHostInteractionState state = {}; + + auto frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + {}); + const auto* documentStack = FindTabStackByNodeId(frame.layout, "document-tabs"); + ASSERT_NE(documentStack, nullptr); + const UIPoint sourceCenter = RectCenter(documentStack->tabStripLayout.tabHeaderRects[2]); + const UIPoint dropPoint( + documentStack->tabStripLayout.tabHeaderRects[0].x + 4.0f, + sourceCenter.y); + + frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + { MakePointerDown(sourceCenter.x, sourceCenter.y) }); + EXPECT_TRUE(frame.result.consumed); + + frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + { MakePointerMove(dropPoint.x, dropPoint.y) }); + EXPECT_TRUE(frame.result.requestPointerCapture); + EXPECT_TRUE(frame.result.consumed); + + frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + { MakePointerUp(dropPoint.x, dropPoint.y) }); + EXPECT_TRUE(frame.result.releasePointerCapture); + EXPECT_TRUE(frame.result.layoutChanged); + + const auto* documentTabs = + FindUIEditorWorkspaceNode(controller.GetWorkspace(), "document-tabs"); + ASSERT_NE(documentTabs, nullptr); + ASSERT_EQ(documentTabs->children.size(), 3u); + EXPECT_EQ(documentTabs->children[0].panel.panelId, "doc-c"); + EXPECT_EQ(documentTabs->children[1].panel.panelId, "doc-a"); + EXPECT_EQ(documentTabs->children[2].panel.panelId, "doc-b"); +} + +TEST(UIEditorDockHostInteractionTest, SameStackReorderCommitsFromLastVisiblePreviewEvenIfReleaseDropsBelowHeader) { + auto controller = + BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace()); + UIEditorDockHostInteractionState state = {}; + + auto frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + {}); + const auto* documentStack = FindTabStackByNodeId(frame.layout, "document-tabs"); + ASSERT_NE(documentStack, nullptr); + const UIPoint sourceCenter = RectCenter(documentStack->tabStripLayout.tabHeaderRects[0]); + const UIPoint previewPoint( + documentStack->tabStripLayout.headerRect.x + + documentStack->tabStripLayout.headerRect.width - 2.0f, + sourceCenter.y); + const UIPoint releasePoint( + previewPoint.x, + documentStack->tabStripLayout.headerRect.y + + documentStack->tabStripLayout.headerRect.height + 8.0f); + + frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + { MakePointerDown(sourceCenter.x, sourceCenter.y) }); + EXPECT_TRUE(frame.result.consumed); + + frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + { MakePointerMove(previewPoint.x, previewPoint.y) }); + EXPECT_TRUE(frame.result.requestPointerCapture); + EXPECT_TRUE(frame.result.consumed); + const auto tabStripStateIt = std::find_if( + state.dockHostState.tabStripStates.begin(), + state.dockHostState.tabStripStates.end(), + [](const auto& tabStripState) { + return tabStripState.nodeId == "document-tabs"; + }); + ASSERT_NE(tabStripStateIt, state.dockHostState.tabStripStates.end()); + ASSERT_NE( + tabStripStateIt->state.reorder.previewInsertionIndex, + XCEngine::UI::Editor::Widgets::UIEditorTabStripInvalidIndex); + + frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + { MakePointerUp(releasePoint.x, releasePoint.y) }); + EXPECT_TRUE(frame.result.releasePointerCapture); + EXPECT_TRUE(frame.result.layoutChanged); + + const auto* documentTabs = + FindUIEditorWorkspaceNode(controller.GetWorkspace(), "document-tabs"); + ASSERT_NE(documentTabs, nullptr); + ASSERT_EQ(documentTabs->children.size(), 2u); + EXPECT_EQ(documentTabs->children[0].panel.panelId, "doc-b"); + EXPECT_EQ(documentTabs->children[1].panel.panelId, "doc-a"); +} + +TEST(UIEditorDockHostInteractionTest, ReleasingDraggedTabOutsideHeaderCancelsWithoutChangingOrder) { + auto controller = + BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace()); + UIEditorDockHostInteractionState state = {}; + + auto frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + {}); + const auto* documentStack = FindTabStackByNodeId(frame.layout, "document-tabs"); + ASSERT_NE(documentStack, nullptr); + const UIPoint sourceCenter = RectCenter(documentStack->tabStripLayout.tabHeaderRects[0]); + const UIPoint activatePoint( + documentStack->tabStripLayout.headerRect.x + + documentStack->tabStripLayout.headerRect.width - 2.0f, + sourceCenter.y); + const UIPoint cancelPoint = RectCenter(documentStack->contentFrameLayout.bodyRect); + + frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + { + MakePointerDown(sourceCenter.x, sourceCenter.y), + MakePointerMove(activatePoint.x, activatePoint.y), + MakePointerMove(cancelPoint.x, cancelPoint.y), + MakePointerUp(cancelPoint.x, cancelPoint.y) + }); + EXPECT_TRUE(frame.result.releasePointerCapture); + EXPECT_FALSE(frame.result.layoutChanged); + + const auto* documentTabs = + FindUIEditorWorkspaceNode(controller.GetWorkspace(), "document-tabs"); + ASSERT_NE(documentTabs, nullptr); + ASSERT_EQ(documentTabs->children.size(), 2u); + EXPECT_EQ(documentTabs->children[0].panel.panelId, "doc-a"); + EXPECT_EQ(documentTabs->children[1].panel.panelId, "doc-b"); +} + +TEST(UIEditorDockHostInteractionTest, DraggingTabOntoAnotherStackHeaderMergesIntoTargetStack) { + auto controller = + BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace()); + UIEditorDockHostInteractionState state = {}; + + auto frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + {}); + const auto* documentStack = FindTabStackByNodeId(frame.layout, "document-tabs"); + const auto* detailsStack = FindTabStackByNodeId(frame.layout, "details-node"); + ASSERT_NE(documentStack, nullptr); + ASSERT_NE(detailsStack, nullptr); + const UIPoint sourceCenter = + RectCenter(documentStack->tabStripLayout.tabHeaderRects[0]); + const UIPoint targetHeaderCenter = + RectCenter(detailsStack->tabStripLayout.headerRect); + + frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + { MakePointerDown(sourceCenter.x, sourceCenter.y) }); + EXPECT_TRUE(frame.result.consumed); + + frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + { MakePointerMove(targetHeaderCenter.x, targetHeaderCenter.y) }); + EXPECT_TRUE(frame.result.requestPointerCapture); + EXPECT_TRUE(state.dockHostState.dropPreview.visible); + EXPECT_EQ(state.dockHostState.dropPreview.targetNodeId, "details-node"); + + frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + { MakePointerUp(targetHeaderCenter.x, targetHeaderCenter.y) }); + EXPECT_TRUE(frame.result.releasePointerCapture); + EXPECT_TRUE(frame.result.layoutChanged); + + const auto* documentTabs = + FindUIEditorWorkspaceNode(controller.GetWorkspace(), "document-tabs"); + const auto* detailsTabs = + FindUIEditorWorkspaceNode(controller.GetWorkspace(), "details-node"); + ASSERT_NE(documentTabs, nullptr); + ASSERT_NE(detailsTabs, nullptr); + ASSERT_EQ(documentTabs->children.size(), 1u); + ASSERT_EQ(detailsTabs->children.size(), 2u); + EXPECT_EQ(documentTabs->children[0].panel.panelId, "doc-b"); + EXPECT_EQ(detailsTabs->children[1].panel.panelId, "doc-a"); + EXPECT_EQ(controller.GetWorkspace().activePanelId, "doc-a"); +} + +TEST(UIEditorDockHostInteractionTest, DraggingTabOntoPanelBodyEdgeCreatesDockSplit) { + auto controller = + BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace()); + UIEditorDockHostInteractionState state = {}; + + auto frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + {}); + const auto* documentStack = FindTabStackByNodeId(frame.layout, "document-tabs"); + const auto* detailsStack = FindTabStackByNodeId(frame.layout, "details-node"); + ASSERT_NE(documentStack, nullptr); + ASSERT_NE(detailsStack, nullptr); + const UIPoint sourceCenter = + RectCenter(documentStack->tabStripLayout.tabHeaderRects[0]); + const UIPoint targetBottomCenter( + detailsStack->contentFrameLayout.bodyRect.x + + detailsStack->contentFrameLayout.bodyRect.width * 0.5f, + detailsStack->contentFrameLayout.bodyRect.y + + detailsStack->contentFrameLayout.bodyRect.height - 4.0f); + + frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + { MakePointerDown(sourceCenter.x, sourceCenter.y) }); + EXPECT_TRUE(frame.result.consumed); + + frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + { MakePointerMove(targetBottomCenter.x, targetBottomCenter.y) }); + EXPECT_TRUE(frame.result.requestPointerCapture); + EXPECT_TRUE(state.dockHostState.dropPreview.visible); + EXPECT_EQ( + state.dockHostState.dropPreview.placement, + XCEngine::UI::Editor::UIEditorWorkspaceDockPlacement::Bottom); + + frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + { MakePointerUp(targetBottomCenter.x, targetBottomCenter.y) }); + EXPECT_TRUE(frame.result.releasePointerCapture); + EXPECT_TRUE(frame.result.layoutChanged); + EXPECT_EQ(controller.GetWorkspace().activePanelId, "doc-a"); + + bool foundDockedTab = false; + for (const std::string candidate : { "details-node__dock_doc-a_stack", "details-node__dock_doc-a_stack-1" }) { + const auto* docked = + FindUIEditorWorkspaceNode(controller.GetWorkspace(), candidate); + if (docked != nullptr && + docked->children.size() == 1u && + docked->children[0].panel.panelId == "doc-a") { + foundDockedTab = true; + break; + } + } + EXPECT_TRUE(foundDockedTab); +} + +TEST(UIEditorDockHostInteractionTest, FocusLostWhileDraggingTabCancelsAndReleasesCapture) { + auto controller = + BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace()); + UIEditorDockHostInteractionState state = {}; + + auto frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + {}); + const auto* documentStack = FindTabStackByNodeId(frame.layout, "document-tabs"); + ASSERT_NE(documentStack, nullptr); + const UIPoint sourceCenter = RectCenter(documentStack->tabStripLayout.tabHeaderRects[0]); + const UIPoint activatePoint( + documentStack->tabStripLayout.headerRect.x + + documentStack->tabStripLayout.headerRect.width - 2.0f, + sourceCenter.y); + + frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + { + MakePointerDown(sourceCenter.x, sourceCenter.y), + MakePointerMove(activatePoint.x, activatePoint.y) + }); + ASSERT_TRUE(frame.result.requestPointerCapture); + + frame = UpdateUIEditorDockHostInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + { MakeFocusLost() }); + EXPECT_TRUE(frame.result.releasePointerCapture); + EXPECT_FALSE(frame.result.layoutChanged); + + const auto* documentTabs = + FindUIEditorWorkspaceNode(controller.GetWorkspace(), "document-tabs"); + ASSERT_NE(documentTabs, nullptr); + ASSERT_EQ(documentTabs->children.size(), 2u); + EXPECT_EQ(documentTabs->children[0].panel.panelId, "doc-a"); + EXPECT_EQ(documentTabs->children[1].panel.panelId, "doc-b"); } diff --git a/tests/UI/Editor/unit/test_ui_editor_menu_bar.cpp b/tests/UI/Editor/unit/test_ui_editor_menu_bar.cpp index b04fee3d..0c16fef3 100644 --- a/tests/UI/Editor/unit/test_ui_editor_menu_bar.cpp +++ b/tests/UI/Editor/unit/test_ui_editor_menu_bar.cpp @@ -96,12 +96,11 @@ TEST(UIEditorMenuBarTest, BackgroundAndForegroundEmitStableCommands) { UIDrawList background("MenuBarBackground"); AppendUIEditorMenuBarBackground(background, layout, items, state); - ASSERT_EQ(background.GetCommandCount(), 6u); + ASSERT_EQ(background.GetCommandCount(), 3u); const auto& backgroundCommands = background.GetCommands(); EXPECT_EQ(backgroundCommands[0].type, UIDrawCommandType::FilledRect); - EXPECT_EQ(backgroundCommands[1].type, UIDrawCommandType::RectOutline); + EXPECT_EQ(backgroundCommands[1].type, UIDrawCommandType::FilledRect); EXPECT_EQ(backgroundCommands[2].type, UIDrawCommandType::FilledRect); - EXPECT_EQ(backgroundCommands[5].type, UIDrawCommandType::RectOutline); UIDrawList foreground("MenuBarForeground"); AppendUIEditorMenuBarForeground(foreground, layout, items, state); diff --git a/tests/UI/Editor/unit/test_ui_editor_shell_interaction.cpp b/tests/UI/Editor/unit/test_ui_editor_shell_interaction.cpp index 30b84284..eacc709c 100644 --- a/tests/UI/Editor/unit/test_ui_editor_shell_interaction.cpp +++ b/tests/UI/Editor/unit/test_ui_editor_shell_interaction.cpp @@ -41,6 +41,8 @@ using XCEngine::UI::Editor::UIEditorShellInteractionModel; using XCEngine::UI::Editor::UIEditorShellInteractionPopupItemRequest; using XCEngine::UI::Editor::UIEditorShellInteractionServices; using XCEngine::UI::Editor::UIEditorShellInteractionState; +using XCEngine::UI::Editor::UIEditorTextMeasureRequest; +using XCEngine::UI::Editor::UIEditorTextMeasurer; using XCEngine::UI::Editor::UIEditorWorkspaceController; using XCEngine::UI::Editor::UIEditorWorkspaceCommandKind; using XCEngine::UI::Editor::UIEditorWorkspaceCommandStatus; @@ -52,6 +54,13 @@ using XCEngine::UI::Editor::Widgets::UIEditorMenuPopupInvalidIndex; using XCEngine::UI::Editor::Widgets::UIEditorStatusBarSlot; using XCEngine::UI::Widgets::UIPopupDismissReason; +class StubTextMeasurer final : public UIEditorTextMeasurer { +public: + float MeasureTextWidth(const UIEditorTextMeasureRequest& request) const override { + return static_cast(request.text.size()) * (request.fontSize * 0.6f); + } +}; + UIEditorPanelRegistry BuildPanelRegistry() { UIEditorPanelRegistry registry = {}; registry.panels = { @@ -219,6 +228,76 @@ UIEditorWorkspaceController BuildController() { return BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace()); } +UIPoint RectCenter(const UIRect& rect); +UIInputEvent MakeLeftPointerDown(const UIPoint& position); + +TEST(UIEditorShellInteractionTest, RequestUsesTextMeasurerForMenuBarWidths) { + UIEditorWorkspaceController controller = BuildController(); + const UIEditorShellInteractionModel model = BuildInteractionModel(); + StubTextMeasurer textMeasurer = {}; + UIEditorShellInteractionServices services = {}; + services.textMeasurer = &textMeasurer; + + const auto request = ResolveUIEditorShellInteractionRequest( + UIRect(0.0f, 0.0f, 480.0f, 320.0f), + controller, + model, + {}, + {}, + services); + + ASSERT_EQ(request.menuBarItems.size(), 2u); + EXPECT_FLOAT_EQ( + request.menuBarItems[0].desiredLabelWidth, + textMeasurer.MeasureTextWidth(UIEditorTextMeasureRequest { "File", 13.0f })); + EXPECT_FLOAT_EQ( + request.menuBarItems[1].desiredLabelWidth, + textMeasurer.MeasureTextWidth(UIEditorTextMeasureRequest { "Window", 13.0f })); + EXPECT_GT(request.menuButtons[1].rect.width, request.menuButtons[0].rect.width); +} + +TEST(UIEditorShellInteractionTest, PopupWidgetWidthsUseTextMeasurerForLabelAndShortcut) { + UIEditorWorkspaceController controller = BuildController(); + const UIEditorShellInteractionModel model = BuildInteractionModel(); + StubTextMeasurer textMeasurer = {}; + UIEditorShellInteractionServices services = {}; + services.textMeasurer = &textMeasurer; + + UIEditorShellInteractionState state = {}; + const auto request = ResolveUIEditorShellInteractionRequest( + UIRect(0.0f, 0.0f, 480.0f, 320.0f), + controller, + model, + state, + {}, + services); + ASSERT_FALSE(request.menuButtons.empty()); + + UIInputEvent openMenuEvent = {}; + openMenuEvent.type = UIInputEventType::PointerButtonDown; + openMenuEvent.pointerButton = UIPointerButton::Left; + openMenuEvent.position = UIPoint( + request.menuButtons.front().rect.x + request.menuButtons.front().rect.width * 0.5f, + request.menuButtons.front().rect.y + request.menuButtons.front().rect.height * 0.5f); + + const auto frame = UpdateUIEditorShellInteraction( + state, + controller, + UIRect(0.0f, 0.0f, 480.0f, 320.0f), + model, + std::vector { openMenuEvent }, + services); + + ASSERT_EQ(frame.request.popupRequests.size(), 1u); + ASSERT_GE(frame.request.popupRequests.front().widgetItems.size(), 2u); + EXPECT_FLOAT_EQ( + frame.request.popupRequests.front().widgetItems[0].desiredLabelWidth, + textMeasurer.MeasureTextWidth(UIEditorTextMeasureRequest { "Workspace Tools", 13.0f })); + EXPECT_FLOAT_EQ( + frame.request.popupRequests.front().widgetItems[1].desiredShortcutWidth, + textMeasurer.MeasureTextWidth(UIEditorTextMeasureRequest { "Ctrl+W", 13.0f })); +} + const UIEditorShellInteractionMenuButtonRequest* FindMenuButton( const UIEditorShellInteractionFrame& frame, std::string_view menuId) { diff --git a/tests/UI/Editor/unit/test_ui_editor_tab_strip.cpp b/tests/UI/Editor/unit/test_ui_editor_tab_strip.cpp index fe5fb8d8..425aa028 100644 --- a/tests/UI/Editor/unit/test_ui_editor_tab_strip.cpp +++ b/tests/UI/Editor/unit/test_ui_editor_tab_strip.cpp @@ -15,7 +15,6 @@ using XCEngine::UI::Editor::Widgets::BuildUIEditorTabStripLayout; using XCEngine::UI::Editor::Widgets::HitTestUIEditorTabStrip; using XCEngine::UI::Editor::Widgets::ResolveUIEditorTabStripDesiredHeaderLabelWidth; using XCEngine::UI::Editor::Widgets::ResolveUIEditorTabStripSelectedIndex; -using XCEngine::UI::Editor::Widgets::ResolveUIEditorTabStripSelectedIndexAfterClose; using XCEngine::UI::Editor::Widgets::UIEditorTabStripHitTargetKind; using XCEngine::UI::Editor::Widgets::UIEditorTabStripInvalidIndex; using XCEngine::UI::Editor::Widgets::UIEditorTabStripItem; @@ -23,7 +22,7 @@ using XCEngine::UI::Editor::Widgets::UIEditorTabStripLayout; using XCEngine::UI::Editor::Widgets::UIEditorTabStripMetrics; using XCEngine::UI::Editor::Widgets::UIEditorTabStripState; -TEST(UIEditorTabStripTest, DesiredHeaderWidthReservesCloseButtonBudget) { +TEST(UIEditorTabStripTest, DesiredHeaderWidthUsesLabelWidthAndLeftInsetOnly) { UIEditorTabStripMetrics metrics = {}; metrics.layoutMetrics.tabHorizontalPadding = 10.0f; metrics.estimatedGlyphWidth = 8.0f; @@ -32,14 +31,14 @@ TEST(UIEditorTabStripTest, DesiredHeaderWidthReservesCloseButtonBudget) { metrics.closeInsetRight = 14.0f; metrics.labelInsetX = 12.0f; - const float closableWidth = ResolveUIEditorTabStripDesiredHeaderLabelWidth( + const float measuredWidth = ResolveUIEditorTabStripDesiredHeaderLabelWidth( UIEditorTabStripItem{ "doc-a", "ABCD", true, 0.0f }, metrics); const float fixedWidth = ResolveUIEditorTabStripDesiredHeaderLabelWidth( UIEditorTabStripItem{ "doc-b", "Ignored", false, 42.0f }, metrics); - EXPECT_FLOAT_EQ(closableWidth, 58.0f); + EXPECT_FLOAT_EQ(measuredWidth, 34.0f); EXPECT_FLOAT_EQ(fixedWidth, 44.0f); } @@ -58,16 +57,7 @@ TEST(UIEditorTabStripTest, SelectedIndexResolvesByTabIdAndFallsBackToValidRange) UIEditorTabStripInvalidIndex); } -TEST(UIEditorTabStripTest, ClosingTabsResolvesSelectionFallbackFromClosedIndex) { - EXPECT_EQ(ResolveUIEditorTabStripSelectedIndexAfterClose(1u, 1u, 3u), 1u); - EXPECT_EQ(ResolveUIEditorTabStripSelectedIndexAfterClose(2u, 2u, 3u), 1u); - EXPECT_EQ(ResolveUIEditorTabStripSelectedIndexAfterClose(2u, 0u, 3u), 1u); - EXPECT_EQ( - ResolveUIEditorTabStripSelectedIndexAfterClose(0u, 0u, 1u), - UIEditorTabStripInvalidIndex); -} - -TEST(UIEditorTabStripTest, LayoutUsesCoreTabArrangementAndBuildsCloseRects) { +TEST(UIEditorTabStripTest, LayoutUsesCoreTabArrangementWithoutCloseButtons) { UIEditorTabStripMetrics metrics = {}; metrics.layoutMetrics.headerHeight = 30.0f; metrics.layoutMetrics.tabMinWidth = 80.0f; @@ -101,20 +91,16 @@ TEST(UIEditorTabStripTest, LayoutUsesCoreTabArrangementAndBuildsCloseRects) { ASSERT_EQ(layout.tabHeaderRects.size(), 2u); EXPECT_FLOAT_EQ(layout.tabHeaderRects[0].x, 10.0f); - EXPECT_FLOAT_EQ(layout.tabHeaderRects[0].width, 90.0f); - EXPECT_FLOAT_EQ(layout.tabHeaderRects[1].x, 104.0f); + EXPECT_FLOAT_EQ(layout.tabHeaderRects[0].width, 80.0f); + EXPECT_FLOAT_EQ(layout.tabHeaderRects[1].x, 94.0f); EXPECT_FLOAT_EQ(layout.tabHeaderRects[1].width, 80.0f); ASSERT_EQ(layout.closeButtonRects.size(), 2u); - EXPECT_TRUE(layout.showCloseButtons[0]); + EXPECT_FALSE(layout.showCloseButtons[0]); EXPECT_FALSE(layout.showCloseButtons[1]); - EXPECT_FLOAT_EQ(layout.closeButtonRects[0].x, 76.0f); - EXPECT_FLOAT_EQ(layout.closeButtonRects[0].y, 29.0f); - EXPECT_FLOAT_EQ(layout.closeButtonRects[0].width, 12.0f); - EXPECT_FLOAT_EQ(layout.closeButtonRects[0].height, 12.0f); } -TEST(UIEditorTabStripTest, HitTestPrioritizesCloseButtonThenTabThenContent) { +TEST(UIEditorTabStripTest, HitTestPrioritizesTabThenHeaderThenContent) { const std::vector items = { { "doc-a", "Document A", true, 48.0f }, { "doc-b", "Document B", false, 40.0f } @@ -125,14 +111,23 @@ TEST(UIEditorTabStripTest, HitTestPrioritizesCloseButtonThenTabThenContent) { const UIEditorTabStripLayout layout = BuildUIEditorTabStripLayout(UIRect(10.0f, 20.0f, 260.0f, 180.0f), items, state); - const auto closeHit = HitTestUIEditorTabStrip(layout, state, UIPoint(85.0f, 34.0f)); - EXPECT_EQ(closeHit.kind, UIEditorTabStripHitTargetKind::CloseButton); - EXPECT_EQ(closeHit.index, 0u); + const auto rightSideTabHit = HitTestUIEditorTabStrip( + layout, + state, + UIPoint( + layout.tabHeaderRects[0].x + layout.tabHeaderRects[0].width - 2.0f, + layout.tabHeaderRects[0].y + layout.tabHeaderRects[0].height * 0.5f)); + EXPECT_EQ(rightSideTabHit.kind, UIEditorTabStripHitTargetKind::Tab); + EXPECT_EQ(rightSideTabHit.index, 0u); const auto tabHit = HitTestUIEditorTabStrip(layout, state, UIPoint(40.0f, 34.0f)); EXPECT_EQ(tabHit.kind, UIEditorTabStripHitTargetKind::Tab); EXPECT_EQ(tabHit.index, 0u); + const auto headerHit = HitTestUIEditorTabStrip(layout, state, UIPoint(200.0f, 34.0f)); + EXPECT_EQ(headerHit.kind, UIEditorTabStripHitTargetKind::HeaderBackground); + EXPECT_EQ(headerHit.index, UIEditorTabStripInvalidIndex); + const auto contentHit = HitTestUIEditorTabStrip(layout, state, UIPoint(40.0f, 70.0f)); EXPECT_EQ(contentHit.kind, UIEditorTabStripHitTargetKind::Content); EXPECT_EQ(contentHit.index, UIEditorTabStripInvalidIndex); @@ -147,7 +142,6 @@ TEST(UIEditorTabStripTest, BackgroundAndForegroundEmitStableChromeCommands) { UIEditorTabStripState state = {}; state.selectedIndex = 0u; state.hoveredIndex = 1u; - state.closeHoveredIndex = 0u; state.focused = true; const UIEditorTabStripLayout layout = @@ -155,24 +149,28 @@ TEST(UIEditorTabStripTest, BackgroundAndForegroundEmitStableChromeCommands) { UIDrawList background("TabStripBackground"); AppendUIEditorTabStripBackground(background, layout, state); - ASSERT_EQ(background.GetCommandCount(), 8u); + ASSERT_EQ(background.GetCommandCount(), 9u); const auto& backgroundCommands = background.GetCommands(); EXPECT_EQ(backgroundCommands[0].type, UIDrawCommandType::FilledRect); EXPECT_EQ(backgroundCommands[3].type, UIDrawCommandType::RectOutline); EXPECT_EQ(backgroundCommands[4].type, UIDrawCommandType::FilledRect); - EXPECT_EQ(backgroundCommands[7].type, UIDrawCommandType::RectOutline); + EXPECT_EQ(backgroundCommands[5].type, UIDrawCommandType::RectOutline); + EXPECT_EQ(backgroundCommands[6].type, UIDrawCommandType::FilledRect); + EXPECT_EQ(backgroundCommands[7].type, UIDrawCommandType::FilledRect); + EXPECT_EQ(backgroundCommands[8].type, UIDrawCommandType::RectOutline); UIDrawList foreground("TabStripForeground"); AppendUIEditorTabStripForeground(foreground, layout, items, state); - ASSERT_EQ(foreground.GetCommandCount(), 9u); + ASSERT_EQ(foreground.GetCommandCount(), 8u); const auto& foregroundCommands = foreground.GetCommands(); - EXPECT_EQ(foregroundCommands[0].type, UIDrawCommandType::PushClipRect); - EXPECT_EQ(foregroundCommands[1].type, UIDrawCommandType::Text); - EXPECT_EQ(foregroundCommands[1].text, "Document A"); - EXPECT_EQ(foregroundCommands[5].type, UIDrawCommandType::Text); - EXPECT_EQ(foregroundCommands[5].text, "X"); - EXPECT_EQ(foregroundCommands[7].type, UIDrawCommandType::Text); - EXPECT_EQ(foregroundCommands[7].text, "Document B"); + EXPECT_EQ(foregroundCommands[0].type, UIDrawCommandType::FilledRect); + EXPECT_EQ(foregroundCommands[1].type, UIDrawCommandType::FilledRect); + EXPECT_EQ(foregroundCommands[2].type, UIDrawCommandType::PushClipRect); + EXPECT_EQ(foregroundCommands[3].type, UIDrawCommandType::Text); + EXPECT_EQ(foregroundCommands[3].text, "Document A"); + EXPECT_EQ(foregroundCommands[5].type, UIDrawCommandType::PushClipRect); + EXPECT_EQ(foregroundCommands[6].type, UIDrawCommandType::Text); + EXPECT_EQ(foregroundCommands[6].text, "Document B"); } } // namespace diff --git a/tests/UI/Editor/unit/test_ui_editor_tab_strip_interaction.cpp b/tests/UI/Editor/unit/test_ui_editor_tab_strip_interaction.cpp index a80fd499..2e8d97d3 100644 --- a/tests/UI/Editor/unit/test_ui_editor_tab_strip_interaction.cpp +++ b/tests/UI/Editor/unit/test_ui_editor_tab_strip_interaction.cpp @@ -15,6 +15,7 @@ using XCEngine::UI::UIRect; using XCEngine::UI::Editor::UIEditorTabStripInteractionState; using XCEngine::UI::Editor::UpdateUIEditorTabStripInteraction; using XCEngine::UI::Editor::Widgets::UIEditorTabStripHitTargetKind; +using XCEngine::UI::Editor::Widgets::UIEditorTabStripInvalidIndex; using XCEngine::UI::Editor::Widgets::UIEditorTabStripItem; std::vector BuildTabItems() { @@ -73,7 +74,7 @@ UIPoint RectCenter(const XCEngine::UI::UIRect& rect) { } // namespace -TEST(UIEditorTabStripInteractionTest, PointerMoveUpdatesHoveredTabAndCloseState) { +TEST(UIEditorTabStripInteractionTest, PointerMoveUpdatesHoveredTabOnly) { const auto items = BuildTabItems(); std::string selectedTabId = "doc-a"; UIEditorTabStripInteractionState state = {}; @@ -85,7 +86,7 @@ TEST(UIEditorTabStripInteractionTest, PointerMoveUpdatesHoveredTabAndCloseState) items, {}); - auto frame = UpdateUIEditorTabStripInteraction( + const auto frame = UpdateUIEditorTabStripInteraction( state, selectedTabId, UIRect(0.0f, 0.0f, 320.0f, 180.0f), @@ -93,21 +94,11 @@ TEST(UIEditorTabStripInteractionTest, PointerMoveUpdatesHoveredTabAndCloseState) { MakePointerMove( initialFrame.layout.tabHeaderRects[1].x + 12.0f, initialFrame.layout.tabHeaderRects[1].y + 12.0f) }); + EXPECT_EQ(frame.result.hitTarget.kind, UIEditorTabStripHitTargetKind::Tab); EXPECT_EQ(state.tabStripState.hoveredIndex, 1u); - EXPECT_EQ(state.tabStripState.closeHoveredIndex, XCEngine::UI::Editor::Widgets::UIEditorTabStripInvalidIndex); - - frame = UpdateUIEditorTabStripInteraction( - state, - selectedTabId, - UIRect(0.0f, 0.0f, 320.0f, 180.0f), - items, - { MakePointerMove( - initialFrame.layout.closeButtonRects[0].x + 4.0f, - initialFrame.layout.closeButtonRects[0].y + 4.0f) }); - EXPECT_EQ(frame.result.hitTarget.kind, UIEditorTabStripHitTargetKind::CloseButton); - EXPECT_EQ(state.tabStripState.hoveredIndex, 0u); - EXPECT_EQ(state.tabStripState.closeHoveredIndex, 0u); + EXPECT_EQ(state.tabStripState.closeHoveredIndex, UIEditorTabStripInvalidIndex); + EXPECT_FALSE(frame.result.closeRequested); } TEST(UIEditorTabStripInteractionTest, LeftClickTabSelectsAndFocusesStrip) { @@ -141,38 +132,6 @@ TEST(UIEditorTabStripInteractionTest, LeftClickTabSelectsAndFocusesStrip) { EXPECT_TRUE(state.tabStripState.focused); } -TEST(UIEditorTabStripInteractionTest, LeftClickCloseButtonRequestsCloseWithoutChangingSelection) { - const auto items = BuildTabItems(); - std::string selectedTabId = "doc-a"; - UIEditorTabStripInteractionState state = {}; - - const auto initialFrame = UpdateUIEditorTabStripInteraction( - state, - selectedTabId, - UIRect(0.0f, 0.0f, 320.0f, 180.0f), - items, - {}); - const UIPoint closeCenter = RectCenter(initialFrame.layout.closeButtonRects[1]); - - const auto frame = UpdateUIEditorTabStripInteraction( - state, - selectedTabId, - UIRect(0.0f, 0.0f, 320.0f, 180.0f), - items, - { - MakePointerDown(closeCenter.x, closeCenter.y), - MakePointerUp(closeCenter.x, closeCenter.y) - }); - - EXPECT_TRUE(frame.result.consumed); - EXPECT_TRUE(frame.result.closeRequested); - EXPECT_FALSE(frame.result.selectionChanged); - EXPECT_EQ(frame.result.closedTabId, "doc-b"); - EXPECT_EQ(frame.result.closedIndex, 1u); - EXPECT_EQ(selectedTabId, "doc-a"); - EXPECT_TRUE(state.tabStripState.focused); -} - TEST(UIEditorTabStripInteractionTest, KeyboardNavigationMovesSelectionWhenFocused) { const auto items = BuildTabItems(); std::string selectedTabId = "doc-b"; @@ -238,7 +197,377 @@ TEST(UIEditorTabStripInteractionTest, OutsideClickAndFocusLostClearFocusAndHover items, { MakePointerLeave(), MakeFocusLost() }); EXPECT_FALSE(state.tabStripState.focused); - EXPECT_EQ(state.tabStripState.hoveredIndex, XCEngine::UI::Editor::Widgets::UIEditorTabStripInvalidIndex); - EXPECT_EQ(state.tabStripState.closeHoveredIndex, XCEngine::UI::Editor::Widgets::UIEditorTabStripInvalidIndex); + EXPECT_EQ(state.tabStripState.hoveredIndex, UIEditorTabStripInvalidIndex); + EXPECT_EQ(state.tabStripState.closeHoveredIndex, UIEditorTabStripInvalidIndex); EXPECT_FALSE(state.hasPointerPosition); } + +TEST(UIEditorTabStripInteractionTest, DraggingTabRequestsPointerCaptureAndShowsInsertionPreview) { + const auto items = BuildTabItems(); + std::string selectedTabId = "doc-a"; + UIEditorTabStripInteractionState state = {}; + + const auto initialFrame = UpdateUIEditorTabStripInteraction( + state, + selectedTabId, + UIRect(0.0f, 0.0f, 320.0f, 180.0f), + items, + {}); + const UIPoint sourceCenter = RectCenter(initialFrame.layout.tabHeaderRects[0]); + const auto& lastRect = initialFrame.layout.tabHeaderRects.back(); + const UIPoint dragPoint(lastRect.x + lastRect.width - 2.0f, sourceCenter.y); + + const auto frame = UpdateUIEditorTabStripInteraction( + state, + selectedTabId, + UIRect(0.0f, 0.0f, 320.0f, 180.0f), + items, + { + MakePointerDown(sourceCenter.x, sourceCenter.y), + MakePointerMove(dragPoint.x, dragPoint.y) + }); + + EXPECT_TRUE(frame.result.requestPointerCapture); + EXPECT_TRUE(frame.result.dragStarted); + EXPECT_TRUE(frame.result.consumed); + EXPECT_EQ(frame.result.draggedTabId, "doc-a"); + EXPECT_EQ(frame.result.dragSourceIndex, 0u); + EXPECT_EQ(frame.result.dropInsertionIndex, items.size()); + EXPECT_EQ(frame.result.reorderToIndex, 2u); + EXPECT_TRUE(frame.result.reorderPreviewActive); + EXPECT_EQ(frame.result.reorderPreviewIndex, 2u); + EXPECT_TRUE(state.reorderCaptureActive); + EXPECT_EQ(state.reorderSourceIndex, 0u); + EXPECT_EQ(state.reorderPreviewIndex, 2u); + EXPECT_TRUE(frame.layout.insertionPreview.visible); + EXPECT_EQ(frame.layout.insertionPreview.insertionIndex, items.size()); +} + +TEST(UIEditorTabStripInteractionTest, DroppingDraggedTabEmitsSameStackReorderRequest) { + const auto items = BuildTabItems(); + std::string selectedTabId = "doc-a"; + UIEditorTabStripInteractionState state = {}; + + const auto initialFrame = UpdateUIEditorTabStripInteraction( + state, + selectedTabId, + UIRect(0.0f, 0.0f, 320.0f, 180.0f), + items, + {}); + const UIPoint sourceCenter = RectCenter(initialFrame.layout.tabHeaderRects[0]); + const auto& lastRect = initialFrame.layout.tabHeaderRects.back(); + const UIPoint dragPoint(lastRect.x + lastRect.width - 2.0f, sourceCenter.y); + + const auto frame = UpdateUIEditorTabStripInteraction( + state, + selectedTabId, + UIRect(0.0f, 0.0f, 320.0f, 180.0f), + items, + { + MakePointerDown(sourceCenter.x, sourceCenter.y), + MakePointerMove(dragPoint.x, dragPoint.y), + MakePointerUp(dragPoint.x, dragPoint.y) + }); + + EXPECT_TRUE(frame.result.releasePointerCapture); + EXPECT_TRUE(frame.result.dragEnded); + EXPECT_TRUE(frame.result.reorderRequested); + EXPECT_FALSE(frame.result.dragCanceled); + EXPECT_EQ(frame.result.draggedTabId, "doc-a"); + EXPECT_EQ(frame.result.dragSourceIndex, 0u); + EXPECT_EQ(frame.result.dropInsertionIndex, items.size()); + EXPECT_EQ(frame.result.reorderToIndex, 2u); + EXPECT_FALSE(state.reorderCaptureActive); + EXPECT_EQ(state.reorderSourceIndex, UIEditorTabStripInvalidIndex); + EXPECT_EQ(state.reorderPreviewIndex, UIEditorTabStripInvalidIndex); + EXPECT_FALSE(frame.layout.insertionPreview.visible); +} + +TEST(UIEditorTabStripInteractionTest, DroppingRightmostTabToFrontCommitsReorderRequest) { + const auto items = BuildTabItems(); + std::string selectedTabId = "doc-c"; + UIEditorTabStripInteractionState state = {}; + + const auto initialFrame = UpdateUIEditorTabStripInteraction( + state, + selectedTabId, + UIRect(0.0f, 0.0f, 320.0f, 180.0f), + items, + {}); + const UIPoint sourceCenter = RectCenter(initialFrame.layout.tabHeaderRects[2]); + const UIPoint dropPoint( + initialFrame.layout.tabHeaderRects[0].x + 4.0f, + sourceCenter.y); + + const auto frame = UpdateUIEditorTabStripInteraction( + state, + selectedTabId, + UIRect(0.0f, 0.0f, 320.0f, 180.0f), + items, + { + MakePointerDown(sourceCenter.x, sourceCenter.y), + MakePointerMove(dropPoint.x, dropPoint.y), + MakePointerUp(dropPoint.x, dropPoint.y) + }); + + EXPECT_TRUE(frame.result.releasePointerCapture); + EXPECT_TRUE(frame.result.dragEnded); + EXPECT_TRUE(frame.result.reorderRequested); + EXPECT_FALSE(frame.result.dragCanceled); + EXPECT_EQ(frame.result.draggedTabId, "doc-c"); + EXPECT_EQ(frame.result.dragSourceIndex, 2u); + EXPECT_EQ(frame.result.dropInsertionIndex, 0u); + EXPECT_EQ(frame.result.reorderToIndex, 0u); +} + +TEST(UIEditorTabStripInteractionTest, DroppingRightmostTabIntoMiddleCommitsReorderRequest) { + const auto items = BuildTabItems(); + std::string selectedTabId = "doc-c"; + UIEditorTabStripInteractionState state = {}; + + const auto initialFrame = UpdateUIEditorTabStripInteraction( + state, + selectedTabId, + UIRect(0.0f, 0.0f, 320.0f, 180.0f), + items, + {}); + const UIPoint sourceCenter = RectCenter(initialFrame.layout.tabHeaderRects[2]); + const UIPoint dropPoint( + initialFrame.layout.tabHeaderRects[1].x + 4.0f, + sourceCenter.y); + + const auto frame = UpdateUIEditorTabStripInteraction( + state, + selectedTabId, + UIRect(0.0f, 0.0f, 320.0f, 180.0f), + items, + { + MakePointerDown(sourceCenter.x, sourceCenter.y), + MakePointerMove(dropPoint.x, dropPoint.y), + MakePointerUp(dropPoint.x, dropPoint.y) + }); + + EXPECT_TRUE(frame.result.releasePointerCapture); + EXPECT_TRUE(frame.result.dragEnded); + EXPECT_TRUE(frame.result.reorderRequested); + EXPECT_FALSE(frame.result.dragCanceled); + EXPECT_EQ(frame.result.draggedTabId, "doc-c"); + EXPECT_EQ(frame.result.dragSourceIndex, 2u); + EXPECT_EQ(frame.result.dropInsertionIndex, 1u); + EXPECT_EQ(frame.result.reorderToIndex, 1u); +} + +TEST(UIEditorTabStripInteractionTest, SeparateFrameDropOfRightmostTabIntoMiddleStillCommitsReorderRequest) { + const auto items = BuildTabItems(); + std::string selectedTabId = "doc-c"; + UIEditorTabStripInteractionState state = {}; + + const auto initialFrame = UpdateUIEditorTabStripInteraction( + state, + selectedTabId, + UIRect(0.0f, 0.0f, 320.0f, 180.0f), + items, + {}); + const UIPoint sourceCenter = RectCenter(initialFrame.layout.tabHeaderRects[2]); + const UIPoint dropPoint( + initialFrame.layout.tabHeaderRects[1].x + 4.0f, + sourceCenter.y); + + auto frame = UpdateUIEditorTabStripInteraction( + state, + selectedTabId, + UIRect(0.0f, 0.0f, 320.0f, 180.0f), + items, + { + MakePointerDown(sourceCenter.x, sourceCenter.y), + MakePointerMove(dropPoint.x, dropPoint.y) + }); + ASSERT_TRUE(frame.result.requestPointerCapture); + ASSERT_TRUE(frame.result.reorderPreviewActive); + ASSERT_TRUE(state.reorderDragState.active); + ASSERT_FALSE(state.reorderDragState.targetId.empty()); + + frame = UpdateUIEditorTabStripInteraction( + state, + selectedTabId, + UIRect(0.0f, 0.0f, 320.0f, 180.0f), + items, + { MakePointerUp(dropPoint.x, dropPoint.y) }); + + EXPECT_TRUE(frame.result.releasePointerCapture); + EXPECT_TRUE(frame.result.dragEnded); + EXPECT_TRUE(frame.result.reorderRequested); + EXPECT_FALSE(frame.result.dragCanceled); + EXPECT_EQ(frame.result.draggedTabId, "doc-c"); + EXPECT_EQ(frame.result.dragSourceIndex, 2u); + EXPECT_EQ(frame.result.dropInsertionIndex, 1u); + EXPECT_EQ(frame.result.reorderToIndex, 1u); +} + +TEST(UIEditorTabStripInteractionTest, ReleasingOutsideHeaderStillCommitsLastVisibleReorderPreview) { + const auto items = BuildTabItems(); + std::string selectedTabId = "doc-a"; + UIEditorTabStripInteractionState state = {}; + + const auto initialFrame = UpdateUIEditorTabStripInteraction( + state, + selectedTabId, + UIRect(0.0f, 0.0f, 320.0f, 180.0f), + items, + {}); + const UIPoint sourceCenter = RectCenter(initialFrame.layout.tabHeaderRects[0]); + const auto& lastRect = initialFrame.layout.tabHeaderRects.back(); + const UIPoint dragPoint(lastRect.x + lastRect.width - 2.0f, sourceCenter.y); + const UIPoint releasePoint( + dragPoint.x, + initialFrame.layout.headerRect.y + initialFrame.layout.headerRect.height + 8.0f); + + auto frame = UpdateUIEditorTabStripInteraction( + state, + selectedTabId, + UIRect(0.0f, 0.0f, 320.0f, 180.0f), + items, + { + MakePointerDown(sourceCenter.x, sourceCenter.y), + MakePointerMove(dragPoint.x, dragPoint.y) + }); + ASSERT_TRUE(frame.result.reorderPreviewActive); + ASSERT_EQ(state.tabStripState.reorder.previewInsertionIndex, items.size()); + + frame = UpdateUIEditorTabStripInteraction( + state, + selectedTabId, + UIRect(0.0f, 0.0f, 320.0f, 180.0f), + items, + { MakePointerUp(releasePoint.x, releasePoint.y) }); + + EXPECT_TRUE(frame.result.releasePointerCapture); + EXPECT_TRUE(frame.result.dragEnded); + EXPECT_TRUE(frame.result.reorderRequested); + EXPECT_FALSE(frame.result.dragCanceled); + EXPECT_EQ(frame.result.draggedTabId, "doc-a"); + EXPECT_EQ(frame.result.dragSourceIndex, 0u); + EXPECT_EQ(frame.result.dropInsertionIndex, items.size()); + EXPECT_EQ(frame.result.reorderToIndex, 2u); +} + +TEST(UIEditorTabStripInteractionTest, EscapeCancelsActiveTabDragAndReleasesCapture) { + const auto items = BuildTabItems(); + std::string selectedTabId = "doc-a"; + UIEditorTabStripInteractionState state = {}; + + const auto initialFrame = UpdateUIEditorTabStripInteraction( + state, + selectedTabId, + UIRect(0.0f, 0.0f, 320.0f, 180.0f), + items, + {}); + const UIPoint sourceCenter = RectCenter(initialFrame.layout.tabHeaderRects[1]); + const UIPoint dragPoint( + initialFrame.layout.tabHeaderRects[2].x + 8.0f, + sourceCenter.y); + + auto frame = UpdateUIEditorTabStripInteraction( + state, + selectedTabId, + UIRect(0.0f, 0.0f, 320.0f, 180.0f), + items, + { + MakePointerDown(sourceCenter.x, sourceCenter.y), + MakePointerMove(dragPoint.x, dragPoint.y) + }); + ASSERT_TRUE(frame.result.dragStarted); + ASSERT_TRUE(state.reorderCaptureActive); + + frame = UpdateUIEditorTabStripInteraction( + state, + selectedTabId, + UIRect(0.0f, 0.0f, 320.0f, 180.0f), + items, + { MakeKeyDown(KeyCode::Escape) }); + + EXPECT_TRUE(frame.result.dragCanceled); + EXPECT_TRUE(frame.result.releasePointerCapture); + EXPECT_FALSE(frame.result.reorderRequested); + EXPECT_EQ(frame.result.draggedTabId, "doc-b"); + EXPECT_FALSE(state.reorderCaptureActive); + EXPECT_EQ(state.reorderSourceIndex, UIEditorTabStripInvalidIndex); +} + +TEST(UIEditorTabStripInteractionTest, FocusLostCancelsActiveTabDragAndClearsPreview) { + const auto items = BuildTabItems(); + std::string selectedTabId = "doc-a"; + UIEditorTabStripInteractionState state = {}; + + const auto initialFrame = UpdateUIEditorTabStripInteraction( + state, + selectedTabId, + UIRect(0.0f, 0.0f, 320.0f, 180.0f), + items, + {}); + const UIPoint sourceCenter = RectCenter(initialFrame.layout.tabHeaderRects[0]); + const UIPoint dragPoint( + initialFrame.layout.tabHeaderRects[2].x + 8.0f, + sourceCenter.y); + + auto frame = UpdateUIEditorTabStripInteraction( + state, + selectedTabId, + UIRect(0.0f, 0.0f, 320.0f, 180.0f), + items, + { + MakePointerDown(sourceCenter.x, sourceCenter.y), + MakePointerMove(dragPoint.x, dragPoint.y) + }); + ASSERT_TRUE(frame.result.dragStarted); + ASSERT_TRUE(frame.result.reorderPreviewActive); + + frame = UpdateUIEditorTabStripInteraction( + state, + selectedTabId, + UIRect(0.0f, 0.0f, 320.0f, 180.0f), + items, + { MakeFocusLost() }); + + EXPECT_TRUE(frame.result.dragCanceled); + EXPECT_TRUE(frame.result.releasePointerCapture); + EXPECT_FALSE(frame.result.reorderRequested); + EXPECT_FALSE(state.tabStripState.focused); + EXPECT_FALSE(state.reorderCaptureActive); + EXPECT_EQ(state.reorderPreviewIndex, UIEditorTabStripInvalidIndex); + EXPECT_FALSE(frame.layout.insertionPreview.visible); +} + +TEST(UIEditorTabStripInteractionTest, VisibleInsertionIndexTracksHeaderGapsInsteadOfFinalTabIndices) { + const auto items = BuildTabItems(); + std::string selectedTabId = "doc-b"; + UIEditorTabStripInteractionState state = {}; + + const auto initialFrame = UpdateUIEditorTabStripInteraction( + state, + selectedTabId, + UIRect(0.0f, 0.0f, 320.0f, 180.0f), + items, + {}); + const UIPoint sourceCenter = RectCenter(initialFrame.layout.tabHeaderRects[1]); + const float betweenFirstAndSecond = + (initialFrame.layout.tabHeaderRects[0].x + initialFrame.layout.tabHeaderRects[0].width + + initialFrame.layout.tabHeaderRects[1].x) * 0.5f; + + const auto frame = UpdateUIEditorTabStripInteraction( + state, + selectedTabId, + UIRect(0.0f, 0.0f, 320.0f, 180.0f), + items, + { + MakePointerDown(sourceCenter.x, sourceCenter.y), + MakePointerMove(betweenFirstAndSecond, sourceCenter.y) + }); + + EXPECT_TRUE(frame.result.requestPointerCapture); + EXPECT_TRUE(frame.result.reorderPreviewActive); + EXPECT_EQ(frame.result.dragSourceIndex, 1u); + EXPECT_EQ(frame.result.dropInsertionIndex, 1u); + EXPECT_EQ(frame.result.reorderToIndex, 1u); + EXPECT_EQ(frame.result.reorderPreviewIndex, 1u); +} diff --git a/tests/UI/Editor/unit/test_ui_editor_viewport_shell.cpp b/tests/UI/Editor/unit/test_ui_editor_viewport_shell.cpp index 3d88c3d4..1dd2ad2c 100644 --- a/tests/UI/Editor/unit/test_ui_editor_viewport_shell.cpp +++ b/tests/UI/Editor/unit/test_ui_editor_viewport_shell.cpp @@ -37,9 +37,9 @@ TEST(UIEditorViewportShellTest, ResolveRequestUsesViewportInputRectSize) { spec); EXPECT_FLOAT_EQ(request.slotLayout.inputRect.width, 800.0f); - EXPECT_FLOAT_EQ(request.slotLayout.inputRect.height, 532.0f); + EXPECT_FLOAT_EQ(request.slotLayout.inputRect.height, 554.0f); EXPECT_FLOAT_EQ(request.requestedViewportSize.width, 800.0f); - EXPECT_FLOAT_EQ(request.requestedViewportSize.height, 532.0f); + EXPECT_FLOAT_EQ(request.requestedViewportSize.height, 554.0f); } TEST(UIEditorViewportShellTest, ResolveRequestTracksChromeBarVisibility) { diff --git a/tests/UI/Editor/unit/test_ui_editor_viewport_slot.cpp b/tests/UI/Editor/unit/test_ui_editor_viewport_slot.cpp index 37963348..cb4d804a 100644 --- a/tests/UI/Editor/unit/test_ui_editor_viewport_slot.cpp +++ b/tests/UI/Editor/unit/test_ui_editor_viewport_slot.cpp @@ -92,7 +92,7 @@ TEST(UIEditorViewportSlotTest, DesiredToolWidthUsesExplicitValueBeforeEstimatedL inferredWidth.label = "Scene"; EXPECT_FLOAT_EQ(ResolveUIEditorViewportSlotDesiredToolWidth(explicitWidth), 88.0f); - EXPECT_FLOAT_EQ(ResolveUIEditorViewportSlotDesiredToolWidth(inferredWidth), 55.0f); + EXPECT_FLOAT_EQ(ResolveUIEditorViewportSlotDesiredToolWidth(inferredWidth), 48.5f); } TEST(UIEditorViewportSlotTest, LayoutBuildsTopBarSurfaceBottomBarAndAspectFittedTexture) { @@ -142,12 +142,12 @@ TEST(UIEditorViewportSlotTest, ToolItemsAlignToEdgesAndTitleRectClampsBetweenToo BuildToolItems(), {}); - EXPECT_FLOAT_EQ(layout.toolItemRects[0].x, 12.0f); + EXPECT_FLOAT_EQ(layout.toolItemRects[0].x, 8.0f); EXPECT_FLOAT_EQ(layout.toolItemRects[0].width, 96.0f); - EXPECT_FLOAT_EQ(layout.toolItemRects[1].x, 762.0f); - EXPECT_FLOAT_EQ(layout.toolItemRects[2].x, 816.0f); - EXPECT_FLOAT_EQ(layout.titleRect.x, 118.0f); - EXPECT_FLOAT_EQ(layout.titleRect.width, 634.0f); + EXPECT_FLOAT_EQ(layout.toolItemRects[1].x, 768.0f); + EXPECT_FLOAT_EQ(layout.toolItemRects[2].x, 820.0f); + EXPECT_FLOAT_EQ(layout.titleRect.x, 110.0f); + EXPECT_FLOAT_EQ(layout.titleRect.width, 652.0f); } TEST(UIEditorViewportSlotTest, HitTestPrioritizesToolThenStatusThenSurface) { diff --git a/tests/UI/Editor/unit/test_ui_editor_workspace_controller.cpp b/tests/UI/Editor/unit/test_ui_editor_workspace_controller.cpp index 88d1f15b..133f1568 100644 --- a/tests/UI/Editor/unit/test_ui_editor_workspace_controller.cpp +++ b/tests/UI/Editor/unit/test_ui_editor_workspace_controller.cpp @@ -2,6 +2,8 @@ #include +#include + namespace { using XCEngine::UI::Editor::BuildDefaultUIEditorWorkspaceController; @@ -9,6 +11,9 @@ using XCEngine::UI::Editor::BuildDefaultUIEditorWorkspaceSession; using XCEngine::UI::Editor::BuildUIEditorWorkspacePanel; using XCEngine::UI::Editor::BuildUIEditorWorkspaceSplit; using XCEngine::UI::Editor::BuildUIEditorWorkspaceTabStack; +using XCEngine::UI::Editor::AreUIEditorWorkspaceModelsEquivalent; +using XCEngine::UI::Editor::FindUIEditorPanelSessionState; +using XCEngine::UI::Editor::FindUIEditorWorkspaceNode; using XCEngine::UI::Editor::GetUIEditorWorkspaceCommandKindName; using XCEngine::UI::Editor::GetUIEditorWorkspaceCommandStatusName; using XCEngine::UI::Editor::UIEditorPanelRegistry; @@ -17,7 +22,11 @@ using XCEngine::UI::Editor::UIEditorWorkspaceCommandKind; using XCEngine::UI::Editor::UIEditorWorkspaceCommandStatus; using XCEngine::UI::Editor::UIEditorWorkspaceControllerValidationCode; using XCEngine::UI::Editor::UIEditorWorkspaceController; +using XCEngine::UI::Editor::UIEditorWorkspaceDockPlacement; +using XCEngine::UI::Editor::UIEditorWorkspaceLayoutOperationStatus; using XCEngine::UI::Editor::UIEditorWorkspaceModel; +using XCEngine::UI::Editor::UIEditorWorkspaceNode; +using XCEngine::UI::Editor::UIEditorWorkspaceSession; using XCEngine::UI::Editor::UIEditorWorkspaceSplitAxis; UIEditorPanelRegistry BuildPanelRegistry() { @@ -25,6 +34,9 @@ UIEditorPanelRegistry BuildPanelRegistry() { registry.panels = { { "doc-a", "Document A", {}, true, true, true }, { "doc-b", "Document B", {}, true, true, true }, + { "doc-c", "Document C", {}, true, true, true }, + { "hidden-a", "Hidden A", {}, true, true, true }, + { "hidden-b", "Hidden B", {}, true, true, true }, { "details", "Details", {}, true, true, true }, { "root", "Root", {}, true, false, false } }; @@ -49,6 +61,34 @@ UIEditorWorkspaceModel BuildWorkspace() { return workspace; } +UIEditorWorkspaceModel BuildReorderWorkspace() { + UIEditorWorkspaceModel workspace = {}; + workspace.root = BuildUIEditorWorkspaceTabStack( + "document-tabs", + { + BuildUIEditorWorkspacePanel("doc-a-node", "doc-a", "Document A", true), + BuildUIEditorWorkspacePanel("hidden-a-node", "hidden-a", "Hidden A", true), + BuildUIEditorWorkspacePanel("doc-b-node", "doc-b", "Document B", true), + BuildUIEditorWorkspacePanel("hidden-b-node", "hidden-b", "Hidden B", true), + BuildUIEditorWorkspacePanel("doc-c-node", "doc-c", "Document C", true) + }, + 2u); + workspace.activePanelId = "doc-b"; + return workspace; +} + +UIEditorWorkspaceSession BuildReorderSession() { + UIEditorWorkspaceSession session = {}; + session.panelStates = { + { "doc-a", true, true }, + { "hidden-a", true, false }, + { "doc-b", true, true }, + { "hidden-b", true, false }, + { "doc-c", true, true } + }; + return session; +} + } // namespace TEST(UIEditorWorkspaceControllerTest, CommandNameHelpersExposeStableDebugNames) { @@ -146,3 +186,201 @@ TEST(UIEditorWorkspaceControllerTest, RejectsUnknownPanelAndNonCloseablePanelCom EXPECT_EQ(nonCloseable.status, UIEditorWorkspaceCommandStatus::Rejected); EXPECT_EQ(rootController.GetWorkspace().activePanelId, "root"); } + +TEST(UIEditorWorkspaceControllerTest, ReorderTabUsesModelSurgeryAndPreservesSelectionActiveAndSession) { + UIEditorWorkspaceController controller( + BuildPanelRegistry(), + BuildReorderWorkspace(), + BuildReorderSession()); + + const auto result = controller.ReorderTab("document-tabs", "doc-c", 0u); + EXPECT_EQ(result.status, UIEditorWorkspaceLayoutOperationStatus::Changed); + EXPECT_EQ(result.activePanelId, "doc-b"); + ASSERT_EQ(result.visiblePanelIds.size(), 1u); + EXPECT_EQ(result.visiblePanelIds[0], "doc-b"); + + const UIEditorWorkspaceNode* tabStack = + FindUIEditorWorkspaceNode(controller.GetWorkspace(), "document-tabs"); + ASSERT_NE(tabStack, nullptr); + ASSERT_EQ(tabStack->children.size(), 5u); + EXPECT_EQ(tabStack->children[0].panel.panelId, "doc-c"); + EXPECT_EQ(tabStack->children[1].panel.panelId, "hidden-a"); + EXPECT_EQ(tabStack->children[2].panel.panelId, "doc-a"); + EXPECT_EQ(tabStack->children[3].panel.panelId, "hidden-b"); + EXPECT_EQ(tabStack->children[4].panel.panelId, "doc-b"); + EXPECT_EQ(tabStack->selectedTabIndex, 4u); + + const auto* hiddenState = + FindUIEditorPanelSessionState(controller.GetSession(), "hidden-a"); + ASSERT_NE(hiddenState, nullptr); + EXPECT_TRUE(hiddenState->open); + EXPECT_FALSE(hiddenState->visible); +} + +TEST(UIEditorWorkspaceControllerTest, ReorderTabRejectsHiddenPanelsAndOutOfRangeTargetsAndReportsNoOp) { + UIEditorWorkspaceController controller( + BuildPanelRegistry(), + BuildReorderWorkspace(), + BuildReorderSession()); + + const auto hidden = controller.ReorderTab("document-tabs", "hidden-a", 0u); + EXPECT_EQ(hidden.status, UIEditorWorkspaceLayoutOperationStatus::Rejected); + + const auto outOfRange = controller.ReorderTab("document-tabs", "doc-a", 4u); + EXPECT_EQ(outOfRange.status, UIEditorWorkspaceLayoutOperationStatus::Rejected); + + const auto noOp = controller.ReorderTab("document-tabs", "doc-a", 0u); + EXPECT_EQ(noOp.status, UIEditorWorkspaceLayoutOperationStatus::NoOp); +} + +TEST(UIEditorWorkspaceControllerTest, MoveTabToStackMovesVisibleTabAcrossStacks) { + UIEditorWorkspaceModel workspace = {}; + workspace.root = BuildUIEditorWorkspaceSplit( + "root-split", + UIEditorWorkspaceSplitAxis::Horizontal, + 0.55f, + BuildUIEditorWorkspaceTabStack( + "left-tabs", + { + BuildUIEditorWorkspacePanel("doc-a-node", "doc-a", "Document A", true), + BuildUIEditorWorkspacePanel("doc-b-node", "doc-b", "Document B", true) + }, + 0u), + BuildUIEditorWorkspacePanel("details-node", "details", "Details", true)); + workspace.activePanelId = "doc-a"; + + UIEditorWorkspaceSession session = {}; + session.panelStates = { + { "doc-a", true, true }, + { "doc-b", true, true }, + { "details", true, true } + }; + + UIEditorWorkspaceController controller(BuildPanelRegistry(), workspace, session); + + const auto result = controller.MoveTabToStack("left-tabs", "doc-b", "details-node", 1u); + EXPECT_EQ(result.status, UIEditorWorkspaceLayoutOperationStatus::Changed); + EXPECT_EQ(result.activePanelId, "doc-b"); + + const UIEditorWorkspaceNode* leftTabs = + FindUIEditorWorkspaceNode(controller.GetWorkspace(), "left-tabs"); + const UIEditorWorkspaceNode* detailsTabs = + FindUIEditorWorkspaceNode(controller.GetWorkspace(), "details-node"); + ASSERT_NE(leftTabs, nullptr); + ASSERT_NE(detailsTabs, nullptr); + ASSERT_EQ(leftTabs->children.size(), 1u); + ASSERT_EQ(detailsTabs->children.size(), 2u); + EXPECT_EQ(detailsTabs->children[1].panel.panelId, "doc-b"); +} + +TEST(UIEditorWorkspaceControllerTest, DockTabRelativeCreatesSplitAroundTargetStack) { + UIEditorWorkspaceModel workspace = {}; + workspace.root = BuildUIEditorWorkspaceSplit( + "root-split", + UIEditorWorkspaceSplitAxis::Horizontal, + 0.55f, + BuildUIEditorWorkspaceTabStack( + "left-tabs", + { + BuildUIEditorWorkspacePanel("doc-a-node", "doc-a", "Document A", true), + BuildUIEditorWorkspacePanel("doc-b-node", "doc-b", "Document B", true) + }, + 0u), + BuildUIEditorWorkspacePanel("details-node", "details", "Details", true)); + workspace.activePanelId = "doc-a"; + + UIEditorWorkspaceSession session = {}; + session.panelStates = { + { "doc-a", true, true }, + { "doc-b", true, true }, + { "details", true, true } + }; + + UIEditorWorkspaceController controller(BuildPanelRegistry(), workspace, session); + + const auto result = controller.DockTabRelative( + "left-tabs", + "doc-b", + "details-node", + UIEditorWorkspaceDockPlacement::Bottom, + 0.4f); + EXPECT_EQ(result.status, UIEditorWorkspaceLayoutOperationStatus::Changed); + EXPECT_EQ(result.activePanelId, "doc-b"); + + const UIEditorWorkspaceNode* detailsTabs = + FindUIEditorWorkspaceNode(controller.GetWorkspace(), "details-node"); + ASSERT_NE(detailsTabs, nullptr); + EXPECT_EQ(detailsTabs->kind, XCEngine::UI::Editor::UIEditorWorkspaceNodeKind::TabStack); + + bool foundDockedTab = false; + for (const std::string candidate : { "details-node__dock_doc-b_stack", "details-node__dock_doc-b_stack-1" }) { + const UIEditorWorkspaceNode* docked = + FindUIEditorWorkspaceNode(controller.GetWorkspace(), candidate); + if (docked != nullptr) { + foundDockedTab = docked->children.size() == 1u && + docked->children[0].panel.panelId == "doc-b"; + if (foundDockedTab) { + break; + } + } + } + EXPECT_TRUE(foundDockedTab); +} + +TEST(UIEditorWorkspaceControllerTest, DockTabRelativeReturnsNoOpWhenRedockingAlreadyDockedSingleTabStack) { + UIEditorWorkspaceModel workspace = {}; + workspace.root = BuildUIEditorWorkspaceSplit( + "root-split", + UIEditorWorkspaceSplitAxis::Horizontal, + 0.55f, + BuildUIEditorWorkspaceTabStack( + "left-tabs", + { + BuildUIEditorWorkspacePanel("doc-a-node", "doc-a", "Document A", true), + BuildUIEditorWorkspacePanel("doc-b-node", "doc-b", "Document B", true) + }, + 0u), + BuildUIEditorWorkspacePanel("details-node", "details", "Details", true)); + workspace.activePanelId = "doc-a"; + + UIEditorWorkspaceSession session = {}; + session.panelStates = { + { "doc-a", true, true }, + { "doc-b", true, true }, + { "details", true, true } + }; + + UIEditorWorkspaceController controller(BuildPanelRegistry(), workspace, session); + + const auto first = controller.DockTabRelative( + "left-tabs", + "doc-b", + "details-node", + UIEditorWorkspaceDockPlacement::Bottom, + 0.4f); + ASSERT_EQ(first.status, UIEditorWorkspaceLayoutOperationStatus::Changed); + + std::string movedStackId = {}; + for (const std::string candidate : { "details-node__dock_doc-b_stack", "details-node__dock_doc-b_stack-1" }) { + if (FindUIEditorWorkspaceNode(controller.GetWorkspace(), candidate) != nullptr) { + movedStackId = candidate; + break; + } + } + ASSERT_FALSE(movedStackId.empty()); + + const UIEditorWorkspaceModel afterFirstDock = controller.GetWorkspace(); + + const auto second = controller.DockTabRelative( + movedStackId, + "doc-b", + "details-node", + UIEditorWorkspaceDockPlacement::Bottom, + 0.4f); + EXPECT_EQ(second.status, UIEditorWorkspaceLayoutOperationStatus::NoOp); + EXPECT_EQ(second.activePanelId, "doc-b"); + EXPECT_TRUE( + AreUIEditorWorkspaceModelsEquivalent( + controller.GetWorkspace(), + afterFirstDock)); +} diff --git a/tests/UI/Editor/unit/test_ui_editor_workspace_model.cpp b/tests/UI/Editor/unit/test_ui_editor_workspace_model.cpp index 84370cc2..06db2ad3 100644 --- a/tests/UI/Editor/unit/test_ui_editor_workspace_model.cpp +++ b/tests/UI/Editor/unit/test_ui_editor_workspace_model.cpp @@ -1,6 +1,7 @@ #include #include +#include #include #include @@ -12,17 +13,25 @@ using XCEngine::UI::Editor::BuildUIEditorWorkspacePanel; using XCEngine::UI::Editor::BuildUIEditorWorkspaceSingleTabStack; using XCEngine::UI::Editor::BuildUIEditorWorkspaceSplit; using XCEngine::UI::Editor::BuildUIEditorWorkspaceTabStack; +using XCEngine::UI::Editor::AreUIEditorWorkspaceModelsEquivalent; using XCEngine::UI::Editor::CanonicalizeUIEditorWorkspaceModel; using XCEngine::UI::Editor::CollectUIEditorWorkspaceVisiblePanels; using XCEngine::UI::Editor::ContainsUIEditorWorkspacePanel; using XCEngine::UI::Editor::FindUIEditorWorkspaceActivePanel; using XCEngine::UI::Editor::FindUIEditorWorkspaceNode; using XCEngine::UI::Editor::TryActivateUIEditorWorkspacePanel; +using XCEngine::UI::Editor::TryDockUIEditorWorkspaceTabRelative; +using XCEngine::UI::Editor::TryMoveUIEditorWorkspaceTabToStack; +using XCEngine::UI::Editor::TryReorderUIEditorWorkspaceTab; using XCEngine::UI::Editor::TrySetUIEditorWorkspaceSplitRatio; +using XCEngine::UI::Editor::UIEditorWorkspaceDockPlacement; using XCEngine::UI::Editor::UIEditorWorkspaceModel; +using XCEngine::UI::Editor::UIEditorWorkspaceNode; +using XCEngine::UI::Editor::UIEditorWorkspaceSession; using XCEngine::UI::Editor::UIEditorWorkspaceNodeKind; using XCEngine::UI::Editor::UIEditorWorkspaceSplitAxis; using XCEngine::UI::Editor::UIEditorWorkspaceValidationCode; +using XCEngine::UI::Editor::UIEditorWorkspaceVisiblePanel; using XCEngine::UI::Editor::ValidateUIEditorWorkspace; std::vector CollectVisiblePanelIds(const UIEditorWorkspaceModel& workspace) { @@ -170,6 +179,78 @@ TEST(UIEditorWorkspaceModelTest, SplitRatioMutationTargetsSplitNodeAndRejectsInv EXPECT_FALSE(TrySetUIEditorWorkspaceSplitRatio(workspace, "root-split", 1.0f)); } +TEST(UIEditorWorkspaceModelTest, ReorderTabMovesVisibleTabsAndPreservesHiddenOrderSelectionAndActivePanel) { + UIEditorWorkspaceModel workspace = {}; + workspace.root = BuildUIEditorWorkspaceTabStack( + "document-tabs", + { + BuildUIEditorWorkspacePanel("doc-a-node", "doc-a", "Document A", true), + BuildUIEditorWorkspacePanel("hidden-a-node", "hidden-a", "Hidden A", true), + BuildUIEditorWorkspacePanel("doc-b-node", "doc-b", "Document B", true), + BuildUIEditorWorkspacePanel("hidden-b-node", "hidden-b", "Hidden B", true), + BuildUIEditorWorkspacePanel("doc-c-node", "doc-c", "Document C", true) + }, + 2u); + workspace.activePanelId = "doc-b"; + + UIEditorWorkspaceSession session = {}; + session.panelStates = { + { "doc-a", true, true }, + { "hidden-a", true, false }, + { "doc-b", true, true }, + { "hidden-b", true, false }, + { "doc-c", true, true } + }; + + ASSERT_TRUE(TryReorderUIEditorWorkspaceTab( + workspace, + session, + "document-tabs", + "doc-c", + 0u)); + + const UIEditorWorkspaceNode* tabStack = + FindUIEditorWorkspaceNode(workspace, "document-tabs"); + ASSERT_NE(tabStack, nullptr); + ASSERT_EQ(tabStack->children.size(), 5u); + EXPECT_EQ(tabStack->children[0].panel.panelId, "doc-c"); + EXPECT_EQ(tabStack->children[1].panel.panelId, "hidden-a"); + EXPECT_EQ(tabStack->children[2].panel.panelId, "doc-a"); + EXPECT_EQ(tabStack->children[3].panel.panelId, "hidden-b"); + EXPECT_EQ(tabStack->children[4].panel.panelId, "doc-b"); + EXPECT_EQ(tabStack->selectedTabIndex, 4u); + EXPECT_EQ(workspace.activePanelId, "doc-b"); + + EXPECT_FALSE(TryReorderUIEditorWorkspaceTab( + workspace, + session, + "document-tabs", + "hidden-a", + 0u)); + + ASSERT_TRUE(TryReorderUIEditorWorkspaceTab( + workspace, + session, + "document-tabs", + "doc-a", + 3u)); + tabStack = FindUIEditorWorkspaceNode(workspace, "document-tabs"); + ASSERT_NE(tabStack, nullptr); + EXPECT_EQ(tabStack->children[0].panel.panelId, "doc-c"); + EXPECT_EQ(tabStack->children[1].panel.panelId, "hidden-a"); + EXPECT_EQ(tabStack->children[2].panel.panelId, "doc-b"); + EXPECT_EQ(tabStack->children[3].panel.panelId, "hidden-b"); + EXPECT_EQ(tabStack->children[4].panel.panelId, "doc-a"); + EXPECT_EQ(tabStack->selectedTabIndex, 2u); + + EXPECT_FALSE(TryReorderUIEditorWorkspaceTab( + workspace, + session, + "document-tabs", + "doc-a", + 4u)); +} + TEST(UIEditorWorkspaceModelTest, CanonicalizeWrapsStandalonePanelsIntoSingleTabStacks) { UIEditorWorkspaceModel workspace = {}; workspace.root = BuildUIEditorWorkspaceSplit( @@ -195,3 +276,254 @@ TEST(UIEditorWorkspaceModelTest, CanonicalizeWrapsStandalonePanelsIntoSingleTabS "left"); EXPECT_EQ(canonicalWorkspace.root.children[1].kind, UIEditorWorkspaceNodeKind::TabStack); } + +TEST(UIEditorWorkspaceModelTest, MoveTabToStackMergesIntoTargetStackAndActivatesMovedPanel) { + UIEditorWorkspaceModel workspace = {}; + workspace.root = BuildUIEditorWorkspaceSplit( + "root-split", + UIEditorWorkspaceSplitAxis::Horizontal, + 0.58f, + BuildUIEditorWorkspaceTabStack( + "left-tabs", + { + BuildUIEditorWorkspacePanel("doc-a-node", "doc-a", "Document A", true), + BuildUIEditorWorkspacePanel("doc-b-node", "doc-b", "Document B", true) + }, + 0u), + BuildUIEditorWorkspaceSingleTabStack( + "right-tabs", + "details", + "Details", + true)); + workspace.activePanelId = "doc-a"; + + UIEditorWorkspaceSession session = {}; + session.panelStates = { + { "doc-a", true, true }, + { "doc-b", true, true }, + { "details", true, true } + }; + + ASSERT_TRUE(TryMoveUIEditorWorkspaceTabToStack( + workspace, + session, + "left-tabs", + "doc-b", + "right-tabs", + 1u)); + + const UIEditorWorkspaceNode* leftTabs = + FindUIEditorWorkspaceNode(workspace, "left-tabs"); + const UIEditorWorkspaceNode* rightTabs = + FindUIEditorWorkspaceNode(workspace, "right-tabs"); + ASSERT_NE(leftTabs, nullptr); + ASSERT_NE(rightTabs, nullptr); + ASSERT_EQ(leftTabs->children.size(), 1u); + ASSERT_EQ(rightTabs->children.size(), 2u); + EXPECT_EQ(leftTabs->children[0].panel.panelId, "doc-a"); + EXPECT_EQ(rightTabs->children[0].panel.panelId, "details"); + EXPECT_EQ(rightTabs->children[1].panel.panelId, "doc-b"); + EXPECT_EQ(rightTabs->selectedTabIndex, 1u); + EXPECT_EQ(workspace.activePanelId, "doc-b"); +} + +TEST(UIEditorWorkspaceModelTest, DockTabRelativeSplitsTargetStackAndCreatesNewLeaf) { + UIEditorWorkspaceModel workspace = {}; + workspace.root = BuildUIEditorWorkspaceSplit( + "root-split", + UIEditorWorkspaceSplitAxis::Horizontal, + 0.58f, + BuildUIEditorWorkspaceTabStack( + "left-tabs", + { + BuildUIEditorWorkspacePanel("doc-a-node", "doc-a", "Document A", true), + BuildUIEditorWorkspacePanel("doc-b-node", "doc-b", "Document B", true) + }, + 0u), + BuildUIEditorWorkspaceSingleTabStack( + "right-tabs", + "details", + "Details", + true)); + workspace.activePanelId = "doc-a"; + + UIEditorWorkspaceSession session = {}; + session.panelStates = { + { "doc-a", true, true }, + { "doc-b", true, true }, + { "details", true, true } + }; + + ASSERT_TRUE(TryDockUIEditorWorkspaceTabRelative( + workspace, + session, + "left-tabs", + "doc-b", + "right-tabs", + UIEditorWorkspaceDockPlacement::Bottom, + 0.4f)); + + const UIEditorWorkspaceNode* rightTabs = + FindUIEditorWorkspaceNode(workspace, "right-tabs"); + ASSERT_NE(rightTabs, nullptr); + ASSERT_EQ(rightTabs->kind, UIEditorWorkspaceNodeKind::TabStack); + ASSERT_EQ(rightTabs->children.size(), 1u); + EXPECT_EQ(rightTabs->children[0].panel.panelId, "details"); + + const UIEditorWorkspaceNode* movedTabStack = nullptr; + if (const UIEditorWorkspaceNode* rootSplit = + FindUIEditorWorkspaceNode(workspace, "root-split"); + rootSplit != nullptr) { + const auto visibleIds = CollectVisiblePanelIds(workspace); + ASSERT_EQ(visibleIds.size(), 3u); + EXPECT_EQ(visibleIds[0], "doc-a"); + EXPECT_EQ(visibleIds[1], "details"); + EXPECT_EQ(visibleIds[2], "doc-b"); + } + + const std::vector visiblePanels = + CollectUIEditorWorkspaceVisiblePanels(workspace); + ASSERT_EQ(visiblePanels.size(), 3u); + EXPECT_EQ(visiblePanels[2].panelId, "doc-b"); + + for (const std::string candidate : { "right-tabs__dock_doc-b_stack", "right-tabs__dock_doc-b_stack-1" }) { + movedTabStack = FindUIEditorWorkspaceNode(workspace, candidate); + if (movedTabStack != nullptr) { + break; + } + } + ASSERT_NE(movedTabStack, nullptr); + ASSERT_EQ(movedTabStack->kind, UIEditorWorkspaceNodeKind::TabStack); + ASSERT_EQ(movedTabStack->children.size(), 1u); + EXPECT_EQ(movedTabStack->children[0].panel.panelId, "doc-b"); + EXPECT_EQ(workspace.activePanelId, "doc-b"); +} + +TEST(UIEditorWorkspaceModelTest, DockTabRelativeCanRedockSingleTabBranchBackOntoSiblingStack) { + UIEditorWorkspaceModel workspace = {}; + workspace.root = BuildUIEditorWorkspaceTabStack( + "center-tabs", + { + BuildUIEditorWorkspacePanel("scene-node", "scene", "Scene", true), + BuildUIEditorWorkspacePanel("game-node", "game", "Game", true) + }, + 0u); + workspace.activePanelId = "scene"; + + UIEditorWorkspaceSession session = {}; + session.panelStates = { + { "scene", true, true }, + { "game", true, true } + }; + + ASSERT_TRUE(TryDockUIEditorWorkspaceTabRelative( + workspace, + session, + "center-tabs", + "scene", + "center-tabs", + UIEditorWorkspaceDockPlacement::Top, + 0.5f)); + + const UIEditorWorkspaceNode* firstDockStack = + FindUIEditorWorkspaceNode(workspace, "center-tabs__dock_scene_stack"); + ASSERT_NE(firstDockStack, nullptr); + ASSERT_EQ(firstDockStack->kind, UIEditorWorkspaceNodeKind::TabStack); + ASSERT_EQ(firstDockStack->children.size(), 1u); + EXPECT_EQ(firstDockStack->children[0].panel.panelId, "scene"); + + ASSERT_TRUE(TryDockUIEditorWorkspaceTabRelative( + workspace, + session, + "center-tabs__dock_scene_stack", + "scene", + "center-tabs", + UIEditorWorkspaceDockPlacement::Top, + 0.5f)); + + const auto validation = ValidateUIEditorWorkspace(workspace); + ASSERT_TRUE(validation.IsValid()) << validation.message; + + const std::vector visiblePanels = + CollectUIEditorWorkspaceVisiblePanels(workspace); + ASSERT_EQ(visiblePanels.size(), 2u); + EXPECT_EQ(visiblePanels[0].panelId, "scene"); + EXPECT_EQ(visiblePanels[1].panelId, "game"); + EXPECT_EQ(workspace.activePanelId, "scene"); +} + +TEST(UIEditorWorkspaceModelTest, DockTabRelativeCanRedockGeneratedSingleTabStackWithoutChangingLayout) { + UIEditorWorkspaceModel workspace = {}; + workspace.root = BuildUIEditorWorkspaceSplit( + "root-split", + UIEditorWorkspaceSplitAxis::Horizontal, + 0.58f, + BuildUIEditorWorkspaceTabStack( + "left-tabs", + { + BuildUIEditorWorkspacePanel("doc-a-node", "doc-a", "Document A", true), + BuildUIEditorWorkspacePanel("doc-b-node", "doc-b", "Document B", true) + }, + 0u), + BuildUIEditorWorkspaceSingleTabStack( + "right-tabs", + "details", + "Details", + true)); + workspace.activePanelId = "doc-a"; + + UIEditorWorkspaceSession session = {}; + session.panelStates = { + { "doc-a", true, true }, + { "doc-b", true, true }, + { "details", true, true } + }; + + ASSERT_TRUE(TryDockUIEditorWorkspaceTabRelative( + workspace, + session, + "left-tabs", + "doc-b", + "right-tabs", + UIEditorWorkspaceDockPlacement::Bottom, + 0.4f)); + + std::string movedStackId = {}; + for (const std::string candidate : { "right-tabs__dock_doc-b_stack", "right-tabs__dock_doc-b_stack-1" }) { + if (FindUIEditorWorkspaceNode(workspace, candidate) != nullptr) { + movedStackId = candidate; + break; + } + } + ASSERT_FALSE(movedStackId.empty()); + + const UIEditorWorkspaceModel afterFirstDock = workspace; + + ASSERT_TRUE(TryDockUIEditorWorkspaceTabRelative( + workspace, + session, + movedStackId, + "doc-b", + "right-tabs", + UIEditorWorkspaceDockPlacement::Bottom, + 0.4f)); + + const auto validation = ValidateUIEditorWorkspace(workspace); + ASSERT_TRUE(validation.IsValid()) << validation.message; + EXPECT_TRUE(AreUIEditorWorkspaceModelsEquivalent(workspace, afterFirstDock)); + EXPECT_EQ(workspace.activePanelId, "doc-b"); + + const UIEditorWorkspaceNode* rightTabs = + FindUIEditorWorkspaceNode(workspace, "right-tabs"); + ASSERT_NE(rightTabs, nullptr); + EXPECT_EQ(rightTabs->kind, UIEditorWorkspaceNodeKind::TabStack); + ASSERT_EQ(rightTabs->children.size(), 1u); + EXPECT_EQ(rightTabs->children[0].panel.panelId, "details"); + + const UIEditorWorkspaceNode* movedTabStack = + FindUIEditorWorkspaceNode(workspace, movedStackId); + ASSERT_NE(movedTabStack, nullptr); + ASSERT_EQ(movedTabStack->kind, UIEditorWorkspaceNodeKind::TabStack); + ASSERT_EQ(movedTabStack->children.size(), 1u); + EXPECT_EQ(movedTabStack->children[0].panel.panelId, "doc-b"); +}