acdream/src/AcDream.App/UI/Layout/LayoutImporter.cs
Erik ad4ed51d6b feat(ui): importer Fix 5 — build meter text children; drop character XP injection hacks
LayoutImporter.BuildWidget gains a UiMeter-specific branch after the
ConsumesDatChildren gate: Type-3 slice containers (already consumed by
DatWidgetFactory.BuildMeter to extract sprite ids) are skipped, but all
other children (Type-12 UIElement_Text overlays) are built normally,
registered in byId, and attached as UiElement children of the meter.

This fixes the XP meter (0x10000236): its two Type-12 children
  0x10000237 "XP for next level:" label
  0x10000238 XP-to-next-level value
are now real UiText widgets in the tree, findable via FindElement.

CharacterStatController.Bind now uses FindElement + LinesProvider binding
instead of injecting runtime AddChild nodes. The two previously-injected
UiText overlays are removed; the controller binds Padding=0, ClickThrough,
RightAligned on the dat-origin widgets instead.

New constants XpNextLabelId + XpNextValueId replace the old comment block.

TAB GROUPS (SKIPPED — documented): UiText.ConsumesDatChildren flipping is
NOT safe globally (risks chat/vitals) and the page-visibility pass in
CharacterStatController depends on tab group Children.Count==0. Tab sprite
injection onto layout.Root stays and is explicitly commented as deliberate.

VITALS SAFE: health/stamina/mana meters have only Type-3 children → new
loop finds nothing → zero UiElement children added → byte-identical render.
VitalsController.BindMeter AddChild(number) pattern unchanged.

Tests: 3 new LayoutImporterTests (slice-only, Fix-5 text children,
vitals regression guard) + 3 CharacterStatControllerTests (label bound,
value bound, missing children no-throw). 715 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 11:35:19 +02:00

