Skip to content

Commit 0c3452c

Browse files
fix(FlexPanel): honor VerticalAlignment.Stretch as definite block axis (#176)
* fix(FlexPanel): honor VerticalAlignment.Stretch as definite block axis FlexPanel's MeasureOverride keyed the block axis off the panel's declared Height only — the parent's offer (availableSize.Height) was ignored even when the panel's VerticalAlignment.Stretch + a finite parent slot meant "fill". As a result the canonical web flex pattern of header(auto) / body(flex:1) / footer(auto) filling a viewport required a hardcoded .Height(N), and a flex:1 body would resolve to an empty pool (basis:0 with no container size to grow into). Changes: - MeasureOverride: when no explicit Height, VerticalAlignment.Stretch, and parent's offer is finite, treat block axis as definite. This is the symmetric counterpart to the existing HorizontalAlignment.Stretch inline-axis fill — same WinUI Stretch contract on both axes. - ArrangeOverride: same opt-in reruns Yoga at finalSize.Height to cover the case where the parent's arrange allocation differs from the measure offer. _arranging suppresses child re-measure during the rerun, preserving the resize-wobble fix from #172. - MeasureFunction wrapper (the load-bearing piece): differentiate Yoga's Exactly mode (true stretch-fit — pass finite to inner) from AtMost / Undefined (basis / content phase — pass infinity). Without this, a nested FlexPanel with default VerticalAlignment.Stretch would treat AtMost's soft cap as a fill target and report the cap as its DesiredSize, defeating the outer's flex-grow distribution across siblings. With it, nested FlexPanels behave correctly under any intermediate (Border, Grid, ContentControl, etc.) without a parent-type sniff. New self-test: Flex_HeaderBodyFooterFillsParentSlot covers the canonical pattern under a Reactor host with no explicit .Height(N) on the column — body resolves to slot − header − footer and overflowing content scrolls. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * review: address Copilot + agent reviews Changes from review: 1. Revert MeasureFunction wrapper to original AtMost → finite mapping. The earlier AtMost → infinity mapping broke TextBlock wrapping in cross-axis flex layouts: Measure(width=∞) yields a 1-line tall result; Measure(width=200) yields the correct wrapped multi-line height. Yoga uses AtMost for FitContent sizing, so any wrapping text under a non-Stretch align-items would have under-reported its row height and clipped. 2. Move the nested-FlexPanel disambiguation to a [ThreadStatic] _outerYogaHeightMode the wrapper publishes around child.Measure. FlexPanel.MeasureOverride consults it to tell apart: - WinUI AtMost from any non-Yoga parent → standard contract (Stretch + finite = fill). - Yoga AtMost from an outer FlexPanel doing basis/FitContent measurement → soft cap, NOT a fill target. Treating it as fill would make the inner report the cap as DesiredSize and defeat the outer's flex-grow distribution across siblings. - Yoga Exactly → stretch-fit allocation, fill. This is what handles nested FlexPanels through any intermediate (Border, Grid, ContentControl) without a brittle parent-type sniff. 3. Rename FlexColumnFillsParentSlot → FlexHeaderBodyFooterFillsParentSlot to match the registry key (Copilot review nit; matches grepability of other fixtures). 4. New self-test FlexBorderAutoHeightInflatesWithStretch documents the known trade-off of measure-time fillBlockAxis: a Border with auto-height around a default-Stretch FlexColumn inflates to the parent's offer rather than shrink-wrapping. Asserts BOTH halves — default Stretch inflates above 120px content, explicit .VAlign(Top) shrink-wraps to 120px. If a future refactor lands web flex semantics without inflating auto-height intermediates, the inflate assertion should flip. 5. New self-test FlexAtMostPreservesTextWrapping verifies the wrapper's AtMost handling didn't regress text wrapping. Long text in a 200px column wraps to ≥ 40px height; under a broken AtMost-as-infinity wrapper it would collapse to ~18px (1 line). Test gates: 6819 unit tests pass, 670 selftests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 35f20a3 commit 0c3452c

3 files changed

Lines changed: 381 additions & 33 deletions

File tree

src/Reactor/Yoga/FlexPanel.cs

Lines changed: 151 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,21 @@ private struct ChildLayout { public float X, Y, Width, Height; }
294294
// Yoga uses the explicit dimension and bypasses MeasureFunction).
295295
private readonly HashSet<UIElement> _measuredThisPass = new();
296296

297+
// Yoga's height-axis MeasureMode for the current MeasureFunction call,
298+
// threaded down to a nested FlexPanel via [ThreadStatic]. Lets a child
299+
// FlexPanel disambiguate two semantically-different "AtMost" cases:
300+
// - WinUI AtMost from a non-Yoga parent (the standard Measure
301+
// contract — VerticalAlignment.Stretch means "fill up to this"),
302+
// - Yoga AtMost from an outer FlexPanel doing basis/FitContent
303+
// measurement (a soft cap on content size, NOT a fill target —
304+
// treating it as fill would make the inner report the cap as its
305+
// DesiredSize and defeat the outer's flex-grow distribution).
306+
// null = not nested under a FlexPanel measurement → use the WinUI
307+
// contract directly. Yoga Exactly = stretch-fit allocation → fill.
308+
// Yoga Undefined / AtMost = basis content measurement → do not fill.
309+
[global::System.ThreadStatic]
310+
private static YogaMeasureMode? _outerYogaHeightMode;
311+
297312
protected override Size MeasureOverride(Size availableSize)
298313
{
299314
_measuredThisPass.Clear();
@@ -325,39 +340,97 @@ protected override Size MeasureOverride(Size availableSize)
325340
: YogaValue.Undefined;
326341
}
327342

328-
// Block axis: CSS-faithful semantics keyed off the panel's *declared*
329-
// height (the FrameworkElement.Height property), not the parent's
330-
// offer (availableSize.Height).
343+
// Block axis: three modes, in priority order.
344+
//
345+
// 1. Explicit Height(N): CSS `height: N` — definite container size.
346+
// Pass N to Yoga; Yoga falls into StretchFit mode (justify-content
347+
// / align-items / align-self all need a definite main axis).
348+
//
349+
// 2. VerticalAlignment.Stretch with a definite parent offer: WinUI
350+
// Stretch + finite availableSize.Height means "fill my parent's
351+
// slot" (analogous to CSS `height: 100%` against a definite-height
352+
// parent). Pass availableSize.Height to Yoga as the container
353+
// size so flex-grow children have a definite pool to distribute.
354+
// This is the symmetric counterpart to inline-axis Stretch above
355+
// and is what makes the canonical web flex pattern —
356+
// header(auto) / body(flex:1) / footer(auto) filling a viewport —
357+
// work without a hard-coded `.Height(N)` on the column.
358+
//
359+
// Wobble safety: when an outer FlexPanel runs Yoga in MaxContent
360+
// mode (no explicit height, parent offer infinite — case 3
361+
// below), Yoga's MeasureFunction calls children with
362+
// hMode=Undefined; the MeasureFunction wrapper translates that
363+
// into availableSize.Height = +∞ for the child. With +∞, the
364+
// child below sees `hasDefiniteHeight = false` and falls into
365+
// case 3 — content-sized — exactly as before. So nested
366+
// FlexPanels under an unconstrained outer behave identically to
367+
// the pre-fix code (no DesiredSize drift on horizontal resize).
368+
//
369+
// 3. Otherwise (no explicit Height, or parent offer is infinite, or
370+
// VerticalAlignment != Stretch): CSS `height: auto`. Pass NaN —
371+
// MaxContent mode, no shrink. Content overflows a smaller parent.
372+
// Block axis: three modes, in priority order.
373+
//
374+
// 1. Explicit .Height(N): CSS `height: N` — definite. Pass N to
375+
// Yoga (StretchFit mode for justify-content / align-items).
376+
//
377+
// 2. VerticalAlignment.Stretch with a definite parent offer: WinUI
378+
// Stretch + finite availableSize.Height = "fill my parent's
379+
// slot" (analogous to CSS `height: 100%` against a definite
380+
// parent). Pass availableSize.Height to Yoga as the container
381+
// size so flex-grow children have a definite pool to
382+
// distribute. This is the symmetric counterpart to the inline
383+
// axis above and is what makes the canonical web flex pattern —
384+
// header(auto) / body(flex:1) / footer(auto) filling a
385+
// viewport — work without forcing a hardcoded `.Height(N)`.
331386
//
332-
// - Explicit Height(N): CSS `height: N` — definite container size.
333-
// Run Yoga in StretchFit mode by passing N as the availableHeight
334-
// to CalculateLayout (with MaxHeight cleared so the algorithm hits
335-
// the StretchFit fall-through). justify-content / align-items:
336-
// stretch / align-self all need a definite main-axis size to act
337-
// on, and Yoga only treats the axis as definite when the param
338-
// itself is finite and the style has no MaxHeight that overrides.
387+
// The "is parent's AtMost a fill target?" question is what the
388+
// [ThreadStatic] _outerYogaHeightMode resolves: when a parent
389+
// FlexPanel's Yoga is in basis/FitContent measurement, we want
390+
// to report content size, not the cap. Only Yoga's Exactly
391+
// mode (and "no flex parent at all" — the normal WinUI
392+
// contract) means fill. Without this discrimination a nested
393+
// FlexPanel.Stretch under a flex-grow:1 sibling would report
394+
// the cap as its DesiredSize and break the outer's grow
395+
// distribution.
339396
//
340-
// - No Height: CSS `height: auto` — content-sized, no constraint to
341-
// shrink against. Pass NaN: MaxContent mode, no shrink. If the
342-
// parent's offer is smaller than content, content overflows the
343-
// parent rather than redistributing inside the FlexPanel. Pushing
344-
// availableSize.Height into Yoga as a max here would re-introduce
345-
// the resize-jiggle from the original code: every sub-pixel
346-
// finalSize.Height shift would re-run shrink across siblings.
397+
// Trade-off: `Border` (or any auto-height container with no
398+
// flex semantics) wrapping a FlexPanel will inflate to the
399+
// parent's offer rather than shrink-wrap content, because
400+
// `_outerYogaHeightMode` is null in that case (no flex parent)
401+
// so the WinUI Stretch contract applies. Users who want a
402+
// content-sized FlexPanel inside an auto-height container
403+
// should set `VerticalAlignment.Top` (or any non-Stretch).
404+
//
405+
// 3. Otherwise (no explicit Height and either parent offer is
406+
// infinite, alignment != Stretch, or outer flex wants content
407+
// size): CSS `height: auto`. Pass NaN — MaxContent mode, no
408+
// shrink, content overflows a smaller parent.
347409
bool hasExplicitHeight = !double.IsNaN(Height);
410+
bool outerFlexWantsContent =
411+
_outerYogaHeightMode == YogaMeasureMode.Undefined
412+
|| _outerYogaHeightMode == YogaMeasureMode.AtMost;
413+
bool fillBlockAxis = !hasExplicitHeight
414+
&& !outerFlexWantsContent
415+
&& VerticalAlignment == VerticalAlignment.Stretch
416+
&& hasDefiniteHeight;
348417
_rootNode.MaxHeight = YogaValue.Undefined;
349418

419+
float rootHeight = hasExplicitHeight
420+
? (float)Height
421+
: fillBlockAxis ? (float)availableSize.Height
422+
: float.NaN;
423+
350424
_rootNode.CalculateLayout(
351425
rootWidth,
352-
hasExplicitHeight ? (float)Height : float.NaN,
426+
rootHeight,
353427
LayoutDirection);
354428

355-
// Clamp the reported height to availableSize.Height *only* when the
356-
// panel has an explicit Height (CSS `height: N` — the box resolves to
357-
// N, never more). Auto-height (CSS `height: auto`) reports the content
358-
// size and is allowed to overflow the parent; a smaller parent offer
359-
// is not a constraint on `auto`.
360-
float reportedHeight = hasExplicitHeight
429+
// Clamp the reported height when the panel has a definite own-height
430+
// (explicit Height(N) or block-axis fill against a definite parent
431+
// offer — both cases the box resolves to that size, never more).
432+
bool hasDefiniteOwnHeight = hasExplicitHeight || fillBlockAxis;
433+
float reportedHeight = hasDefiniteOwnHeight
361434
? Math.Min(_rootNode.LayoutHeight, (float)availableSize.Height)
362435
: _rootNode.LayoutHeight;
363436
_cachedDesiredSize = new Size(_rootNode.LayoutWidth, reportedHeight);
@@ -420,17 +493,38 @@ protected override Size ArrangeOverride(Size finalSize)
420493
{
421494
_rootNode.MaxWidth = YogaValue.Undefined;
422495
_rootNode.MaxHeight = YogaValue.Undefined;
423-
// Mirror MeasureOverride's height policy:
424-
// - explicit Height: pass finalSize.Height as definite so
425-
// Yoga falls into StretchFit (justify-content, align-items
426-
// stretch, align-self all need a definite container).
427-
// - no Height: pass NaN so Yoga is in MaxContent mode and
428-
// sub-pixel finalSize.Height jitter during a horizontal
429-
// drag doesn't trigger flex-shrink across children.
496+
// Block-axis policy at arrange time:
497+
// - explicit Height: pass finalSize.Height (== Height) as
498+
// definite so Yoga falls into StretchFit.
499+
// - VerticalAlignment.Stretch (the user opted into "fill
500+
// my parent's slot" — symmetric to the inline axis, which
501+
// has always been treated as definite at finalSize.Width
502+
// a few lines above): pass finalSize.Height as definite
503+
// so Yoga distributes the slot across grow/shrink
504+
// children. This is what gives the canonical web flex
505+
// pattern — `header(auto) / body(flex:1) / footer(auto)`
506+
// filling a viewport — a definite main-axis pool to
507+
// distribute, without forcing a hardcoded `.Height(N)`.
508+
// Wobble protection: this is in Arrange, not Measure, so
509+
// DesiredSize is unaffected. The _arranging flag makes
510+
// the Yoga rerun's MeasureFunction return cached child
511+
// DesiredSize without re-measuring — sub-pixel jitter
512+
// only shifts Yoga's internal positions, not children's
513+
// own DesiredSize.
514+
// - VerticalAlignment != Stretch (caller asked for
515+
// fit-content vertically): pass NaN so Yoga stays in
516+
// MaxContent mode and a horizontal-drag's sub-pixel
517+
// finalSize.Height jitter doesn't drive flex-shrink.
430518
bool hasExplicitHeight = !double.IsNaN(Height);
519+
bool fillBlockAxisAtArrange = !hasExplicitHeight
520+
&& VerticalAlignment == VerticalAlignment.Stretch
521+
&& !double.IsInfinity(finalSize.Height);
522+
float arrangeHeight = (hasExplicitHeight || fillBlockAxisAtArrange)
523+
? (float)finalSize.Height
524+
: float.NaN;
431525
_rootNode.CalculateLayout(
432526
(float)finalSize.Width,
433-
hasExplicitHeight ? (float)finalSize.Height : float.NaN,
527+
arrangeHeight,
434528
LayoutDirection);
435529

436530
// Update cached positions from the new layout
@@ -551,9 +645,33 @@ private void SyncYogaTree()
551645

552646
// Yoga's constraints are content-area (excluding margin).
553647
// Add margin so WinUI's subtraction yields the correct content area.
648+
//
649+
// Mode → WinUI constraint:
650+
// - Undefined → +∞ (give me your content size).
651+
// - AtMost / Exactly → finite (w + margin). Preserves
652+
// the standard WinUI Measure contract — TextBlock
653+
// wrapping, Image stretch sizing, etc. all depend on
654+
// receiving a real cap rather than infinity.
655+
//
656+
// The "is this AtMost a fill target?" discrimination is
657+
// not done by changing the constraint — that would break
658+
// text wrapping. Instead the wrapper publishes Yoga's
659+
// hMode via [ThreadStatic] _outerYogaHeightMode so a
660+
// nested FlexPanel.MeasureOverride can tell whether it
661+
// is being measured for content (Undefined / AtMost) or
662+
// for fill (Exactly). See fillBlockAxis above.
554663
var constraintW = wMode == YogaMeasureMode.Undefined ? double.PositiveInfinity : w + mH;
555664
var constraintH = hMode == YogaMeasureMode.Undefined ? double.PositiveInfinity : h + mV;
556-
capturedChild.Measure(new Size(constraintW, constraintH));
665+
var prevYogaH = _outerYogaHeightMode;
666+
_outerYogaHeightMode = hMode;
667+
try
668+
{
669+
capturedChild.Measure(new Size(constraintW, constraintH));
670+
}
671+
finally
672+
{
673+
_outerYogaHeightMode = prevYogaH;
674+
}
557675
panel._measuredThisPass.Add(capturedChild);
558676
// Return content size (without margin) since Yoga tracks margins separately
559677
return new YogaSize(

0 commit comments

Comments
 (0)