Add XCUI core scroll view validation in new_editor
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
#include <XCEngine/UI/Runtime/UIScreenTypes.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
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<std::string, float> m_verticalScrollOffsets = {};
|
||||
ScrollDebugSnapshot m_scrollDebugSnapshot = {};
|
||||
};
|
||||
|
||||
} // namespace Runtime
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
@@ -39,9 +40,15 @@ constexpr float kHeaderTextGap = 2.0f;
|
||||
|
||||
struct RuntimeLayoutNode {
|
||||
const UIDocumentNode* source = nullptr;
|
||||
std::string stateKey = {};
|
||||
std::vector<RuntimeLayoutNode> 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<Layout::UILayoutItem> items = {};
|
||||
items.reserve(node.children.size());
|
||||
std::vector<Layout::UILayoutItem> desiredItems = {};
|
||||
desiredItems.reserve(node.children.size());
|
||||
std::vector<Layout::UILayoutItem> 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<std::string, float>& 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<Layout::UILayoutItem> 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<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;
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user