584 lines
28 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
namespace AcDream.App.UI.Layout;
/// <summary>
/// The result of importing a retail LayoutDesc: a <see cref="UiElement"/> tree with
/// an O(1) lookup table for finding any element by its dat id.
/// </summary>
public sealed class ImportedLayout
{
/// <summary>Root widget of the imported tree.</summary>
public UiElement Root { get; }
private readonly Dictionary<uint, UiElement> _byId;
public ImportedLayout(UiElement root, Dictionary<uint, UiElement> byId)
{
Root = root;
_byId = byId;
}
/// <summary>Find a widget by its dat element id (e.g. <c>0x100000E6</c>).
/// Returns null if the id was skipped (Type-12 prototype) or not present.</summary>
public UiElement? FindElement(uint id)
=> _byId.TryGetValue(id, out var e) ? e : null;
}
/// <summary>
/// Two-layer layout importer for retail LayoutDesc dat objects.
///
/// <para>
/// <strong>Pure layer</strong> (<see cref="Build"/> / <see cref="BuildFromInfos"/>):
/// converts a pre-resolved <see cref="ElementInfo"/> tree into a <see cref="UiElement"/>
/// tree via <see cref="DatWidgetFactory"/>. Testable without dats or OpenGL — all tests
/// in <c>LayoutImporterTests.cs</c> exercise this layer only.
/// </para>
///
/// <para>
/// <strong>Dat shell</strong> (<see cref="Import"/>): reads a <see cref="LayoutDesc"/>,
/// converts each top-level <see cref="ElementDesc"/> to a fully resolved
/// <see cref="ElementInfo"/> (applying <c>BaseElement</c> / <c>BaseLayoutId</c>
/// inheritance with a cycle guard), then delegates to <see cref="Build"/>.
/// </para>
///
/// <para>
/// Meter elements (Type 7) consume their own dat-children: <see cref="DatWidgetFactory"/>
/// reads the grandchild slice-sprite ids during <see cref="UiMeter"/> construction, so the
/// children must NOT be added as separate <see cref="UiElement"/> nodes in the tree.
/// Every other element type recurses its children generically.
/// </para>
/// </summary>
public static class LayoutImporter
{
// ── Pure layer ────────────────────────────────────────────────────────────
/// <summary>
/// Convenience for tests: attach <paramref name="children"/> to
/// <paramref name="rootInfo"/>, then call <see cref="Build"/>.
/// The children list is set directly on <paramref name="rootInfo"/>;
/// any existing children are replaced.
/// </summary>
public static ImportedLayout BuildFromInfos(
ElementInfo rootInfo,
IEnumerable<ElementInfo> children,
Func<uint, (uint, int, int)> resolve,
UiDatFont? datFont,
Func<uint, UiDatFont?>? fontResolve = null)
{
rootInfo.Children = new List<ElementInfo>(children);
return Build(rootInfo, resolve, datFont, fontResolve);
}
/// <summary>
/// Pure builder: produce the widget tree from a fully resolved
/// <see cref="ElementInfo"/> tree (children already attached).
/// </summary>
/// <param name="fontResolve">Optional per-element font resolver — FontDid →
/// <see cref="UiDatFont"/> (or null if the font can't be loaded). When supplied,
/// elements with a non-zero <see cref="ElementInfo.FontDid"/> get their own dat
/// font at build time instead of the shared <paramref name="datFont"/> fallback.
/// Null preserves the original single-font behavior for all callers that don't
/// pass it — no behavior change for the live game path.</param>
public static ImportedLayout Build(
ElementInfo rootInfo,
Func<uint, (uint, int, int)> resolve,
UiDatFont? datFont,
Func<uint, UiDatFont?>? fontResolve = null)
{
var byId = new Dictionary<uint, UiElement>();
// Root is never a Type-12 prototype in practice; fall back to a generic
// container if the factory returns null for an exotic root type.
var root = BuildWidget(rootInfo, resolve, datFont, fontResolve, byId);
if (root is null)
{
Console.WriteLine($"[D.2b] LayoutImporter: root element 0x{rootInfo.Id:X8} (type {rootInfo.Type}) produced no widget — using empty container fallback.");
root = new UiDatElement(rootInfo, resolve);
}
return new ImportedLayout(root, byId);
}
private static UiElement? BuildWidget(
ElementInfo info,
Func<uint, (uint, int, int)> resolve,
UiDatFont? datFont,
Func<uint, UiDatFont?>? fontResolve,
Dictionary<uint, UiElement> byId)
{
var w = DatWidgetFactory.Create(info, resolve, datFont, fontResolve);
if (w is null) return null; // Type-12 style prototype — skip
if (info.Id != 0) byId[info.Id] = w;
// Behavioral widgets that draw their full appearance + reproduce their dat
// sub-elements procedurally (Meter's 3-slice, Menu's label/rows, Field/Text caps,
// Button labels, Scrollbar arrows) CONSUME their dat children — building those as
// separate widgets double-draws and lets an invisible child steal pointer/focus
// from the behavioral widget (e.g. the channel Menu's label child intercepting the
// button click). Only generic containers (UiDatElement, panels) recurse. See
// UiElement.ConsumesDatChildren.
if (!w.ConsumesDatChildren)
{
foreach (var child in info.Children)
{
var cw = BuildWidget(child, resolve, datFont, fontResolve, byId);
if (cw is not null) w.AddChild(cw);
}
}
else if (w is UiMeter)
{
// Fix 5: UiMeter.ConsumesDatChildren=true swallows ALL children, including text
// label/value overlays that are separate renderable widgets (not part of the bar
// art). BuildMeter in DatWidgetFactory already consumed the Type-3 slice containers
// (reads their grandchild sprite ids to populate Back*/Front* properties). The
// remaining non-Type-3 children (typically Type-12 UIElement_Text overlays such
// as the XP meter's 0x10000237 label + 0x10000238 value) ARE renderable and belong
// in the widget tree. We build them here explicitly, registered in byId so
// FindElement can locate them, and attached as children of the meter so they render
// as overlays at their dat-local coordinates. The controller can then locate these
// widgets via FindElement and bind LinesProvider without injecting new runtime nodes.
//
// Type-3 children are SKIPPED here because BuildMeter already consumed them (they
// carry the 3-slice sprite ids, not text content; building them again would
// double-draw the bar art). All other child types are built normally.
//
// Safe for vitals: the health/stamina/mana meters have ONLY Type-3 slice children
// (no text children). This loop finds nothing for them → no change to vitals.
foreach (var child in info.Children)
{
if (child.Type == 3) continue; // slice containers: already consumed by BuildMeter
var cw = BuildWidget(child, resolve, datFont, fontResolve, byId);
if (cw is not null) w.AddChild(cw);
}
}
return w;
}
// ── Dat shell ─────────────────────────────────────────────────────────────
/// <summary>
/// Dat shell, ElementInfo half: load the layout + resolve inheritance + build the
/// ElementInfo tree (no widgets). Exposed for fixture generation + conformance tests.
/// Returns null if the layout is missing.
/// </summary>
/// <param name="dats">The dat collection to read the LayoutDesc from.</param>
/// <param name="layoutId">The LayoutDesc dat id to read.</param>
public static ElementInfo? ImportInfos(DatCollection dats, uint layoutId)
{
var ld = dats.Get<LayoutDesc>(layoutId);
if (ld is null) return null;
// Collect the set of element ids that are referenced as a BaseElement by ANY
// element in THIS layout (where BaseLayoutId == layoutId). Such elements are
// purely inheritance templates ("prototypes") — retail never instantiates them
// as live widgets. Example: the toolbar slot prototype 0x100001B2 in LayoutDesc
// 0x21000016, which all 18 slot elements inherit from and which has no own media.
//
// NOTE: the Resolve path reads BaseElement from the raw dat directly (via
// dats.Get<LayoutDesc>), so the prototype never needs to appear in the built
// widget tree for inheritance to work. Skipping it here is safe.
var referencedAsBase = new HashSet<uint>();
foreach (var kv in ld.Elements)
CollectBaseRefsInDesc(kv.Value, layoutId, referencedAsBase);
var tops = new List<ElementInfo>();
foreach (var kv in ld.Elements)
{
// Skip pure prototype elements: top-level elements that are referenced as a
// base template by another element in this same layout AND have no own state
// media (so they draw nothing and contribute nothing but their inherited shape).
var d = kv.Value;
if (referencedAsBase.Contains(d.ElementId) && HasNoOwnMedia(d))
{
Console.WriteLine($"[D.2b] LayoutImporter: skipping prototype element 0x{d.ElementId:X8} in layout 0x{layoutId:X8} (no own media, referenced as BaseElement).");
continue;
}
tops.Add(Resolve(dats, d, new HashSet<(uint, uint)>()));
}
return tops.Count == 1
? tops[0]
: new ElementInfo { Id = 0, Type = 3, Children = tops };
}
/// <summary>
/// Dat shell: load the LayoutDesc, resolve inheritance for every top-level
/// element, and build the widget tree. Returns null if the layout is absent
/// from the dats.
///
/// <para>
/// <b>Dat UIState visibility model (2026-06-26 audit):</b>
/// The dat's <c>ElementDesc.DefaultState</c> field specifies which SPRITE/MEDIA
/// state an element starts in (e.g., <c>Normal</c>, <c>Minimized</c>). It does
/// NOT encode visibility of sibling Group containers. The <c>StateDesc</c>'s
/// <see cref="DatReaderWriter.Enums.IncorporationFlags"/> contains X/Y/Width/Height/
/// ZLevel/PassToChildren — there is no Visible flag.
/// </para>
///
/// <para>
/// Windows that display multiple sibling Group containers at the same position
/// (the character footer's three state-groups; the tab-page content areas) manage
/// visibility purely at runtime via C++ controller code. Retail uses
/// <c>UIElement::SetState(stateId)</c> on the parent to propagate state, then
/// C++ getters access the right sub-group by element id. All groups are shipped
/// as visible in the imported widget tree; the relevant controllers
/// (<see cref="CharacterStatController"/>) perform the initial show/hide.
/// </para>
/// </summary>
/// <param name="fontResolve">Optional per-element font resolver (see
/// <see cref="Build"/> for details). Null = original single-font behavior.</param>
public static ImportedLayout? Import(
DatCollection dats,
uint layoutId,
Func<uint, (uint, int, int)> resolve,
UiDatFont? datFont,
Func<uint, UiDatFont?>? fontResolve = null)
{
var rootInfo = ImportInfos(dats, layoutId);
if (rootInfo is null) return null;
return Build(rootInfo, resolve, datFont, fontResolve);
}
// ── Inheritance resolution ────────────────────────────────────────────────
/// <summary>True when a pure-container leaf should inherit its base's subtree (the
/// gmInventoryUI sub-window mount): the derived element has no own children, no own
/// state media, and the base resolved to ≥1 child. Inert for media-bearing inheritors
/// (close button / title), elements with their own children, and childless style
/// prototypes (vitals/chat/toolbar text — the base prototype has no children).</summary>
internal static bool ShouldMountBaseChildren(int derivedChildCount, int derivedMediaCount, int baseChildCount)
=> derivedChildCount == 0 && derivedMediaCount == 0 && baseChildCount > 0;
/// <summary>
/// Converts an <see cref="ElementDesc"/> to a resolved <see cref="ElementInfo"/>:
/// reads own fields + media, applies the BaseElement / BaseLayoutId chain
/// (cycle-guarded by <paramref name="baseChain"/>), then resolves + attaches children.
/// </summary>
private static ElementInfo Resolve(
DatCollection dats,
ElementDesc d,
HashSet<(uint layoutId, uint elementId)> baseChain)
{
// Read this element's own fields + media (no inheritance, no children yet).
var self = ToInfo(d);
var result = self;
List<ElementInfo>? baseChildren = null;
// Apply BaseElement / BaseLayoutId inheritance if present.
if (d.BaseElement != 0 && d.BaseLayoutId != 0
&& baseChain.Add((d.BaseLayoutId, d.BaseElement)))
{
var baseLd = dats.Get<LayoutDesc>(d.BaseLayoutId);
var baseDesc = baseLd is null ? null : FindDesc(baseLd, d.BaseElement);
if (baseDesc is not null)
{
// Recurse the base chain (already guarded by the HashSet add above).
var baseInfo = Resolve(dats, baseDesc, baseChain);
// Derived fields override the base; children are attached below.
result = ElementReader.Merge(baseInfo, self);
baseChildren = baseInfo.Children; // capture for the sub-window mount
}
}
// Resolve + attach children. Each child gets a FRESH base-chain set:
// the cycle guard is per-element, not shared across siblings.
foreach (var kv in d.Children)
result.Children.Add(Resolve(dats, kv.Value, new HashSet<(uint, uint)>()));
// Sub-window mount: a pure-container leaf (no own children, no own media) that inherits
// from a base WITH content attaches the base's subtree. Targets the gmInventoryUI panels
// (0x100001CD/CE/CF — Type-0, media-less, childless, BaseLayoutId → a gm*UI window);
// inert for media-bearing inheritors (close button/title) and childless style prototypes
// (vitals/chat/toolbar). See ShouldMountBaseChildren + the B-Grid spec.
if (baseChildren is not null
&& ShouldMountBaseChildren(d.Children.Count, self.StateMedia.Count, baseChildren.Count))
{
result.Children.AddRange(baseChildren);
// The mounted slot's layer WITHIN THE FRAME is its OWN ZLevel, not the mounted
// sub-window root's. The gm*UI sub-window roots carry ZLevel 1000 (their standalone
// top-window layer); ElementReader.Merge's zero-wins-base rule made the slot (own
// ZLevel 0) inherit that 1000, and the #145 ZOrder fold (ReadOrder ZLevel·10000)
// turns 1000 into ZOrder ≈ 10,000,000 — sinking the whole panel BEHIND the frame's
// Alphablend backdrop (ZLevel 100 → ≈ 1,000,000). The backdrop then overpaints the
// panel's captions/meter/cells (the wash-out bug; the paperdoll root happens to be
// ZLevel 0 so it escaped). Restore the slot's own frame-layer so the panel sits in
// FRONT of the backdrop. (B-Controller debug 2026-06-21; continuation of #145.)
result.ZLevel = self.ZLevel;
// Sub-layout slot sizing: when a sub-layout is mounted into a slot that is TALLER
// (or wider) than the sub-layout's own design height, the cascade of Bottom/Right
// anchored elements must be resized to fill the slot. Without this step, the
// background element (0x10000226 for the Attributes tab, designed at 337px) captures
// _amB = slotH - designH = 238 and permanently stays at 337px, and every element
// within it with Bottom anchor stays at its design size too (list box at 160px
// instead of the correct 398px).
//
// Retail reference: UIElement::UpdateForParentSizeChange (C++ runtime) propagates a
// new parent height down the whole tree, updating each Bottom-anchored child's
// rect by maintaining its bottom margin. We replicate that cascade here at import
// time on the base-children subtree so the anchor-capture during the first render
// frame sees zero (or unchanged) margins — not the spurious "slot bigger than design"
// margin.
//
// Safe guard: only fire when the slot has explicit size AND the base children are
// smaller (i.e., this is the "slot bigger than design" case).
// Inventory/paperdoll slots are unaffected because their slot H already matches the
// sub-layout design H, so no cascade occurs.
if (result.Width > 0 && result.Height > 0)
{
foreach (var child in baseChildren)
{
var childAnchors = ElementReader.ToAnchors(child.Left, child.Top, child.Right, child.Bottom);
const AnchorEdges StretchAll = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom;
if (child.X == 0 && child.Y == 0
&& (childAnchors & StretchAll) == StretchAll // full-stretch background
&& child.Width <= result.Width
&& child.Height > 0 && child.Height < result.Height) // smaller than slot
{
// Cascade UpdateForParentSizeChange down the subtree: maintain each
// Bottom-anchored element's bottom margin while growing the parent height.
CascadeHeight(child, child.Height, result.Height);
}
}
}
}
return result;
}
/// <summary>
/// Read an <see cref="ElementDesc"/>'s own scalar fields + state media into a
/// fresh <see cref="ElementInfo"/>. No inheritance is applied; children are not
/// attached (the caller handles those).
/// </summary>
private static ElementInfo ToInfo(ElementDesc d)
{
// Normalize DefaultState: UIStateId.ToString() gives "Undef"/"Undefined" or "0" when
// no default is set; map those to "" so UiDatElement treats them as "no preference".
var defState = d.DefaultState.ToString();
var info = new ElementInfo
{
Id = d.ElementId,
Type = d.Type,
X = (float)d.X,
Y = (float)d.Y,
Width = (float)d.Width,
Height = (float)d.Height,
Left = d.LeftEdge,
Top = d.TopEdge,
Right = d.RightEdge,
Bottom = d.BottomEdge,
ReadOrder = d.ReadOrder,
ZLevel = d.ZLevel,
DefaultStateName = (defState is "Undef" or "Undefined" or "0") ? "" : defState,
};
// DirectState (unnamed, key "").
if (d.StateDesc is not null)
ReadState(d.StateDesc, "", info);
// Named states (e.g. UIStateId.HideDetail → "HideDetail").
foreach (var s in d.States)
ReadState(s.Value, s.Key.ToString(), info);
return info;
}
/// <summary>
/// Read the first <see cref="MediaDescImage"/> from <paramref name="sd"/> into
/// <c>info.StateMedia[name]</c> and extract the font DID from property 0x1A
/// (<c>ArrayBaseProperty → DataIdBaseProperty</c>) if not yet set.
/// </summary>
private static void ReadState(StateDesc sd, string name, ElementInfo info)
{
// Only MediaDescImage is read for rendering; MediaDescCursor items (on grips/drag bars)
// are intentionally skipped — cursor behavior is Plan 2.
foreach (var m in sd.Media)
{
if (m is MediaDescImage img && img.File != 0)
{
info.StateMedia[name] = (img.File, (int)img.DrawMode);
break;
}
}
// Font DID: Properties[0x1A] is ArrayBaseProperty{ DataIdBaseProperty }.
// Format doc §3: "ArrayBaseProperty containing ONE DataIdBaseProperty".
if (info.FontDid == 0 && sd.Properties is not null
&& sd.Properties.TryGetValue(0x1Au, out var raw)
&& raw is ArrayBaseProperty arr && arr.Value.Count > 0
&& arr.Value[0] is DataIdBaseProperty did)
{
info.FontDid = did.Value;
}
if (sd.Properties is not null)
{
// HorizontalJustification (0x14): EnumBaseProperty.
// Retail values: 0=Left, 1=Center, 3=Right, 5=Right (treat 5 as Right per spec).
// Only update if still at the default (Center); derived-wins handled in Merge.
if (info.HJustify == HJustify.Center
&& sd.Properties.TryGetValue(0x14u, out var hRaw)
&& hRaw is EnumBaseProperty hEnum)
{
info.HJustify = hEnum.Value switch
{
0u => HJustify.Left,
1u => HJustify.Center,
3u => HJustify.Right,
5u => HJustify.Right,
_ => HJustify.Center, // unknown → center (safe default)
};
}
// VerticalJustification (0x15): EnumBaseProperty.
// Retail values: 2=Top, 4=Bottom; absent/other = Center.
if (info.VJustify == VJustify.Center
&& sd.Properties.TryGetValue(0x15u, out var vRaw)
&& vRaw is EnumBaseProperty vEnum)
{
info.VJustify = vEnum.Value switch
{
2u => VJustify.Top,
4u => VJustify.Bottom,
_ => VJustify.Center,
};
}
// ColorBaseProperty (0x1B): ARGB bytes → normalized [0,1] Vector4 (R,G,B,A).
// Only read when not already set (first dat state wins; Merge propagates from base).
if (info.FontColor is null
&& sd.Properties.TryGetValue(0x1Bu, out var cRaw)
&& cRaw is ColorBaseProperty cProp)
{
var c = cProp.Value;
// ColorARGB stores components as bytes (0255); normalize to [0,1] for Vector4.
// Alpha=0 in the dat typically means fully opaque (retail convention: 0 → 255).
float a = c.Alpha == 0 ? 1f : c.Alpha / 255f;
info.FontColor = new System.Numerics.Vector4(c.Red / 255f, c.Green / 255f, c.Blue / 255f, a);
}
}
}
// ── Prototype detection helpers ───────────────────────────────────────────
/// <summary>
/// Recursively walks <paramref name="d"/> and all its children, adding to
/// <paramref name="result"/> the <c>BaseElement</c> of every descriptor that
/// references this layout (<c>BaseLayoutId == layoutId</c>). Used by
/// <see cref="ImportInfos"/> to identify pure prototype/template elements that
/// should not be instantiated as live widgets.
/// </summary>
private static void CollectBaseRefsInDesc(ElementDesc d, uint layoutId, HashSet<uint> result)
{
if (d.BaseElement != 0 && d.BaseLayoutId == layoutId)
result.Add(d.BaseElement);
foreach (var kv in d.Children)
CollectBaseRefsInDesc(kv.Value, layoutId, result);
}
/// <summary>
/// Returns true when <paramref name="d"/> carries no own state media — i.e. its
/// <c>StateDesc</c> (DirectState) and <c>States</c> (named states) yield no
/// <see cref="MediaDescImage"/> entries with a non-zero file id.
/// Such elements are pure inheritance templates with no rendering content.
/// </summary>
private static bool HasNoOwnMedia(ElementDesc d)
{
// Re-use ToInfo's media extraction: if the resulting StateMedia is empty the
// element has no renderable image in any state.
var info = ToInfo(d);
return info.StateMedia.Count == 0;
}
// ── Element tree search ───────────────────────────────────────────────────
/// <summary>
/// Find an <see cref="ElementDesc"/> by id anywhere in the top-level tree of
/// <paramref name="ld"/> (depth-first). Returns null if not found.
/// </summary>
private static ElementDesc? FindDesc(LayoutDesc ld, uint id)
{
foreach (var kv in ld.Elements)
{
var f = FindDescIn(kv.Value, id);
if (f is not null) return f;
}
return null;
}
private static ElementDesc? FindDescIn(ElementDesc d, uint id)
{
if (d.ElementId == id) return d;
foreach (var kv in d.Children)
{
var f = FindDescIn(kv.Value, id);
if (f is not null) return f;
}
return null;
}
// ── Sub-layout slot sizing helpers ────────────────────────────────────────
/// <summary>
/// Recursively propagates a parent-height change from <paramref name="oldParentH"/>
/// to <paramref name="newParentH"/> through <paramref name="el"/> and all its
/// descendants, replicating <c>UIElement::UpdateForParentSizeChange</c> from the
/// retail runtime.
///
/// <para>Three anchor cases for the vertical axis:
/// <list type="bullet">
/// <item>Top+Bottom (stretch): maintain top margin AND bottom margin; height grows.</item>
/// <item>Bottom only (pin-to-bottom, fixed height): maintain bottom margin; Y moves, H unchanged.</item>
/// <item>Top only or None: Y and H unchanged (pinned to top with fixed height).</item>
/// </list>
/// Recurse with the element's new height so grandchildren see the correct parent size.</para>
///
/// <para>This is called once at import time on base-children that are mounted into a
/// slot taller than the sub-layout's design height.</para>
/// </summary>
private static void CascadeHeight(ElementInfo el, float oldParentH, float newParentH)
{
float oldH = el.Height;
float newH = el.Height;
float oldY = el.Y;
var anchors = ElementReader.ToAnchors(el.Left, el.Top, el.Right, el.Bottom);
bool hasTop = (anchors & AnchorEdges.Top) != 0;
bool hasBottom = (anchors & AnchorEdges.Bottom) != 0;
if (hasTop && hasBottom)
{
// Stretch: maintain both top and bottom margins.
// amT = el.Y (pinned to top, unchanged)
// amB = oldParentH - (el.Y + el.H)
float amB = oldParentH - (el.Y + el.Height);
newH = newParentH - el.Y - amB;
if (newH < 0f) newH = 0f;
el.Height = newH;
}
else if (hasBottom && !hasTop)
{
// Pin to bottom: maintain bottom margin, height fixed, Y moves.
// amB = oldParentH - (el.Y + el.H)
float amB = oldParentH - (el.Y + el.Height);
float newY = newParentH - amB - el.Height;
el.Y = newY;
// Height unchanged; newH = oldH for recursion.
}
// else: Top-only or None — element is top-pinned with fixed height; nothing moves.
// Recurse into children with the element's new height as their parent.
foreach (var child in el.Children)
CascadeHeight(child, oldH, newH);
}
}