From 7812b92992739e7cd40f2ac38c03dfb41b6d310a Mon Sep 17 00:00:00 2001 From: ssdfasd <2156608475@qq.com> Date: Sun, 5 Apr 2026 21:27:00 +0800 Subject: [PATCH] Add XCUI core scroll view validation in new_editor --- .../UI/Runtime/UIScreenDocumentHost.h | 25 ++ .../src/UI/Runtime/UIScreenDocumentHost.cpp | 315 ++++++++++++++++-- new_editor/src/Application.cpp | 109 +++++- new_editor/src/Application.h | 3 + new_editor/ui/views/editor_shell.xcui | 24 +- tests/Core/UI/test_ui_runtime.cpp | 76 +++++ 6 files changed, 518 insertions(+), 34 deletions(-) diff --git a/engine/include/XCEngine/UI/Runtime/UIScreenDocumentHost.h b/engine/include/XCEngine/UI/Runtime/UIScreenDocumentHost.h index 274a26d4..f49efe13 100644 --- a/engine/include/XCEngine/UI/Runtime/UIScreenDocumentHost.h +++ b/engine/include/XCEngine/UI/Runtime/UIScreenDocumentHost.h @@ -2,6 +2,10 @@ #include +#include +#include +#include + namespace XCEngine { namespace UI { namespace Runtime { @@ -18,10 +22,31 @@ public: class UIDocumentScreenHost final : public IUIScreenDocumentHost { public: + struct ScrollDebugSnapshot { + std::uint64_t totalWheelEventCount = 0u; + std::uint64_t handledWheelEventCount = 0u; + UIPoint lastPointerPosition = {}; + UIRect lastViewportRect = {}; + UIRect primaryViewportRect = {}; + float lastWheelDelta = 0.0f; + float lastOverflow = 0.0f; + float lastOffsetBefore = 0.0f; + float lastOffsetAfter = 0.0f; + float primaryOverflow = 0.0f; + std::string lastTargetStateKey = {}; + std::string primaryTargetStateKey = {}; + std::string lastResult = {}; + }; + UIScreenLoadResult LoadScreen(const UIScreenAsset& asset) override; UIScreenFrameResult BuildFrame( const UIScreenDocument& document, const UIScreenFrameInput& input) override; + const ScrollDebugSnapshot& GetScrollDebugSnapshot() const; + +private: + std::unordered_map m_verticalScrollOffsets = {}; + ScrollDebugSnapshot m_scrollDebugSnapshot = {}; }; } // namespace Runtime diff --git a/engine/src/UI/Runtime/UIScreenDocumentHost.cpp b/engine/src/UI/Runtime/UIScreenDocumentHost.cpp index 20752f64..918cff5b 100644 --- a/engine/src/UI/Runtime/UIScreenDocumentHost.cpp +++ b/engine/src/UI/Runtime/UIScreenDocumentHost.cpp @@ -7,6 +7,7 @@ #include #include +#include #include #include #include @@ -39,9 +40,15 @@ constexpr float kHeaderTextGap = 2.0f; struct RuntimeLayoutNode { const UIDocumentNode* source = nullptr; + std::string stateKey = {}; std::vector children = {}; UISize desiredSize = {}; + UISize minimumSize = {}; + UISize contentDesiredSize = {}; UIRect rect = {}; + UIRect scrollViewportRect = {}; + float scrollOffsetY = 0.0f; + bool isScrollView = false; }; UIColor ToUIColor(const Color& color) { @@ -185,23 +192,14 @@ Layout::UILayoutThickness ParsePadding( Layout::UILayoutItem BuildLayoutItem( const RuntimeLayoutNode& child, - Layout::UILayoutAxis parentAxis) { + Layout::UILayoutAxis parentAxis, + const UISize& measuredContentSize) { Layout::UILayoutItem item = {}; - item.desiredContentSize = child.desiredSize; + item.desiredContentSize = measuredContentSize; + item.minSize = child.minimumSize; item.width = ParseLengthAttribute(*child.source, "width"); item.height = ParseLengthAttribute(*child.source, "height"); - // Pixel-authored lengths act as requested extents, but never below the measured content floor. - if (item.width.unit == Layout::UILayoutLengthUnit::Pixels && - item.width.value < child.desiredSize.width) { - item.minSize.width = child.desiredSize.width; - } - - if (item.height.unit == Layout::UILayoutLengthUnit::Pixels && - item.height.value < child.desiredSize.height) { - item.minSize.height = child.desiredSize.height; - } - if (parentAxis == Layout::UILayoutAxis::Vertical && item.width.unit == Layout::UILayoutLengthUnit::Auto) { item.horizontalAlignment = Layout::UILayoutAlignment::Stretch; @@ -233,6 +231,10 @@ bool IsHorizontalTag(const std::string& tagName) { return tagName == "Row"; } +bool IsScrollViewTag(const std::string& tagName) { + return tagName == "ScrollView"; +} + bool IsContainerTag(const UIDocumentNode& node) { if (node.children.Size() > 0u) { return true; @@ -242,10 +244,42 @@ bool IsContainerTag(const UIDocumentNode& node) { return tagName == "View" || tagName == "Column" || tagName == "Row" || + tagName == "ScrollView" || tagName == "Card" || tagName == "Button"; } +std::string BuildNodeStateKeySegment( + const UIDocumentNode& source, + std::size_t siblingIndex) { + const std::string id = GetAttribute(source, "id"); + if (!id.empty()) { + return id; + } + + const std::string name = GetAttribute(source, "name"); + if (!name.empty()) { + return ToStdString(source.tagName) + ":" + name; + } + + return ToStdString(source.tagName) + "#" + std::to_string(siblingIndex); +} + +float ComputeScrollOverflow(float contentExtent, float viewportExtent) { + return (std::max)(0.0f, contentExtent - viewportExtent); +} + +float ClampScrollOffset(float offset, float contentExtent, float viewportExtent) { + return (std::max)(0.0f, (std::min)(offset, ComputeScrollOverflow(contentExtent, viewportExtent))); +} + +bool RectContainsPoint(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; +} + Color ResolveBackgroundColor(const UIDocumentNode& node) { const std::string tone = GetAttribute(node, "tone"); const std::string tagName = ToStdString(node.tagName); @@ -278,12 +312,17 @@ Color ResolveBorderColor(const UIDocumentNode& node) { return Color(0.30f, 0.30f, 0.30f, 1.0f); } -RuntimeLayoutNode BuildLayoutTree(const UIDocumentNode& source) { +RuntimeLayoutNode BuildLayoutTree( + const UIDocumentNode& source, + const std::string& parentStateKey, + std::size_t siblingIndex) { RuntimeLayoutNode node = {}; node.source = &source; + node.stateKey = parentStateKey + "/" + BuildNodeStateKeySegment(source, siblingIndex); + node.isScrollView = IsScrollViewTag(ToStdString(source.tagName)); node.children.reserve(source.children.Size()); - for (const UIDocumentNode& child : source.children) { - node.children.push_back(BuildLayoutTree(child)); + for (std::size_t index = 0; index < source.children.Size(); ++index) { + node.children.push_back(BuildLayoutTree(source.children[index], node.stateKey, index)); } return node; } @@ -297,6 +336,7 @@ UISize MeasureNode(RuntimeLayoutNode& node) { node.desiredSize = UISize( MeasureTextWidth(text, kDefaultFontSize), MeasureTextHeight(kDefaultFontSize)); + node.minimumSize = node.desiredSize; return node.desiredSize; } @@ -304,6 +344,7 @@ UISize MeasureNode(RuntimeLayoutNode& node) { node.desiredSize = UISize( (std::max)(160.0f, MeasureTextWidth(ResolveNodeText(source), kDefaultFontSize) + 24.0f), 44.0f); + node.minimumSize = node.desiredSize; return node.desiredSize; } @@ -319,16 +360,21 @@ UISize MeasureNode(RuntimeLayoutNode& node) { source, tagName == "View" ? 16.0f : 12.0f); - std::vector items = {}; - items.reserve(node.children.size()); + std::vector desiredItems = {}; + desiredItems.reserve(node.children.size()); + std::vector minimumItems = {}; + minimumItems.reserve(node.children.size()); for (RuntimeLayoutNode& child : node.children) { MeasureNode(child); - Layout::UILayoutItem item = BuildLayoutItem(child, options.axis); - items.push_back(item); + desiredItems.push_back(BuildLayoutItem(child, options.axis, child.desiredSize)); + minimumItems.push_back(BuildLayoutItem(child, options.axis, child.minimumSize)); } - const auto measured = Layout::MeasureStackLayout(options, items); + const auto measured = Layout::MeasureStackLayout(options, desiredItems); + const auto minimumMeasured = Layout::MeasureStackLayout(options, minimumItems); + node.contentDesiredSize = measured.desiredSize; node.desiredSize = measured.desiredSize; + node.minimumSize = minimumMeasured.desiredSize; const float headerHeight = MeasureHeaderHeight(source); const float headerTextWidth = MeasureHeaderTextWidth(source); @@ -339,6 +385,16 @@ UISize MeasureNode(RuntimeLayoutNode& node) { ? headerTextWidth + options.padding.Horizontal() : MeasureTextWidth(ResolveNodeText(source), kDefaultFontSize) + options.padding.Horizontal()); node.desiredSize.height += headerHeight; + node.minimumSize.width = (std::max)( + node.minimumSize.width, + headerTextWidth > 0.0f + ? headerTextWidth + options.padding.Horizontal() + : MeasureTextWidth(ResolveNodeText(source), kDefaultFontSize) + options.padding.Horizontal()); + node.minimumSize.height += headerHeight; + + if (node.isScrollView) { + node.minimumSize.height = headerHeight + options.padding.Vertical(); + } float explicitWidth = 0.0f; if (TryParseFloat(GetAttribute(source, "width"), explicitWidth)) { @@ -353,8 +409,13 @@ UISize MeasureNode(RuntimeLayoutNode& node) { return node.desiredSize; } -void ArrangeNode(RuntimeLayoutNode& node, const UIRect& rect) { +void ArrangeNode( + RuntimeLayoutNode& node, + const UIRect& rect, + const std::unordered_map& verticalScrollOffsets) { node.rect = rect; + node.scrollViewportRect = {}; + node.scrollOffsetY = 0.0f; const UIDocumentNode& source = *node.source; if (!IsContainerTag(source)) { @@ -383,13 +444,180 @@ void ArrangeNode(RuntimeLayoutNode& node, const UIRect& rect) { std::vector items = {}; items.reserve(node.children.size()); for (RuntimeLayoutNode& child : node.children) { - Layout::UILayoutItem item = BuildLayoutItem(child, options.axis); + Layout::UILayoutItem item = BuildLayoutItem(child, options.axis, child.desiredSize); items.push_back(item); } + if (node.isScrollView) { + const auto found = verticalScrollOffsets.find(node.stateKey); + const float requestedOffset = found != verticalScrollOffsets.end() ? found->second : 0.0f; + node.scrollViewportRect = contentRect; + node.scrollOffsetY = ClampScrollOffset( + requestedOffset, + node.contentDesiredSize.height, + contentRect.height); + + UIRect scrolledContentRect = contentRect; + scrolledContentRect.y -= node.scrollOffsetY; + const auto arranged = Layout::ArrangeStackLayout(options, items, scrolledContentRect); + for (std::size_t index = 0; index < node.children.size(); ++index) { + ArrangeNode( + node.children[index], + arranged.children[index].arrangedRect, + verticalScrollOffsets); + } + return; + } + const auto arranged = Layout::ArrangeStackLayout(options, items, contentRect); for (std::size_t index = 0; index < node.children.size(); ++index) { - ArrangeNode(node.children[index], arranged.children[index].arrangedRect); + ArrangeNode( + node.children[index], + arranged.children[index].arrangedRect, + verticalScrollOffsets); + } +} + +RuntimeLayoutNode* FindDeepestScrollTarget( + RuntimeLayoutNode& node, + const UIPoint& point) { + for (RuntimeLayoutNode& child : node.children) { + if (RuntimeLayoutNode* target = FindDeepestScrollTarget(child, point); target != nullptr) { + return target; + } + } + + if (!node.isScrollView) { + return nullptr; + } + + if (!RectContainsPoint(node.scrollViewportRect, point)) { + return nullptr; + } + + if (ComputeScrollOverflow(node.contentDesiredSize.height, node.scrollViewportRect.height) <= 0.0f) { + return nullptr; + } + + return &node; +} + +RuntimeLayoutNode* FindDeepestHoveredScrollView( + RuntimeLayoutNode& node, + const UIPoint& point) { + for (RuntimeLayoutNode& child : node.children) { + if (RuntimeLayoutNode* hovered = FindDeepestHoveredScrollView(child, point); hovered != nullptr) { + return hovered; + } + } + + if (!node.isScrollView) { + return nullptr; + } + + return RectContainsPoint(node.scrollViewportRect, point) + ? &node + : nullptr; +} + +const RuntimeLayoutNode* FindFirstScrollView(const RuntimeLayoutNode& node) { + if (node.isScrollView) { + return &node; + } + + for (const RuntimeLayoutNode& child : node.children) { + if (const RuntimeLayoutNode* found = FindFirstScrollView(child); found != nullptr) { + return found; + } + } + + return nullptr; +} + +bool ApplyScrollWheelInput( + RuntimeLayoutNode& root, + const UIScreenFrameInput& input, + std::unordered_map& verticalScrollOffsets, + UIDocumentScreenHost::ScrollDebugSnapshot& scrollDebugSnapshot) { + bool changed = false; + + for (const UIInputEvent& event : input.events) { + if (event.type != UIInputEventType::PointerWheel) { + continue; + } + + ++scrollDebugSnapshot.totalWheelEventCount; + scrollDebugSnapshot.lastPointerPosition = event.position; + scrollDebugSnapshot.lastWheelDelta = event.wheelDelta; + scrollDebugSnapshot.lastTargetStateKey.clear(); + scrollDebugSnapshot.lastViewportRect = {}; + scrollDebugSnapshot.lastOverflow = 0.0f; + scrollDebugSnapshot.lastOffsetBefore = 0.0f; + scrollDebugSnapshot.lastOffsetAfter = 0.0f; + scrollDebugSnapshot.lastResult = "No hovered ScrollView"; + + RuntimeLayoutNode* hoveredScrollView = FindDeepestHoveredScrollView(root, event.position); + if (hoveredScrollView == nullptr) { + continue; + } + + scrollDebugSnapshot.lastTargetStateKey = hoveredScrollView->stateKey; + scrollDebugSnapshot.lastViewportRect = hoveredScrollView->scrollViewportRect; + scrollDebugSnapshot.lastOverflow = ComputeScrollOverflow( + hoveredScrollView->contentDesiredSize.height, + hoveredScrollView->scrollViewportRect.height); + + if (scrollDebugSnapshot.lastOverflow <= 0.0f) { + scrollDebugSnapshot.lastResult = "Hovered ScrollView has no overflow"; + continue; + } + + RuntimeLayoutNode* target = FindDeepestScrollTarget(root, event.position); + if (target == nullptr) { + scrollDebugSnapshot.lastResult = "Scroll target resolution failed"; + continue; + } + + const auto found = verticalScrollOffsets.find(target->stateKey); + const float oldOffset = found != verticalScrollOffsets.end() + ? found->second + : target->scrollOffsetY; + scrollDebugSnapshot.lastOffsetBefore = oldOffset; + + const float scrollUnits = event.wheelDelta / 120.0f; + const float nextOffset = ClampScrollOffset( + oldOffset - scrollUnits * 48.0f, + target->contentDesiredSize.height, + target->scrollViewportRect.height); + scrollDebugSnapshot.lastOffsetAfter = nextOffset; + if (std::fabs(nextOffset - oldOffset) <= 0.01f) { + scrollDebugSnapshot.lastResult = "Scroll delta clamped to current offset"; + continue; + } + + verticalScrollOffsets[target->stateKey] = nextOffset; + ++scrollDebugSnapshot.handledWheelEventCount; + scrollDebugSnapshot.lastResult = "Handled"; + changed = true; + } + + return changed; +} + +void SyncScrollOffsets( + const RuntimeLayoutNode& node, + std::unordered_map& verticalScrollOffsets) { + if (node.isScrollView) { + const float overflow = ComputeScrollOverflow(node.contentDesiredSize.height, node.scrollViewportRect.height); + if (overflow <= 0.0f || node.scrollOffsetY <= 0.0f) { + verticalScrollOffsets.erase(node.stateKey); + } else { + verticalScrollOffsets[node.stateKey] = node.scrollOffsetY; + } + } + + for (const RuntimeLayoutNode& child : node.children) { + SyncScrollOffsets(child, verticalScrollOffsets); } } @@ -451,9 +679,21 @@ void EmitNode( ++stats.textCommandCount; } + const bool pushScrollClip = + node.isScrollView && + node.scrollViewportRect.width > 0.0f && + node.scrollViewportRect.height > 0.0f; + if (pushScrollClip) { + drawList.PushClipRect(node.scrollViewportRect); + } + for (const RuntimeLayoutNode& child : node.children) { EmitNode(child, drawList, stats); } + + if (pushScrollClip) { + drawList.PopClipRect(); + } } std::string ResolveDisplayName( @@ -565,7 +805,10 @@ UIScreenFrameResult UIDocumentScreenHost::BuildFrame( return result; } - RuntimeLayoutNode root = BuildLayoutTree(document.viewDocument.rootNode); + const std::string stateRoot = document.sourcePath.empty() + ? document.displayName + : document.sourcePath; + RuntimeLayoutNode root = BuildLayoutTree(document.viewDocument.rootNode, stateRoot, 0u); MeasureNode(root); UIRect viewportRect = input.viewportRect; @@ -576,7 +819,23 @@ UIScreenFrameResult UIDocumentScreenHost::BuildFrame( viewportRect.height = (std::max)(360.0f, root.desiredSize.height); } - ArrangeNode(root, viewportRect); + ArrangeNode(root, viewportRect, m_verticalScrollOffsets); + if (ApplyScrollWheelInput(root, input, m_verticalScrollOffsets, m_scrollDebugSnapshot)) { + ArrangeNode(root, viewportRect, m_verticalScrollOffsets); + } + SyncScrollOffsets(root, m_verticalScrollOffsets); + + if (const RuntimeLayoutNode* primaryScrollView = FindFirstScrollView(root); primaryScrollView != nullptr) { + m_scrollDebugSnapshot.primaryTargetStateKey = primaryScrollView->stateKey; + m_scrollDebugSnapshot.primaryViewportRect = primaryScrollView->scrollViewportRect; + m_scrollDebugSnapshot.primaryOverflow = ComputeScrollOverflow( + primaryScrollView->contentDesiredSize.height, + primaryScrollView->scrollViewportRect.height); + } else { + m_scrollDebugSnapshot.primaryTargetStateKey.clear(); + m_scrollDebugSnapshot.primaryViewportRect = {}; + m_scrollDebugSnapshot.primaryOverflow = 0.0f; + } UIDrawList& drawList = result.drawData.EmplaceDrawList(document.displayName); EmitNode(root, drawList, result.stats); @@ -589,6 +848,10 @@ UIScreenFrameResult UIDocumentScreenHost::BuildFrame( return result; } +const UIDocumentScreenHost::ScrollDebugSnapshot& UIDocumentScreenHost::GetScrollDebugSnapshot() const { + return m_scrollDebugSnapshot; +} + } // namespace Runtime } // namespace UI } // namespace XCEngine diff --git a/new_editor/src/Application.cpp b/new_editor/src/Application.cpp index ece54551..96b90580 100644 --- a/new_editor/src/Application.cpp +++ b/new_editor/src/Application.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -22,6 +23,9 @@ namespace { using ::XCEngine::UI::UIColor; using ::XCEngine::UI::UIDrawData; using ::XCEngine::UI::UIDrawList; +using ::XCEngine::UI::UIInputEvent; +using ::XCEngine::UI::UIInputEventType; +using ::XCEngine::UI::UIInputModifiers; using ::XCEngine::UI::UIPoint; using ::XCEngine::UI::UIRect; using ::XCEngine::UI::Runtime::UIScreenFrameInput; @@ -53,6 +57,35 @@ std::string TruncateText(const std::string& text, std::size_t maxLength) { return text.substr(0, maxLength - 3u) + "..."; } +std::string FormatFloat(float value) { + std::ostringstream stream; + stream.setf(std::ios::fixed, std::ios::floatfield); + stream.precision(1); + stream << value; + return stream.str(); +} + +std::string FormatPoint(const UIPoint& point) { + return "(" + FormatFloat(point.x) + ", " + FormatFloat(point.y) + ")"; +} + +std::string FormatRect(const UIRect& rect) { + return "(" + FormatFloat(rect.x) + + ", " + FormatFloat(rect.y) + + ", " + FormatFloat(rect.width) + + ", " + FormatFloat(rect.height) + + ")"; +} + +UIInputModifiers BuildInputModifiers(size_t wParam) { + UIInputModifiers modifiers = {}; + modifiers.shift = (wParam & MK_SHIFT) != 0; + modifiers.control = (wParam & MK_CONTROL) != 0; + modifiers.alt = (GetKeyState(VK_MENU) & 0x8000) != 0; + modifiers.super = (GetKeyState(VK_LWIN) & 0x8000) != 0 || (GetKeyState(VK_RWIN) & 0x8000) != 0; + return modifiers; +} + } // namespace Application::Application() @@ -170,11 +203,14 @@ void Application::RenderFrame() { m_lastFrameTime = now; RefreshStructuredScreen(); + std::vector frameEvents = std::move(m_pendingInputEvents); + m_pendingInputEvents.clear(); UIDrawData drawData = {}; if (m_useStructuredScreen && m_screenPlayer.IsLoaded()) { UIScreenFrameInput input = {}; input.viewportRect = UIRect(0.0f, 0.0f, width, height); + input.events = std::move(frameEvents); input.deltaTimeSeconds = deltaTimeSeconds; input.frameIndex = ++m_frameIndex; input.focused = GetForegroundWindow() == m_hwnd; @@ -219,6 +255,26 @@ void Application::OnResize(UINT width, UINT height) { m_renderer.Resize(width, height); } +void Application::QueuePointerWheelEvent(short wheelDelta, WPARAM wParam, LPARAM lParam) { + if (m_hwnd == nullptr) { + return; + } + + POINT screenPoint = { + GET_X_LPARAM(lParam), + GET_Y_LPARAM(lParam) + }; + ScreenToClient(m_hwnd, &screenPoint); + + UIInputEvent event = {}; + event.type = UIInputEventType::PointerWheel; + event.position = UIPoint(static_cast(screenPoint.x), static_cast(screenPoint.y)); + event.wheelDelta = static_cast(wheelDelta); + event.modifiers = BuildInputModifiers(static_cast(wParam)); + m_pendingInputEvents.push_back(event); + m_autoScreenshot.RequestCapture("wheel"); +} + bool Application::LoadStructuredScreen(const char* triggerReason) { m_screenAsset = {}; m_screenAsset.screenId = "new_editor.editor_shell"; @@ -316,13 +372,58 @@ bool Application::DetectTrackedFileChange() const { void Application::AppendRuntimeOverlay(UIDrawData& drawData, float width, float height) const { const bool authoredMode = m_useStructuredScreen && m_screenPlayer.IsLoaded(); - const float panelWidth = authoredMode ? 256.0f : 360.0f; + const float panelWidth = authoredMode ? 420.0f : 360.0f; std::vector detailLines = {}; detailLines.push_back( authoredMode ? "Hot reload watches authored UI resources." : "Using native fallback while authored UI is invalid."); + if (authoredMode) { + const auto& scrollDebug = m_documentHost.GetScrollDebugSnapshot(); + if (!scrollDebug.primaryTargetStateKey.empty()) { + detailLines.push_back( + "Primary: " + + TruncateText(scrollDebug.primaryTargetStateKey, 52u)); + detailLines.push_back( + "Primary viewport: " + + FormatRect(scrollDebug.primaryViewportRect)); + detailLines.push_back( + "Primary overflow: " + + FormatFloat(scrollDebug.primaryOverflow)); + } + detailLines.push_back( + "Wheel total/handled: " + + std::to_string(scrollDebug.totalWheelEventCount) + + " / " + + std::to_string(scrollDebug.handledWheelEventCount)); + if (scrollDebug.totalWheelEventCount > 0u) { + detailLines.push_back( + "Last wheel " + + FormatFloat(scrollDebug.lastWheelDelta) + + " at " + + FormatPoint(scrollDebug.lastPointerPosition)); + detailLines.push_back( + "Result: " + + (scrollDebug.lastResult.empty() ? std::string("n/a") : scrollDebug.lastResult)); + if (!scrollDebug.lastTargetStateKey.empty()) { + detailLines.push_back( + "Target: " + + TruncateText(scrollDebug.lastTargetStateKey, 52u)); + detailLines.push_back( + "Viewport: " + + FormatRect(scrollDebug.lastViewportRect)); + detailLines.push_back( + "Overflow/offset: " + + FormatFloat(scrollDebug.lastOverflow) + + " | " + + FormatFloat(scrollDebug.lastOffsetBefore) + + " -> " + + FormatFloat(scrollDebug.lastOffsetAfter)); + } + } + } + if (m_autoScreenshot.HasPendingCapture()) { detailLines.push_back("Shot pending..."); } else if (!m_autoScreenshot.GetLastCaptureSummary().empty()) { @@ -393,6 +494,12 @@ LRESULT CALLBACK Application::WndProc(HWND hwnd, UINT message, WPARAM wParam, LP return 0; } break; + case WM_MOUSEWHEEL: + if (application != nullptr) { + application->QueuePointerWheelEvent(GET_WHEEL_DELTA_WPARAM(wParam), wParam, lParam); + return 0; + } + break; case WM_ERASEBKGND: return 1; case WM_DESTROY: diff --git a/new_editor/src/Application.h b/new_editor/src/Application.h index fcc20347..8412a452 100644 --- a/new_editor/src/Application.h +++ b/new_editor/src/Application.h @@ -11,6 +11,7 @@ #include #include +#include #include #include @@ -40,6 +41,7 @@ private: void Shutdown(); void RenderFrame(); void OnResize(UINT width, UINT height); + void QueuePointerWheelEvent(short wheelDelta, WPARAM wParam, LPARAM lParam); bool LoadStructuredScreen(const char* triggerReason); void RefreshStructuredScreen(); void RebuildTrackedFileStates(); @@ -60,6 +62,7 @@ private: std::chrono::steady_clock::time_point m_lastFrameTime = {}; std::chrono::steady_clock::time_point m_lastReloadPollTime = {}; std::uint64_t m_frameIndex = 0; + std::vector<::XCEngine::UI::UIInputEvent> m_pendingInputEvents = {}; bool m_useStructuredScreen = false; std::string m_runtimeStatus = {}; std::string m_runtimeError = {}; diff --git a/new_editor/ui/views/editor_shell.xcui b/new_editor/ui/views/editor_shell.xcui index 100cf302..e07afc1a 100644 --- a/new_editor/ui/views/editor_shell.xcui +++ b/new_editor/ui/views/editor_shell.xcui @@ -46,13 +46,23 @@ - - - - - - - + + + + + + + + + + + + + + + + + diff --git a/tests/Core/UI/test_ui_runtime.cpp b/tests/Core/UI/test_ui_runtime.cpp index ddac0647..3fa10d08 100644 --- a/tests/Core/UI/test_ui_runtime.cpp +++ b/tests/Core/UI/test_ui_runtime.cpp @@ -153,6 +153,21 @@ bool TryFindSmallestFilledRectContainingPoint( return found; } +std::size_t CountCommandsOfType( + const XCEngine::UI::UIDrawData& drawData, + XCEngine::UI::UIDrawCommandType type) { + std::size_t count = 0u; + for (const XCEngine::UI::UIDrawList& drawList : drawData.GetDrawLists()) { + for (const XCEngine::UI::UIDrawCommand& command : drawList.GetCommands()) { + if (command.type == type) { + ++count; + } + } + } + + return count; +} + UIScreenFrameInput BuildInputState(std::uint64_t frameIndex = 1u) { UIScreenFrameInput input = {}; input.viewportRect = XCEngine::UI::UIRect(0.0f, 0.0f, 800.0f, 480.0f); @@ -308,6 +323,67 @@ TEST(UIRuntimeTest, DocumentHostDoesNotLetExplicitHeightCrushCardContent) { EXPECT_LE(buttonRect.y + buttonRect.height, cardRect.y + cardRect.height - 8.0f); } +TEST(UIRuntimeTest, DocumentHostScrollViewClipsOverflowingChildrenAndRespondsToWheelInput) { + TempFileScope viewFile( + "xcui_runtime_scroll_view", + ".xcui", + "\n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + "\n"); + UIDocumentScreenHost host = {}; + UIScreenPlayer player(host); + + ASSERT_TRUE(player.Load(BuildScreenAsset(viewFile.Path(), "runtime.scroll.view"))); + + UIScreenFrameInput firstInput = BuildInputState(1u); + firstInput.viewportRect = XCEngine::UI::UIRect(0.0f, 0.0f, 480.0f, 320.0f); + const auto& firstFrame = player.Update(firstInput); + const auto* line01Before = FindTextCommand(firstFrame.drawData, "Line 01"); + ASSERT_NE(line01Before, nullptr); + const float line01BeforeY = line01Before->position.y; + EXPECT_GT(CountCommandsOfType(firstFrame.drawData, XCEngine::UI::UIDrawCommandType::PushClipRect), 0u); + EXPECT_GT(CountCommandsOfType(firstFrame.drawData, XCEngine::UI::UIDrawCommandType::PopClipRect), 0u); + + UIScreenFrameInput secondInput = BuildInputState(2u); + secondInput.viewportRect = firstInput.viewportRect; + XCEngine::UI::UIInputEvent wheelEvent = {}; + wheelEvent.type = XCEngine::UI::UIInputEventType::PointerWheel; + wheelEvent.position = XCEngine::UI::UIPoint(90.0f, 130.0f); + wheelEvent.wheelDelta = -120.0f; + secondInput.events.push_back(wheelEvent); + const auto& secondFrame = player.Update(secondInput); + const auto* line01After = FindTextCommand(secondFrame.drawData, "Line 01"); + ASSERT_NE(line01After, nullptr); + const float line01AfterY = line01After->position.y; + EXPECT_LT(line01AfterY, line01BeforeY); + + UIScreenFrameInput thirdInput = BuildInputState(3u); + thirdInput.viewportRect = firstInput.viewportRect; + const auto& thirdFrame = player.Update(thirdInput); + const auto* line01Persisted = FindTextCommand(thirdFrame.drawData, "Line 01"); + ASSERT_NE(line01Persisted, nullptr); + EXPECT_FLOAT_EQ(line01Persisted->position.y, line01AfterY); +} + TEST(UIRuntimeTest, ScreenPlayerConsumeLastFrameReturnsDetachedPacketAndClearsBorrowedState) { TempFileScope viewFile("xcui_runtime_consume_player", ".xcui", BuildViewMarkup("Runtime Consume")); UIDocumentScreenHost host = {};