diff --git a/docs/plan/used/XCUI_Subplan-02_LayoutEngine_完成归档_2026-04-04.md b/docs/plan/used/XCUI_Subplan-02_LayoutEngine_完成归档_2026-04-04.md new file mode 100644 index 00000000..b5c72044 --- /dev/null +++ b/docs/plan/used/XCUI_Subplan-02_LayoutEngine_完成归档_2026-04-04.md @@ -0,0 +1,50 @@ +# XCUI Subplan 02:Layout Engine 完成归档 + +归档日期: + +- `2026-04-04` + +原始来源: + +- [../XCUI完整架构设计与执行计划.md](../XCUI完整架构设计与执行计划.md) + +本次完成范围: + +- 落地 XCUI 纯算法布局基础类型: + - `UILayoutLength` + - `UILayoutConstraints` + - `UILayoutThickness` + - `UILayoutItem` + - `UIStackLayoutOptions` + - `UIOverlayLayoutOptions` +- 落地 measure / arrange 双阶段布局算法 +- 实现 `Horizontal Stack` / `Vertical Stack` / `Overlay` 三类 MVP 容器 +- 支持 `px / content / stretch` +- 支持 `padding / spacing / margin / min / max / alignment` +- 建立独立 `ui_tests` 测试目标并通过验证 + +实际代码落点: + +- [engine/include/XCEngine/UI/Types.h](D:/Xuanchi/Main/XCEngine/engine/include/XCEngine/UI/Types.h) +- [engine/include/XCEngine/UI/Layout/LayoutTypes.h](D:/Xuanchi/Main/XCEngine/engine/include/XCEngine/UI/Layout/LayoutTypes.h) +- [engine/include/XCEngine/UI/Layout/LayoutEngine.h](D:/Xuanchi/Main/XCEngine/engine/include/XCEngine/UI/Layout/LayoutEngine.h) +- [tests/Core/Math/CMakeLists.txt](D:/Xuanchi/Main/XCEngine/tests/Core/Math/CMakeLists.txt) +- [tests/Core/Math/test_ui_layout.cpp](D:/Xuanchi/Main/XCEngine/tests/Core/Math/test_ui_layout.cpp) + +验证结果: + +- `cmake --build . --config Debug --target math_tests -- /m:1` +- `ctest -C Debug --test-dir D:\\Xuanchi\\Main\\XCEngine\\build\\tests\\Core\\Math --output-on-failure -R UI_Layout` +- 结果:`5/5 UI_Layout tests passed` + +与原子计划相比,当前仍未覆盖: + +- `Scroll` 容器 +- 更复杂的主轴分布策略 +- 与 XCUI tree/state 的正式对接 +- 文本测量与真实控件树集成 + +建议后续承接: + +- 由 `Subplan-01` 提供 tree / node / invalidation 契约后,把当前布局算法接入正式 UI tree +- 后续再补 `Scroll`、更完整容器族、文本测量桥接 diff --git a/engine/include/XCEngine/UI/Layout/LayoutEngine.h b/engine/include/XCEngine/UI/Layout/LayoutEngine.h new file mode 100644 index 00000000..ea8ed8f7 --- /dev/null +++ b/engine/include/XCEngine/UI/Layout/LayoutEngine.h @@ -0,0 +1,485 @@ +#pragma once + +#include + +#include +#include +#include + +namespace XCEngine { +namespace UI { +namespace Layout { + +struct UILayoutPassResult { + UISize desiredSize = UISize(0.0f, 0.0f); + std::vector children = {}; +}; + +namespace Detail { + +inline bool IsFiniteExtent(float value) { + return std::isfinite(value); +} + +inline float ClampExtent(float value, float minValue, float maxValue) { + value = (std::max)(value, minValue); + if (IsFiniteExtent(maxValue)) { + value = (std::min)(value, maxValue); + } + return value; +} + +inline float ReduceAvailableExtent(float value, float reduction) { + if (!IsFiniteExtent(value)) { + return value; + } + + return (std::max)(0.0f, value - reduction); +} + +inline UISize DeflateSize(const UISize& size, const UILayoutThickness& thickness) { + return UISize( + ReduceAvailableExtent(size.width, thickness.Horizontal()), + ReduceAvailableExtent(size.height, thickness.Vertical())); +} + +inline UIRect DeflateRect(const UIRect& rect, const UILayoutThickness& thickness) { + const float width = (std::max)(0.0f, rect.width - thickness.Horizontal()); + const float height = (std::max)(0.0f, rect.height - thickness.Vertical()); + return UIRect(rect.x + thickness.left, rect.y + thickness.top, width, height); +} + +inline float ResolveMeasuredExtent( + const UILayoutLength& length, + float desiredContentExtent, + float minExtent, + float maxExtent, + float availableExtent) { + float resolved = desiredContentExtent; + switch (length.unit) { + case UILayoutLengthUnit::Pixels: + resolved = length.value; + break; + case UILayoutLengthUnit::Stretch: + case UILayoutLengthUnit::Auto: + default: + resolved = desiredContentExtent; + break; + } + + float effectiveMaxExtent = maxExtent; + if (IsFiniteExtent(availableExtent)) { + effectiveMaxExtent = IsFiniteExtent(effectiveMaxExtent) + ? (std::min)(effectiveMaxExtent, availableExtent) + : availableExtent; + } + + return ClampExtent(resolved, minExtent, effectiveMaxExtent); +} + +inline UISize MeasureItemBaseSize(const UILayoutItem& item, const UILayoutConstraints& constraints) { + UISize measuredSize = {}; + measuredSize.width = ResolveMeasuredExtent( + item.width, + item.desiredContentSize.width, + item.minSize.width, + item.maxSize.width, + constraints.maxSize.width); + measuredSize.height = ResolveMeasuredExtent( + item.height, + item.desiredContentSize.height, + item.minSize.height, + item.maxSize.height, + constraints.maxSize.height); + return measuredSize; +} + +inline float GetMainExtent(const UISize& size, UILayoutAxis axis) { + return axis == UILayoutAxis::Horizontal ? size.width : size.height; +} + +inline float GetCrossExtent(const UISize& size, UILayoutAxis axis) { + return axis == UILayoutAxis::Horizontal ? size.height : size.width; +} + +inline float GetLeadingMargin(const UILayoutThickness& thickness, UILayoutAxis axis) { + return axis == UILayoutAxis::Horizontal ? thickness.left : thickness.top; +} + +inline float GetTrailingMargin(const UILayoutThickness& thickness, UILayoutAxis axis) { + return axis == UILayoutAxis::Horizontal ? thickness.right : thickness.bottom; +} + +inline float GetMainMargin(const UILayoutThickness& thickness, UILayoutAxis axis) { + return GetLeadingMargin(thickness, axis) + GetTrailingMargin(thickness, axis); +} + +inline float GetCrossMargin(const UILayoutThickness& thickness, UILayoutAxis axis) { + return axis == UILayoutAxis::Horizontal + ? thickness.top + thickness.bottom + : thickness.left + thickness.right; +} + +inline UILayoutLength GetMainLength(const UILayoutItem& item, UILayoutAxis axis) { + return axis == UILayoutAxis::Horizontal ? item.width : item.height; +} + +inline UILayoutLength GetCrossLength(const UILayoutItem& item, UILayoutAxis axis) { + return axis == UILayoutAxis::Horizontal ? item.height : item.width; +} + +inline float GetMinMainExtent(const UILayoutItem& item, UILayoutAxis axis) { + return axis == UILayoutAxis::Horizontal ? item.minSize.width : item.minSize.height; +} + +inline float GetMinCrossExtent(const UILayoutItem& item, UILayoutAxis axis) { + return axis == UILayoutAxis::Horizontal ? item.minSize.height : item.minSize.width; +} + +inline float GetMaxMainExtent(const UILayoutItem& item, UILayoutAxis axis) { + return axis == UILayoutAxis::Horizontal ? item.maxSize.width : item.maxSize.height; +} + +inline float GetMaxCrossExtent(const UILayoutItem& item, UILayoutAxis axis) { + return axis == UILayoutAxis::Horizontal ? item.maxSize.height : item.maxSize.width; +} + +inline UILayoutAlignment GetCrossAlignment(const UILayoutItem& item, UILayoutAxis axis) { + return axis == UILayoutAxis::Horizontal ? item.verticalAlignment : item.horizontalAlignment; +} + +inline UILayoutAlignment GetHorizontalAlignment(const UILayoutItem& item) { + return item.horizontalAlignment; +} + +inline UILayoutAlignment GetVerticalAlignment(const UILayoutItem& item) { + return item.verticalAlignment; +} + +inline float ComputeAlignmentOffset( + UILayoutAlignment alignment, + float availableExtent, + float childExtent) { + const float remainingExtent = (std::max)(0.0f, availableExtent - childExtent); + switch (alignment) { + case UILayoutAlignment::Center: + return remainingExtent * 0.5f; + case UILayoutAlignment::End: + return remainingExtent; + case UILayoutAlignment::Start: + case UILayoutAlignment::Stretch: + default: + return 0.0f; + } +} + +inline float ResolveArrangedCrossExtent( + const UILayoutItem& item, + UILayoutAxis axis, + float measuredCrossExtent, + float availableCrossExtent) { + const UILayoutLength crossLength = GetCrossLength(item, axis); + const UILayoutAlignment crossAlignment = GetCrossAlignment(item, axis); + float resolvedCrossExtent = measuredCrossExtent; + + if ((crossLength.IsStretch() || crossAlignment == UILayoutAlignment::Stretch) && + IsFiniteExtent(availableCrossExtent)) { + resolvedCrossExtent = availableCrossExtent; + } + + float effectiveMaxExtent = GetMaxCrossExtent(item, axis); + if (IsFiniteExtent(availableCrossExtent)) { + effectiveMaxExtent = IsFiniteExtent(effectiveMaxExtent) + ? (std::min)(effectiveMaxExtent, availableCrossExtent) + : availableCrossExtent; + } + + return ClampExtent(resolvedCrossExtent, GetMinCrossExtent(item, axis), effectiveMaxExtent); +} + +inline float ResolveArrangedMainExtent( + const UILayoutItem& item, + UILayoutAxis axis, + float measuredMainExtent, + float extraMainExtent, + float availableMainExtent) { + float resolvedMainExtent = measuredMainExtent; + if (GetMainLength(item, axis).IsStretch()) { + resolvedMainExtent += extraMainExtent; + } + + float effectiveMaxExtent = GetMaxMainExtent(item, axis); + if (IsFiniteExtent(availableMainExtent)) { + effectiveMaxExtent = IsFiniteExtent(effectiveMaxExtent) + ? (std::min)(effectiveMaxExtent, availableMainExtent) + : availableMainExtent; + } + + return ClampExtent(resolvedMainExtent, GetMinMainExtent(item, axis), effectiveMaxExtent); +} + +} // namespace Detail + +inline UILayoutPassResult MeasureStackLayout( + const UIStackLayoutOptions& options, + const std::vector& items, + const UILayoutConstraints& constraints = UILayoutConstraints::Unbounded()) { + UILayoutPassResult result = {}; + result.children.resize(items.size()); + + const UISize innerMaxSize = Detail::DeflateSize(constraints.maxSize, options.padding); + + float accumulatedMainExtent = 0.0f; + float maxCrossExtent = 0.0f; + std::size_t visibleCount = 0; + + for (std::size_t index = 0; index < items.size(); ++index) { + const UILayoutItem& item = items[index]; + UILayoutChildResult& childResult = result.children[index]; + childResult.visible = item.visible; + if (!item.visible) { + continue; + } + + UILayoutConstraints childConstraints = {}; + childConstraints.maxSize = UISize( + Detail::ReduceAvailableExtent(innerMaxSize.width, item.margin.Horizontal()), + Detail::ReduceAvailableExtent(innerMaxSize.height, item.margin.Vertical())); + childResult.measuredSize = Detail::MeasureItemBaseSize(item, childConstraints); + + accumulatedMainExtent += + Detail::GetMainExtent(childResult.measuredSize, options.axis) + + Detail::GetMainMargin(item.margin, options.axis); + maxCrossExtent = (std::max)( + maxCrossExtent, + Detail::GetCrossExtent(childResult.measuredSize, options.axis) + + Detail::GetCrossMargin(item.margin, options.axis)); + ++visibleCount; + } + + if (visibleCount > 1u) { + accumulatedMainExtent += options.spacing * static_cast(visibleCount - 1u); + } + + if (options.axis == UILayoutAxis::Horizontal) { + result.desiredSize.width = accumulatedMainExtent + options.padding.Horizontal(); + result.desiredSize.height = maxCrossExtent + options.padding.Vertical(); + } else { + result.desiredSize.width = maxCrossExtent + options.padding.Horizontal(); + result.desiredSize.height = accumulatedMainExtent + options.padding.Vertical(); + } + + result.desiredSize.width = Detail::ClampExtent( + result.desiredSize.width, + constraints.minSize.width, + constraints.maxSize.width); + result.desiredSize.height = Detail::ClampExtent( + result.desiredSize.height, + constraints.minSize.height, + constraints.maxSize.height); + return result; +} + +inline UILayoutPassResult ArrangeStackLayout( + const UIStackLayoutOptions& options, + const std::vector& items, + const UIRect& bounds) { + UILayoutPassResult result = + MeasureStackLayout(options, items, UILayoutConstraints::Bounded(bounds.width, bounds.height)); + + const UIRect innerBounds = Detail::DeflateRect(bounds, options.padding); + const float innerMainExtent = options.axis == UILayoutAxis::Horizontal ? innerBounds.width : innerBounds.height; + const float innerCrossExtent = options.axis == UILayoutAxis::Horizontal ? innerBounds.height : innerBounds.width; + + float totalBaseMainExtent = 0.0f; + float totalStretchWeight = 0.0f; + std::size_t visibleCount = 0u; + for (std::size_t index = 0; index < items.size(); ++index) { + const UILayoutItem& item = items[index]; + const UILayoutChildResult& childResult = result.children[index]; + if (!item.visible || !childResult.visible) { + continue; + } + + totalBaseMainExtent += + Detail::GetMainExtent(childResult.measuredSize, options.axis) + + Detail::GetMainMargin(item.margin, options.axis); + if (Detail::GetMainLength(item, options.axis).IsStretch()) { + totalStretchWeight += Detail::GetMainLength(item, options.axis).value; + } + ++visibleCount; + } + + if (visibleCount > 1u) { + totalBaseMainExtent += options.spacing * static_cast(visibleCount - 1u); + } + + const float remainingMainExtent = (std::max)(0.0f, innerMainExtent - totalBaseMainExtent); + float mainCursor = options.axis == UILayoutAxis::Horizontal ? innerBounds.x : innerBounds.y; + + for (std::size_t index = 0; index < items.size(); ++index) { + const UILayoutItem& item = items[index]; + UILayoutChildResult& childResult = result.children[index]; + if (!item.visible || !childResult.visible) { + childResult.arrangedRect = UIRect(0.0f, 0.0f, 0.0f, 0.0f); + continue; + } + + const float stretchWeight = Detail::GetMainLength(item, options.axis).IsStretch() + ? Detail::GetMainLength(item, options.axis).value + : 0.0f; + const float allocatedExtraMainExtent = + totalStretchWeight > 0.0f + ? remainingMainExtent * (stretchWeight / totalStretchWeight) + : 0.0f; + + const float availableMainExtent = Detail::ReduceAvailableExtent( + innerMainExtent, + Detail::GetMainMargin(item.margin, options.axis)); + const float resolvedMainExtent = Detail::ResolveArrangedMainExtent( + item, + options.axis, + Detail::GetMainExtent(childResult.measuredSize, options.axis), + allocatedExtraMainExtent, + availableMainExtent); + + const float availableCrossExtent = Detail::ReduceAvailableExtent( + innerCrossExtent, + Detail::GetCrossMargin(item.margin, options.axis)); + const float resolvedCrossExtent = Detail::ResolveArrangedCrossExtent( + item, + options.axis, + Detail::GetCrossExtent(childResult.measuredSize, options.axis), + availableCrossExtent); + + const float crossOrigin = options.axis == UILayoutAxis::Horizontal + ? innerBounds.y + item.margin.top + : innerBounds.x + item.margin.left; + const float crossOffset = Detail::ComputeAlignmentOffset( + Detail::GetCrossAlignment(item, options.axis), + availableCrossExtent, + resolvedCrossExtent); + + const float mainOrigin = mainCursor + Detail::GetLeadingMargin(item.margin, options.axis); + if (options.axis == UILayoutAxis::Horizontal) { + childResult.arrangedRect = UIRect( + mainOrigin, + crossOrigin + crossOffset, + resolvedMainExtent, + resolvedCrossExtent); + } else { + childResult.arrangedRect = UIRect( + crossOrigin + crossOffset, + mainOrigin, + resolvedCrossExtent, + resolvedMainExtent); + } + + mainCursor += + Detail::GetLeadingMargin(item.margin, options.axis) + + resolvedMainExtent + + Detail::GetTrailingMargin(item.margin, options.axis) + + options.spacing; + } + + return result; +} + +inline UILayoutPassResult MeasureOverlayLayout( + const UIOverlayLayoutOptions& options, + const std::vector& items, + const UILayoutConstraints& constraints = UILayoutConstraints::Unbounded()) { + UILayoutPassResult result = {}; + result.children.resize(items.size()); + + const UISize innerMaxSize = Detail::DeflateSize(constraints.maxSize, options.padding); + + float desiredWidth = 0.0f; + float desiredHeight = 0.0f; + for (std::size_t index = 0; index < items.size(); ++index) { + const UILayoutItem& item = items[index]; + UILayoutChildResult& childResult = result.children[index]; + childResult.visible = item.visible; + if (!item.visible) { + continue; + } + + UILayoutConstraints childConstraints = {}; + childConstraints.maxSize = UISize( + Detail::ReduceAvailableExtent(innerMaxSize.width, item.margin.Horizontal()), + Detail::ReduceAvailableExtent(innerMaxSize.height, item.margin.Vertical())); + childResult.measuredSize = Detail::MeasureItemBaseSize(item, childConstraints); + + desiredWidth = (std::max)(desiredWidth, childResult.measuredSize.width + item.margin.Horizontal()); + desiredHeight = (std::max)(desiredHeight, childResult.measuredSize.height + item.margin.Vertical()); + } + + result.desiredSize.width = desiredWidth + options.padding.Horizontal(); + result.desiredSize.height = desiredHeight + options.padding.Vertical(); + result.desiredSize.width = Detail::ClampExtent( + result.desiredSize.width, + constraints.minSize.width, + constraints.maxSize.width); + result.desiredSize.height = Detail::ClampExtent( + result.desiredSize.height, + constraints.minSize.height, + constraints.maxSize.height); + return result; +} + +inline UILayoutPassResult ArrangeOverlayLayout( + const UIOverlayLayoutOptions& options, + const std::vector& items, + const UIRect& bounds) { + UILayoutPassResult result = + MeasureOverlayLayout(options, items, UILayoutConstraints::Bounded(bounds.width, bounds.height)); + + const UIRect innerBounds = Detail::DeflateRect(bounds, options.padding); + for (std::size_t index = 0; index < items.size(); ++index) { + const UILayoutItem& item = items[index]; + UILayoutChildResult& childResult = result.children[index]; + if (!item.visible || !childResult.visible) { + childResult.arrangedRect = UIRect(0.0f, 0.0f, 0.0f, 0.0f); + continue; + } + + const float availableWidth = Detail::ReduceAvailableExtent(innerBounds.width, item.margin.Horizontal()); + const float availableHeight = Detail::ReduceAvailableExtent(innerBounds.height, item.margin.Vertical()); + + float resolvedWidth = childResult.measuredSize.width; + if ((item.width.IsStretch() || item.horizontalAlignment == UILayoutAlignment::Stretch) && + Detail::IsFiniteExtent(availableWidth)) { + resolvedWidth = availableWidth; + } + resolvedWidth = Detail::ClampExtent(resolvedWidth, item.minSize.width, item.maxSize.width); + if (Detail::IsFiniteExtent(availableWidth)) { + resolvedWidth = (std::min)(resolvedWidth, availableWidth); + } + + float resolvedHeight = childResult.measuredSize.height; + if ((item.height.IsStretch() || item.verticalAlignment == UILayoutAlignment::Stretch) && + Detail::IsFiniteExtent(availableHeight)) { + resolvedHeight = availableHeight; + } + resolvedHeight = Detail::ClampExtent(resolvedHeight, item.minSize.height, item.maxSize.height); + if (Detail::IsFiniteExtent(availableHeight)) { + resolvedHeight = (std::min)(resolvedHeight, availableHeight); + } + + const float originX = + innerBounds.x + + item.margin.left + + Detail::ComputeAlignmentOffset(item.horizontalAlignment, availableWidth, resolvedWidth); + const float originY = + innerBounds.y + + item.margin.top + + Detail::ComputeAlignmentOffset(item.verticalAlignment, availableHeight, resolvedHeight); + childResult.arrangedRect = UIRect(originX, originY, resolvedWidth, resolvedHeight); + } + + return result; +} + +} // namespace Layout +} // namespace UI +} // namespace XCEngine diff --git a/engine/include/XCEngine/UI/Layout/LayoutTypes.h b/engine/include/XCEngine/UI/Layout/LayoutTypes.h new file mode 100644 index 00000000..f1379c7a --- /dev/null +++ b/engine/include/XCEngine/UI/Layout/LayoutTypes.h @@ -0,0 +1,145 @@ +#pragma once + +#include + +#include +#include +#include + +namespace XCEngine { +namespace UI { +namespace Layout { + +inline float GetUnboundedLayoutExtent() { + return std::numeric_limits::infinity(); +} + +enum class UILayoutAxis : std::uint8_t { + Horizontal = 0, + Vertical +}; + +enum class UILayoutLengthUnit : std::uint8_t { + Auto = 0, + Pixels, + Stretch +}; + +enum class UILayoutAlignment : std::uint8_t { + Start = 0, + Center, + End, + Stretch +}; + +struct UILayoutLength { + UILayoutLengthUnit unit = UILayoutLengthUnit::Auto; + float value = 0.0f; + + static UILayoutLength Auto() { + return {}; + } + + static UILayoutLength Pixels(float pixels) { + UILayoutLength length = {}; + length.unit = UILayoutLengthUnit::Pixels; + length.value = (std::max)(0.0f, pixels); + return length; + } + + static UILayoutLength Stretch(float weight = 1.0f) { + UILayoutLength length = {}; + length.unit = UILayoutLengthUnit::Stretch; + length.value = (std::max)(0.0f, weight); + return length; + } + + bool IsStretch() const { + return unit == UILayoutLengthUnit::Stretch && value > 0.0f; + } +}; + +struct UILayoutThickness { + float left = 0.0f; + float top = 0.0f; + float right = 0.0f; + float bottom = 0.0f; + + UILayoutThickness() = default; + + UILayoutThickness(float leftValue, float topValue, float rightValue, float bottomValue) + : left((std::max)(0.0f, leftValue)) + , top((std::max)(0.0f, topValue)) + , right((std::max)(0.0f, rightValue)) + , bottom((std::max)(0.0f, bottomValue)) { + } + + static UILayoutThickness Uniform(float value) { + return UILayoutThickness(value, value, value, value); + } + + static UILayoutThickness Symmetric(float horizontal, float vertical) { + return UILayoutThickness(horizontal, vertical, horizontal, vertical); + } + + float Horizontal() const { + return left + right; + } + + float Vertical() const { + return top + bottom; + } +}; + +struct UILayoutConstraints { + UISize minSize = UISize(0.0f, 0.0f); + UISize maxSize = UISize(GetUnboundedLayoutExtent(), GetUnboundedLayoutExtent()); + + static UILayoutConstraints Unbounded() { + return {}; + } + + static UILayoutConstraints Bounded(float width, float height) { + UILayoutConstraints constraints = {}; + constraints.maxSize = UISize((std::max)(0.0f, width), (std::max)(0.0f, height)); + return constraints; + } + + static UILayoutConstraints Tight(float width, float height) { + UILayoutConstraints constraints = Bounded(width, height); + constraints.minSize = constraints.maxSize; + return constraints; + } +}; + +struct UILayoutItem { + UISize desiredContentSize = UISize(0.0f, 0.0f); + UILayoutLength width = UILayoutLength::Auto(); + UILayoutLength height = UILayoutLength::Auto(); + UISize minSize = UISize(0.0f, 0.0f); + UISize maxSize = UISize(GetUnboundedLayoutExtent(), GetUnboundedLayoutExtent()); + UILayoutThickness margin = {}; + UILayoutAlignment horizontalAlignment = UILayoutAlignment::Start; + UILayoutAlignment verticalAlignment = UILayoutAlignment::Start; + bool visible = true; +}; + +struct UIStackLayoutOptions { + UILayoutAxis axis = UILayoutAxis::Vertical; + float spacing = 0.0f; + UILayoutThickness padding = {}; +}; + +struct UIOverlayLayoutOptions { + UILayoutThickness padding = {}; +}; + +struct UILayoutChildResult { + UISize measuredSize = UISize(0.0f, 0.0f); + UIRect arrangedRect = UIRect(0.0f, 0.0f, 0.0f, 0.0f); + bool visible = false; +}; + +} // namespace Layout +} // namespace UI +} // namespace XCEngine diff --git a/tests/Core/Math/test_ui_layout.cpp b/tests/Core/Math/test_ui_layout.cpp new file mode 100644 index 00000000..12d0b218 --- /dev/null +++ b/tests/Core/Math/test_ui_layout.cpp @@ -0,0 +1,130 @@ +#include + +#include + +namespace { + +using XCEngine::UI::UIRect; +using XCEngine::UI::UISize; +using XCEngine::UI::Layout::ArrangeOverlayLayout; +using XCEngine::UI::Layout::ArrangeStackLayout; +using XCEngine::UI::Layout::MeasureOverlayLayout; +using XCEngine::UI::Layout::MeasureStackLayout; +using XCEngine::UI::Layout::UILayoutAlignment; +using XCEngine::UI::Layout::UILayoutAxis; +using XCEngine::UI::Layout::UILayoutConstraints; +using XCEngine::UI::Layout::UILayoutItem; +using XCEngine::UI::Layout::UILayoutLength; +using XCEngine::UI::Layout::UILayoutThickness; +using XCEngine::UI::Layout::UIOverlayLayoutOptions; +using XCEngine::UI::Layout::UIStackLayoutOptions; + +void ExpectRect( + const UIRect& rect, + float x, + float y, + float width, + float height) { + EXPECT_FLOAT_EQ(rect.x, x); + EXPECT_FLOAT_EQ(rect.y, y); + EXPECT_FLOAT_EQ(rect.width, width); + EXPECT_FLOAT_EQ(rect.height, height); +} + +} // namespace + +TEST(UI_Layout, MeasureHorizontalStackAccumulatesSpacingPaddingAndCrossExtent) { + UIStackLayoutOptions options = {}; + options.axis = UILayoutAxis::Horizontal; + options.spacing = 5.0f; + options.padding = UILayoutThickness::Symmetric(10.0f, 6.0f); + + std::vector items(2); + items[0].desiredContentSize = UISize(40.0f, 20.0f); + items[1].desiredContentSize = UISize(60.0f, 30.0f); + + const auto result = MeasureStackLayout(options, items); + + EXPECT_FLOAT_EQ(result.desiredSize.width, 125.0f); + EXPECT_FLOAT_EQ(result.desiredSize.height, 42.0f); +} + +TEST(UI_Layout, ArrangeHorizontalStackDistributesRemainingSpaceToStretchChildren) { + UIStackLayoutOptions options = {}; + options.axis = UILayoutAxis::Horizontal; + options.spacing = 5.0f; + options.padding = UILayoutThickness::Uniform(10.0f); + + std::vector items(3); + items[0].width = UILayoutLength::Pixels(100.0f); + items[0].desiredContentSize = UISize(10.0f, 20.0f); + + items[1].width = UILayoutLength::Stretch(1.0f); + items[1].desiredContentSize = UISize(30.0f, 20.0f); + + items[2].width = UILayoutLength::Pixels(50.0f); + items[2].desiredContentSize = UISize(10.0f, 20.0f); + + const auto result = ArrangeStackLayout(options, items, UIRect(0.0f, 0.0f, 300.0f, 80.0f)); + + ExpectRect(result.children[0].arrangedRect, 10.0f, 10.0f, 100.0f, 20.0f); + ExpectRect(result.children[1].arrangedRect, 115.0f, 10.0f, 120.0f, 20.0f); + ExpectRect(result.children[2].arrangedRect, 240.0f, 10.0f, 50.0f, 20.0f); +} + +TEST(UI_Layout, ArrangeVerticalStackSupportsCrossAxisStretch) { + UIStackLayoutOptions options = {}; + options.axis = UILayoutAxis::Vertical; + options.spacing = 4.0f; + options.padding = UILayoutThickness::Symmetric(8.0f, 6.0f); + + std::vector items(2); + items[0].desiredContentSize = UISize(40.0f, 10.0f); + items[0].horizontalAlignment = UILayoutAlignment::Stretch; + items[1].desiredContentSize = UISize(60.0f, 20.0f); + + const auto result = ArrangeStackLayout(options, items, UIRect(0.0f, 0.0f, 200.0f, 100.0f)); + + ExpectRect(result.children[0].arrangedRect, 8.0f, 6.0f, 184.0f, 10.0f); + ExpectRect(result.children[1].arrangedRect, 8.0f, 20.0f, 60.0f, 20.0f); +} + +TEST(UI_Layout, ArrangeOverlaySupportsCenterAndStretch) { + UIOverlayLayoutOptions options = {}; + options.padding = UILayoutThickness::Uniform(10.0f); + + std::vector items(2); + items[0].desiredContentSize = UISize(40.0f, 20.0f); + items[0].horizontalAlignment = UILayoutAlignment::Center; + items[0].verticalAlignment = UILayoutAlignment::Center; + + items[1].desiredContentSize = UISize(10.0f, 10.0f); + items[1].width = UILayoutLength::Stretch(); + items[1].height = UILayoutLength::Stretch(); + items[1].margin = UILayoutThickness::Uniform(5.0f); + + const auto result = ArrangeOverlayLayout(options, items, UIRect(0.0f, 0.0f, 100.0f, 60.0f)); + + ExpectRect(result.children[0].arrangedRect, 30.0f, 20.0f, 40.0f, 20.0f); + ExpectRect(result.children[1].arrangedRect, 15.0f, 15.0f, 70.0f, 30.0f); +} + +TEST(UI_Layout, MeasureOverlayRespectsItemMinMaxAndAvailableConstraints) { + UIOverlayLayoutOptions options = {}; + + std::vector items(1); + items[0].width = UILayoutLength::Pixels(500.0f); + items[0].desiredContentSize = UISize(10.0f, 10.0f); + items[0].minSize = UISize(0.0f, 50.0f); + items[0].maxSize = UISize(200.0f, 120.0f); + + const auto result = MeasureOverlayLayout( + options, + items, + UILayoutConstraints::Bounded(150.0f, 100.0f)); + + EXPECT_FLOAT_EQ(result.children[0].measuredSize.width, 150.0f); + EXPECT_FLOAT_EQ(result.children[0].measuredSize.height, 50.0f); + EXPECT_FLOAT_EQ(result.desiredSize.width, 150.0f); + EXPECT_FLOAT_EQ(result.desiredSize.height, 50.0f); +} diff --git a/tests/core/Math/CMakeLists.txt b/tests/core/Math/CMakeLists.txt index 1b063e71..dae5d7d9 100644 --- a/tests/core/Math/CMakeLists.txt +++ b/tests/core/Math/CMakeLists.txt @@ -7,6 +7,7 @@ set(MATH_TEST_SOURCES test_matrix.cpp test_quaternion.cpp test_geometry.cpp + test_ui_layout.cpp ) add_executable(math_tests ${MATH_TEST_SOURCES})