Add XCUI layout engine MVP and archive subplan 02
This commit is contained in:
@@ -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`、更完整容器族、文本测量桥接
|
||||
485
engine/include/XCEngine/UI/Layout/LayoutEngine.h
Normal file
485
engine/include/XCEngine/UI/Layout/LayoutEngine.h
Normal file
@@ -0,0 +1,485 @@
|
||||
#pragma once
|
||||
|
||||
#include <XCEngine/UI/Layout/LayoutTypes.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <vector>
|
||||
|
||||
namespace XCEngine {
|
||||
namespace UI {
|
||||
namespace Layout {
|
||||
|
||||
struct UILayoutPassResult {
|
||||
UISize desiredSize = UISize(0.0f, 0.0f);
|
||||
std::vector<UILayoutChildResult> 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<UILayoutItem>& 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<float>(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<UILayoutItem>& 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<float>(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<UILayoutItem>& 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<UILayoutItem>& 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
|
||||
145
engine/include/XCEngine/UI/Layout/LayoutTypes.h
Normal file
145
engine/include/XCEngine/UI/Layout/LayoutTypes.h
Normal file
@@ -0,0 +1,145 @@
|
||||
#pragma once
|
||||
|
||||
#include <XCEngine/UI/Types.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <limits>
|
||||
|
||||
namespace XCEngine {
|
||||
namespace UI {
|
||||
namespace Layout {
|
||||
|
||||
inline float GetUnboundedLayoutExtent() {
|
||||
return std::numeric_limits<float>::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
|
||||
130
tests/Core/Math/test_ui_layout.cpp
Normal file
130
tests/Core/Math/test_ui_layout.cpp
Normal file
@@ -0,0 +1,130 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <XCEngine/UI/Layout/LayoutEngine.h>
|
||||
|
||||
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<UILayoutItem> 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<UILayoutItem> 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<UILayoutItem> 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<UILayoutItem> 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<UILayoutItem> 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);
|
||||
}
|
||||
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user