Skip to content

Commit ab15b59

Browse files
committed
feat(layout): wrap text to width + fit parents size to percent children
Two layout gaps surfaced by the cuo-ecs UI mod: - AddText measured the whole string as a single line and the text leaf used the default FIT layout, so text never wrapped to its container (it ran off the right edge). ComputeLayout now reflows any text leaf that overflows its available width via the new ITextMeasurer.MeasureTextWrapped (default impl = no wrap), then RecomputeFitHeights re-sums FIT ancestor heights so the box grows to the wrapped line count. - CloseElement hard-zeroed a Percent element's width, so a FIT parent whose sole child is percent/grow-width collapsed to 0 width (0 hit area, e.g. a tooltip wrapper around flowing text never registered hover). Keep the content-derived width instead; the width pass still resolves the percent against the real parent, so the only effect is fit parents now size to content (CSS min-content behaviour).
1 parent 903081d commit ab15b59

2 files changed

Lines changed: 98 additions & 4 deletions

File tree

src/Clay/Core/ClayContext.cs

Lines changed: 87 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -595,10 +595,13 @@ public void CloseElement()
595595
openLayoutElement.MinDimensions.Width = Math.Clamp(openLayoutElement.MinDimensions.Width,
596596
layoutConfig.Sizing.Width.MinMax.Min, maxWidth);
597597
}
598-
else
599-
{
600-
openLayoutElement.Dimensions.Width = 0;
601-
}
598+
// NOTE: a Percent element keeps the content-derived width computed from
599+
// its children here (it is NOT reset to 0). The width pass overwrites it
600+
// with the resolved percent against the real parent, so the only effect
601+
// of keeping it is that a FIT parent can size to this element's content.
602+
// Resetting to 0 collapsed a fit parent whose sole child is percent/grow
603+
// width — e.g. a tooltip wrapper around flowing text got 0 width and thus
604+
// 0 hit area (no hover). Content sizing matches CSS min-content behaviour.
602605

