Add XCUI core scroll view validation in new_editor

This commit is contained in:
2026-04-05 21:27:00 +08:00
parent 05debc0499
commit 7812b92992
6 changed files with 518 additions and 34 deletions

View File

@@ -2,6 +2,10 @@
#include <XCEngine/UI/Runtime/UIScreenTypes.h> #include <XCEngine/UI/Runtime/UIScreenTypes.h>
#include <cstdint>
#include <string>
#include <unordered_map>
namespace XCEngine { namespace XCEngine {
namespace UI { namespace UI {
namespace Runtime { namespace Runtime {
@@ -18,10 +22,31 @@ public:
class UIDocumentScreenHost final : public IUIScreenDocumentHost { class UIDocumentScreenHost final : public IUIScreenDocumentHost {
public: 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; UIScreenLoadResult LoadScreen(const UIScreenAsset& asset) override;
UIScreenFrameResult BuildFrame( UIScreenFrameResult BuildFrame(
const UIScreenDocument& document, const UIScreenDocument& document,
const UIScreenFrameInput& input) override; const UIScreenFrameInput& input) override;
const ScrollDebugSnapshot& GetScrollDebugSnapshot() const;
private:
std::unordered_map<std::string, float> m_verticalScrollOffsets = {};
ScrollDebugSnapshot m_scrollDebugSnapshot = {};
}; };
} // namespace Runtime } // namespace Runtime

View File

@@ -7,6 +7,7 @@
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
#include <cmath>
#include <cstdlib> #include <cstdlib>
#include <filesystem> #include <filesystem>
#include <string> #include <string>
@@ -39,9 +40,15 @@ constexpr float kHeaderTextGap = 2.0f;
struct RuntimeLayoutNode { struct RuntimeLayoutNode {
const UIDocumentNode* source = nullptr; const UIDocumentNode* source = nullptr;
std::string stateKey = {};
std::vector<RuntimeLayoutNode> children = {}; std::vector<RuntimeLayoutNode> children = {};
UISize desiredSize = {}; UISize desiredSize = {};
UISize minimumSize = {};
UISize contentDesiredSize = {};
UIRect rect = {}; UIRect rect = {};
UIRect scrollViewportRect = {};
float scrollOffsetY = 0.0f;
bool isScrollView = false;
}; };
UIColor ToUIColor(const Color& color) { UIColor ToUIColor(const Color& color) {
@@ -185,23 +192,14 @@ Layout::UILayoutThickness ParsePadding(
Layout::UILayoutItem BuildLayoutItem( Layout::UILayoutItem BuildLayoutItem(
const RuntimeLayoutNode& child, const RuntimeLayoutNode& child,
Layout::UILayoutAxis parentAxis) { Layout::UILayoutAxis parentAxis,
const UISize& measuredContentSize) {
Layout::UILayoutItem item = {}; Layout::UILayoutItem item = {};
item.desiredContentSize = child.desiredSize; item.desiredContentSize = measuredContentSize;
item.minSize = child.minimumSize;
item.width = ParseLengthAttribute(*child.source, "width"); item.width = ParseLengthAttribute(*child.source, "width");
item.height = ParseLengthAttribute(*child.source, "height"); 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 && if (parentAxis == Layout::UILayoutAxis::Vertical &&
item.width.unit == Layout::UILayoutLengthUnit::Auto) { item.width.unit == Layout::UILayoutLengthUnit::Auto) {
item.horizontalAlignment = Layout::UILayoutAlignment::Stretch; item.horizontalAlignment = Layout::UILayoutAlignment::Stretch;
@@ -233,6 +231,10 @@ bool IsHorizontalTag(const std::string& tagName) {
return tagName == "Row"; return tagName == "Row";
} }
bool IsScrollViewTag(const std::string& tagName) {
return tagName == "ScrollView";
}
bool IsContainerTag(const UIDocumentNode& node) { bool IsContainerTag(const UIDocumentNode& node) {
if (node.children.Size() > 0u) { if (node.children.Size() > 0u) {
return true; return true;
@@ -242,10 +244,42 @@ bool IsContainerTag(const UIDocumentNode& node) {
return tagName == "View" || return tagName == "View" ||
tagName == "Column" || tagName == "Column" ||
tagName == "Row" || tagName == "Row" ||
tagName == "ScrollView" ||
tagName == "Card" || tagName == "Card" ||
tagName == "Button"; 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) { Color ResolveBackgroundColor(const UIDocumentNode& node) {
const std::string tone = GetAttribute(node, "tone"); const std::string tone = GetAttribute(node, "tone");
const std::string tagName = ToStdString(node.tagName); 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); 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 = {}; RuntimeLayoutNode node = {};
node.source = &source; node.source = &source;
node.stateKey = parentStateKey + "/" + BuildNodeStateKeySegment(source, siblingIndex);
node.isScrollView = IsScrollViewTag(ToStdString(source.tagName));
node.children.reserve(source.children.Size()); node.children.reserve(source.children.Size());
for (const UIDocumentNode& child : source.children) { for (std::size_t index = 0; index < source.children.Size(); ++index) {
node.children.push_back(BuildLayoutTree(child)); node.children.push_back(BuildLayoutTree(source.children[index], node.stateKey, index));
} }
return node; return node;
} }
@@ -297,6 +336,7 @@ UISize MeasureNode(RuntimeLayoutNode& node) {
node.desiredSize = UISize( node.desiredSize = UISize(
MeasureTextWidth(text, kDefaultFontSize), MeasureTextWidth(text, kDefaultFontSize),
MeasureTextHeight(kDefaultFontSize)); MeasureTextHeight(kDefaultFontSize));
node.minimumSize = node.desiredSize;
return node.desiredSize; return node.desiredSize;
} }
@@ -304,6 +344,7 @@ UISize MeasureNode(RuntimeLayoutNode& node) {
node.desiredSize = UISize( node.desiredSize = UISize(
(std::max)(160.0f, MeasureTextWidth(ResolveNodeText(source), kDefaultFontSize) + 24.0f), (std::max)(160.0f, MeasureTextWidth(ResolveNodeText(source), kDefaultFontSize) + 24.0f),
44.0f); 44.0f);
node.minimumSize = node.desiredSize;
return node.desiredSize; return node.desiredSize;
} }
@@ -319,16 +360,21 @@ UISize MeasureNode(RuntimeLayoutNode& node) {
source, source,
tagName == "View" ? 16.0f : 12.0f); tagName == "View" ? 16.0f : 12.0f);
std::vector<Layout::UILayoutItem> items = {}; std::vector<Layout::UILayoutItem> desiredItems = {};
items.reserve(node.children.size()); desiredItems.reserve(node.children.size());
std::vector<Layout::UILayoutItem> minimumItems = {};
minimumItems.reserve(node.children.size());
for (RuntimeLayoutNode& child : node.children) { for (RuntimeLayoutNode& child : node.children) {
MeasureNode(child); MeasureNode(child);
Layout::UILayoutItem item = BuildLayoutItem(child, options.axis); desiredItems.push_back(BuildLayoutItem(child, options.axis, child.desiredSize));
items.push_back(item); 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.desiredSize = measured.desiredSize;
node.minimumSize = minimumMeasured.desiredSize;
const float headerHeight = MeasureHeaderHeight(source); const float headerHeight = MeasureHeaderHeight(source);
const float headerTextWidth = MeasureHeaderTextWidth(source); const float headerTextWidth = MeasureHeaderTextWidth(source);
@@ -339,6 +385,16 @@ UISize MeasureNode(RuntimeLayoutNode& node) {
? headerTextWidth + options.padding.Horizontal() ? headerTextWidth + options.padding.Horizontal()
: MeasureTextWidth(ResolveNodeText(source), kDefaultFontSize) + options.padding.Horizontal()); : MeasureTextWidth(ResolveNodeText(source), kDefaultFontSize) + options.padding.Horizontal());
node.desiredSize.height += headerHeight; 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; float explicitWidth = 0.0f;
if (TryParseFloat(GetAttribute(source, "width"), explicitWidth)) { if (TryParseFloat(GetAttribute(source, "width"), explicitWidth)) {
@@ -353,8 +409,13 @@ UISize MeasureNode(RuntimeLayoutNode& node) {
return node.desiredSize; return node.desiredSize;
} }
void ArrangeNode(RuntimeLayoutNode& node, const UIRect& rect) { void ArrangeNode(
RuntimeLayoutNode& node,
const UIRect& rect,
const std::unordered_map<std::string, float>& verticalScrollOffsets) {
node.rect = rect; node.rect = rect;
node.scrollViewportRect = {};
node.scrollOffsetY = 0.0f;
const UIDocumentNode& source = *node.source; const UIDocumentNode& source = *node.source;
if (!IsContainerTag(source)) { if (!IsContainerTag(source)) {
@@ -383,13 +444,180 @@ void ArrangeNode(RuntimeLayoutNode& node, const UIRect& rect) {
std::vector<Layout::UILayoutItem> items = {}; std::vector<Layout::UILayoutItem> items = {};
items.reserve(node.children.size()); items.reserve(node.children.size());
for (RuntimeLayoutNode& child : node.children) { 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); 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); const auto arranged = Layout::ArrangeStackLayout(options, items, contentRect);
for (std::size_t index = 0; index < node.children.size(); ++index) { 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<std::string, float>& 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<std::string, float>& 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; ++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) { for (const RuntimeLayoutNode& child : node.children) {
EmitNode(child, drawList, stats); EmitNode(child, drawList, stats);
} }
if (pushScrollClip) {
drawList.PopClipRect();
}
} }
std::string ResolveDisplayName( std::string ResolveDisplayName(
@@ -565,7 +805,10 @@ UIScreenFrameResult UIDocumentScreenHost::BuildFrame(
return result; 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); MeasureNode(root);
UIRect viewportRect = input.viewportRect; UIRect viewportRect = input.viewportRect;
@@ -576,7 +819,23 @@ UIScreenFrameResult UIDocumentScreenHost::BuildFrame(
viewportRect.height = (std::max)(360.0f, root.desiredSize.height); 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); UIDrawList& drawList = result.drawData.EmplaceDrawList(document.displayName);
EmitNode(root, drawList, result.stats); EmitNode(root, drawList, result.stats);
@@ -589,6 +848,10 @@ UIScreenFrameResult UIDocumentScreenHost::BuildFrame(
return result; return result;
} }
const UIDocumentScreenHost::ScrollDebugSnapshot& UIDocumentScreenHost::GetScrollDebugSnapshot() const {
return m_scrollDebugSnapshot;
}
} // namespace Runtime } // namespace Runtime
} // namespace UI } // namespace UI
} // namespace XCEngine } // namespace XCEngine

View File

@@ -5,6 +5,7 @@
#include <algorithm> #include <algorithm>
#include <chrono> #include <chrono>
#include <filesystem> #include <filesystem>
#include <sstream>
#include <string> #include <string>
#include <system_error> #include <system_error>
#include <unordered_set> #include <unordered_set>
@@ -22,6 +23,9 @@ namespace {
using ::XCEngine::UI::UIColor; using ::XCEngine::UI::UIColor;
using ::XCEngine::UI::UIDrawData; using ::XCEngine::UI::UIDrawData;
using ::XCEngine::UI::UIDrawList; using ::XCEngine::UI::UIDrawList;
using ::XCEngine::UI::UIInputEvent;
using ::XCEngine::UI::UIInputEventType;
using ::XCEngine::UI::UIInputModifiers;
using ::XCEngine::UI::UIPoint; using ::XCEngine::UI::UIPoint;
using ::XCEngine::UI::UIRect; using ::XCEngine::UI::UIRect;
using ::XCEngine::UI::Runtime::UIScreenFrameInput; 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) + "..."; 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 } // namespace
Application::Application() Application::Application()
@@ -170,11 +203,14 @@ void Application::RenderFrame() {
m_lastFrameTime = now; m_lastFrameTime = now;
RefreshStructuredScreen(); RefreshStructuredScreen();
std::vector<UIInputEvent> frameEvents = std::move(m_pendingInputEvents);
m_pendingInputEvents.clear();
UIDrawData drawData = {}; UIDrawData drawData = {};
if (m_useStructuredScreen && m_screenPlayer.IsLoaded()) { if (m_useStructuredScreen && m_screenPlayer.IsLoaded()) {
UIScreenFrameInput input = {}; UIScreenFrameInput input = {};
input.viewportRect = UIRect(0.0f, 0.0f, width, height); input.viewportRect = UIRect(0.0f, 0.0f, width, height);
input.events = std::move(frameEvents);
input.deltaTimeSeconds = deltaTimeSeconds; input.deltaTimeSeconds = deltaTimeSeconds;
input.frameIndex = ++m_frameIndex; input.frameIndex = ++m_frameIndex;
input.focused = GetForegroundWindow() == m_hwnd; input.focused = GetForegroundWindow() == m_hwnd;
@@ -219,6 +255,26 @@ void Application::OnResize(UINT width, UINT height) {
m_renderer.Resize(width, 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<float>(screenPoint.x), static_cast<float>(screenPoint.y));
event.wheelDelta = static_cast<float>(wheelDelta);
event.modifiers = BuildInputModifiers(static_cast<size_t>(wParam));
m_pendingInputEvents.push_back(event);
m_autoScreenshot.RequestCapture("wheel");
}
bool Application::LoadStructuredScreen(const char* triggerReason) { bool Application::LoadStructuredScreen(const char* triggerReason) {
m_screenAsset = {}; m_screenAsset = {};
m_screenAsset.screenId = "new_editor.editor_shell"; 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 { void Application::AppendRuntimeOverlay(UIDrawData& drawData, float width, float height) const {
const bool authoredMode = m_useStructuredScreen && m_screenPlayer.IsLoaded(); 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<std::string> detailLines = {}; std::vector<std::string> detailLines = {};
detailLines.push_back( detailLines.push_back(
authoredMode authoredMode
? "Hot reload watches authored UI resources." ? "Hot reload watches authored UI resources."
: "Using native fallback while authored UI is invalid."); : "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()) { if (m_autoScreenshot.HasPendingCapture()) {
detailLines.push_back("Shot pending..."); detailLines.push_back("Shot pending...");
} else if (!m_autoScreenshot.GetLastCaptureSummary().empty()) { } else if (!m_autoScreenshot.GetLastCaptureSummary().empty()) {
@@ -393,6 +494,12 @@ LRESULT CALLBACK Application::WndProc(HWND hwnd, UINT message, WPARAM wParam, LP
return 0; return 0;
} }
break; break;
case WM_MOUSEWHEEL:
if (application != nullptr) {
application->QueuePointerWheelEvent(GET_WHEEL_DELTA_WPARAM(wParam), wParam, lParam);
return 0;
}
break;
case WM_ERASEBKGND: case WM_ERASEBKGND:
return 1; return 1;
case WM_DESTROY: case WM_DESTROY:

View File

@@ -11,6 +11,7 @@
#include <XCEngine/UI/Runtime/UIScreenPlayer.h> #include <XCEngine/UI/Runtime/UIScreenPlayer.h>
#include <windows.h> #include <windows.h>
#include <windowsx.h>
#include <chrono> #include <chrono>
#include <cstdint> #include <cstdint>
@@ -40,6 +41,7 @@ private:
void Shutdown(); void Shutdown();
void RenderFrame(); void RenderFrame();
void OnResize(UINT width, UINT height); void OnResize(UINT width, UINT height);
void QueuePointerWheelEvent(short wheelDelta, WPARAM wParam, LPARAM lParam);
bool LoadStructuredScreen(const char* triggerReason); bool LoadStructuredScreen(const char* triggerReason);
void RefreshStructuredScreen(); void RefreshStructuredScreen();
void RebuildTrackedFileStates(); void RebuildTrackedFileStates();
@@ -60,6 +62,7 @@ private:
std::chrono::steady_clock::time_point m_lastFrameTime = {}; std::chrono::steady_clock::time_point m_lastFrameTime = {};
std::chrono::steady_clock::time_point m_lastReloadPollTime = {}; std::chrono::steady_clock::time_point m_lastReloadPollTime = {};
std::uint64_t m_frameIndex = 0; std::uint64_t m_frameIndex = 0;
std::vector<::XCEngine::UI::UIInputEvent> m_pendingInputEvents = {};
bool m_useStructuredScreen = false; bool m_useStructuredScreen = false;
std::string m_runtimeStatus = {}; std::string m_runtimeStatus = {};
std::string m_runtimeError = {}; std::string m_runtimeError = {};

View File

@@ -46,13 +46,23 @@
</Column> </Column>
</Card> </Card>
<Card title="Console" subtitle="runtime smoke log" height="220"> <Card title="Console" subtitle="wheel-scroll core validation" height="220">
<ScrollView id="console-scroll" height="fill">
<Column gap="8"> <Column gap="8">
<Text text="Scroll here with the mouse wheel." />
<Text text="Check that content clips cleanly inside this card." />
<Text text="Info XCUI authored screen loaded." /> <Text text="Info XCUI authored screen loaded." />
<Text text="Info Theme + schema resources are tracked for reload." /> <Text text="Info Theme + schema resources are tracked for reload." />
<Text text="Warn Viewport host stays out of scope in this phase." /> <Text text="Warn Viewport host stays out of scope in this phase." />
<Text text="Todo Replace shell placeholders with Editor widgets." /> <Text text="Todo Replace shell placeholders with Editor widgets." />
<Text text="Trace ScrollView should retain offset across frames." />
<Text text="Trace Wheel input should only affect the hovered view." />
<Text text="Trace Hidden rows must stay clipped below the footer." />
<Text text="Trace Resize should not corrupt the scroll offset clamp." />
<Text text="Trace Hot reload should rebuild layout without tearing." />
<Text text="Trace This panel exists only to validate XCUI core scroll." />
</Column> </Column>
</ScrollView>
</Card> </Card>
</Column> </Column>

View File

@@ -153,6 +153,21 @@ bool TryFindSmallestFilledRectContainingPoint(
return found; 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 BuildInputState(std::uint64_t frameIndex = 1u) {
UIScreenFrameInput input = {}; UIScreenFrameInput input = {};
input.viewportRect = XCEngine::UI::UIRect(0.0f, 0.0f, 800.0f, 480.0f); 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); EXPECT_LE(buttonRect.y + buttonRect.height, cardRect.y + cardRect.height - 8.0f);
} }
TEST(UIRuntimeTest, DocumentHostScrollViewClipsOverflowingChildrenAndRespondsToWheelInput) {
TempFileScope viewFile(
"xcui_runtime_scroll_view",
".xcui",
"<View name=\"Scroll View Test\">\n"
" <Column padding=\"18\" gap=\"10\">\n"
" <Card title=\"Console\" subtitle=\"Scroll smoke\" height=\"200\">\n"
" <ScrollView id=\"log-scroll\" height=\"fill\">\n"
" <Column gap=\"8\">\n"
" <Text text=\"Line 01\" />\n"
" <Text text=\"Line 02\" />\n"
" <Text text=\"Line 03\" />\n"
" <Text text=\"Line 04\" />\n"
" <Text text=\"Line 05\" />\n"
" <Text text=\"Line 06\" />\n"
" <Text text=\"Line 07\" />\n"
" <Text text=\"Line 08\" />\n"
" <Text text=\"Line 09\" />\n"
" <Text text=\"Line 10\" />\n"
" <Text text=\"Line 11\" />\n"
" <Text text=\"Line 12\" />\n"
" </Column>\n"
" </ScrollView>\n"
" </Card>\n"
" </Column>\n"
"</View>\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) { TEST(UIRuntimeTest, ScreenPlayerConsumeLastFrameReturnsDetachedPacketAndClearsBorrowedState) {
TempFileScope viewFile("xcui_runtime_consume_player", ".xcui", BuildViewMarkup("Runtime Consume")); TempFileScope viewFile("xcui_runtime_consume_player", ".xcui", BuildViewMarkup("Runtime Consume"));
UIDocumentScreenHost host = {}; UIDocumentScreenHost host = {};