603606
if (layoutConfig.Sizing.Height.Type != SizingType.Percent)
604607
{
@@ -873,10 +876,63 @@ private void UpdateAspectRatioBox(ref LayoutElement layoutElement)
873876
private void ComputeLayout()
874877
{
875878
SizeContainersAlongAxis(true);
879+
// Width pass reflows overflowing text leaves to multiple lines; their FIT
880+
// ancestors were height-summed at CloseElement against the pre-wrap single
881+
// line, so re-sum them before the height pass / positioning runs.
882+
RecomputeFitHeights();
876883
SizeContainersAlongAxis(false);
877884
PositionElements();
878885
}
879886

887+
// Bottom-up (post-order) re-accumulation of FIT element heights. Only FIT
888+
// containers are touched — Fixed/Percent/Grow heights are owned by other
889+
// passes. Mirrors the CloseElement FIT rule: a column sums child heights +
890+
// gaps + padding, a row takes the tallest child + padding.
891+
private void RecomputeFitHeights()
892+
{
893+
for (int r = 0; r < LayoutElementTreeRoots.Length; r++)
894+
RecomputeFitHeightsRecursive(LayoutElementTreeRoots[r].LayoutElementIndex);
895+
}
896+
897+
private float RecomputeFitHeightsRecursive(int elementIndex)
898+
{
899+
// Re-fetch by index after recursing (the elements array never resizes
900+
// here, but avoid holding a ref across the child calls regardless).
901+
if (LayoutElements[elementIndex].IsTextElement)
902+
return LayoutElements[elementIndex].Dimensions.Height;
903+
904+
int childStart = LayoutElements[elementIndex].Children.StartIndex;
905+
int childCount = LayoutElements[elementIndex].Children.Length;
906+
for (int i = 0; i < childCount; i++)
907+
RecomputeFitHeightsRecursive(LayoutElementChildren[childStart + i]);
908+
909+
ref var el = ref LayoutElements[elementIndex];
910+
ref var cfg = ref LayoutConfigs[el.LayoutConfigIndex];
911+
if (cfg.Sizing.Height.Type != SizingType.Fit)
912+
return el.Dimensions.Height;
913+
914+
float padV = cfg.Padding.Top + cfg.Padding.Bottom;
915+
float height;
916+
if (cfg.Direction == LayoutDirection.TopToBottom)
917+
{
918+
height = padV;
919+
for (int i = 0; i < childCount; i++)
920+
height += LayoutElements[LayoutElementChildren[childStart + i]].Dimensions.Height;
921+
height += Math.Max(childCount - 1, 0) * cfg.ChildGap;
922+
}
923+
else
924+
{
925+
float maxChild = 0;
926+
for (int i = 0; i < childCount; i++)
927+
maxChild = Math.Max(maxChild, LayoutElements[LayoutElementChildren[childStart + i]].Dimensions.Height);
928+
height = maxChild + padV;
929+
}
930+
931+
float maxHeight = cfg.Sizing.Height.MinMax.Max > 0 ? cfg.Sizing.Height.MinMax.Max : float.MaxValue;
932+
el.Dimensions.Height = Math.Clamp(height, cfg.Sizing.Height.MinMax.Min, maxHeight);
933+
return el.Dimensions.Height;
934+
}
935+
880936
private void SizeContainersAlongAxis(bool xAxis)
881937
{
882938
for (int rootIndex = 0; rootIndex < LayoutElementTreeRoots.Length; rootIndex++)
@@ -945,6 +1001,33 @@ private void SizeContainersAlongAxis(bool xAxis)
9451001
queue.Add(childIndex);
9461002
}
9471003

1004+
// Text reflow: a text leaf is measured single-line at AddText
1005+
// time (FIT), so it overflows a narrower container. Now that the
1006+
// parent's width is resolved (top-down BFS), re-wrap any text
1007+
// child to the available content width — width shrinks to the
1008+
// widest wrapped line, height grows with the line count.
1009+
// RecomputeFitHeights (run after this pass) propagates the new
1010+
// height up through FIT ancestors. Height-only effect here;
1011+
// widths of FIT ancestors were already framed by non-text
1012+
// siblings / fixed sizing in CloseElement.
1013+
if (xAxis && child.IsTextElement && TextMeasurer != null
1014+
&& availableSpace > 0 && child.Dimensions.Width > availableSpace)
1015+
{
1016+
int tcIndex = FindConfigIndex(ref child, ElementConfigType.Text);
1017+
if (tcIndex >= 0)
1018+
{
1019+
ref var td = ref TextElementData[child.TextData.Index];
1020+
ref var tc = ref TextElementConfigs[tcIndex];
1021+
var wrapped = TextMeasurer.MeasureTextWrapped(
1022+
td.Text.AsSpan(), tc.FontId, tc.FontSize, tc.LetterSpacing, availableSpace);
1023+
if (tc.LineHeight > 0 && tc.FontSize > 0)
1024+
wrapped.Height *= tc.LineHeight / (float)tc.FontSize;
1025+
child.Dimensions.Width = Math.Min(wrapped.Width, availableSpace);
1026+
child.Dimensions.Height = wrapped.Height;
1027+
td.PreferredDimensions = child.Dimensions;
1028+
}
1029+
}
1030+
9481031
ref var childLayoutConfig = ref LayoutConfigs[child.LayoutConfigIndex];
9491032
var childSizing = xAxis ? childLayoutConfig.Sizing.Width : childLayoutConfig.Sizing.Height;
9501033

src/Clay/Text/ITextMeasurer.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,17 @@ public interface ITextMeasurer
1515
/// <param name="letterSpacing">Extra spacing between characters.</param>
1616
/// <returns>The dimensions of the rendered text.</returns>
1717
Dimensions MeasureText(ReadOnlySpan<char> text, ushort fontId, ushort fontSize, ushort letterSpacing);
18+
19+
/// <summary>
20+
/// Measures text wrapped to <paramref name="maxWidth"/>: the returned width is
21+
/// the widest resulting line (≤ maxWidth) and the height grows with the line
22+
/// count. The layout pass calls this once an element's available width is
23+
/// known so a text run that overflows its container reflows instead of running
24+
/// off-box. Default implementation ignores the constraint (no wrap) for
25+
/// measurers that don't support it.
26+
/// </summary>
27+
Dimensions MeasureTextWrapped(ReadOnlySpan<char> text, ushort fontId, ushort fontSize, ushort letterSpacing, float maxWidth)
28+
=> MeasureText(text, fontId, fontSize, letterSpacing);
1829
}
1930

2031
/// <summary>

0 commit comments

Comments
 (0)