fix(ui): effects list stayed pinned at its authored height in a taller window
The "Beneficial Spells in Effect" window rendered its rows only in the top 249px and painted the rest of the list as empty black background, with a scrollbar thumb sized for a viewport far smaller than the visible one. It did not depend on window size, and the last visible row was sliced mid-height -- a clip boundary, not a missing row. Root cause is the #412 class again. The authored list element (0x10000123) is a UiTemplateListBox, not a UiItemList, so EffectsUiController creates the item list itself and attaches it as a child with fill anchors. That baseline is captured lazily on the child's first ApplyAnchor -- which lands AFTER the host has already been resized to the restored window height in the same frame. The capture then measures a bottom margin of (hostH - 249) and ComputeAnchoredRect preserves it forever: h = hostH - (hostH - 249) = 249, at every subsequent size. Rows past 249px fail LayoutCells' cull test and never draw. Capturing the baseline at creation, while the list's extent still exactly equals the host's, makes the margins (0,0,0,0) so it tracks the host from then on. Identical fix and reason to UiTemplateListBox's own viewport seed. The spellbook's component list is built by the same pattern and had the same latent defect; it is fixed alongside. Why it shipped: every existing test in EffectsUiControllerTests supplies a synthetic UiItemList as the list element, so `host is UiItemList` is true and the controller uses it directly -- the create-and-attach branch that actually runs against real dat was never exercised. The new test binds the real fixture, which builds the real UiTemplateListBox. Neutralising the fix makes it fail with the exact production numbers (expected 547, actual 249). Measured, not guessed. tools/LayoutDump grew --resize, which reproduces retail's raw-edge policy (UIElement::UpdateForParentSizeChange @ 0x00462640) offline, and it ruled out the authored geometry, the import, the layout policy and the window frame in turn -- all four are faithful. The 4px gap between the scrollbar and the window's inner edge is likewise authored: the user confirmed retail shows the same gap, so it is deliberately left alone. Solution builds clean; 14,465 tests pass on the standard hermetic lane filter, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
6db0d69816
commit
7e75be23d1
4 changed files with 121 additions and 0 deletions
|
|
@ -101,6 +101,19 @@ public sealed class EffectsUiController : IRetainedPanelController
|
|||
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom,
|
||||
};
|
||||
host.AddChild(list);
|
||||
// #412-class: this list is created HERE, after Build, so its
|
||||
// fill-anchor baseline would be captured lazily on its first
|
||||
// ApplyAnchor -- which lands AFTER the host has already been
|
||||
// resized by the restored window size in that same frame. The
|
||||
// capture then measures a bottom margin of (hostH - authoredH)
|
||||
// and ComputeAnchoredRect preserves it forever, pinning this list
|
||||
// at its AUTHORED height inside a taller host: rows past that
|
||||
// height are culled and the rest of the list paints as empty
|
||||
// background. Capturing NOW, while Width/Height still exactly
|
||||
// equal the host's, makes the margins (0,0,0,0) so the list
|
||||
// tracks the host at every later size. Same fix, same reason, as
|
||||
// UiTemplateListBox's own viewport seed.
|
||||
list.CaptureCurrentAnchorBaseline();
|
||||
}
|
||||
return new EffectsUiController(
|
||||
layout,
|
||||
|
|
|
|||
|
|
@ -211,6 +211,19 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom,
|
||||
};
|
||||
componentHost.AddChild(componentList);
|
||||
// #412-class: this component list is created HERE, after Build, so its
|
||||
// fill-anchor baseline would be captured lazily on its first
|
||||
// ApplyAnchor -- which lands AFTER the host has already been
|
||||
// resized by the restored window size in that same frame. The
|
||||
// capture then measures a bottom margin of (hostH - authoredH)
|
||||
// and ComputeAnchoredRect preserves it forever, pinning this component list
|
||||
// at its AUTHORED height inside a taller host: rows past that
|
||||
// height are culled and the rest of the list paints as empty
|
||||
// background. Capturing NOW, while Width/Height still exactly
|
||||
// equal the host's, makes the margins (0,0,0,0) so the list
|
||||
// tracks the host at every later size. Same fix, same reason, as
|
||||
// UiTemplateListBox's own viewport seed.
|
||||
componentList.CaptureCurrentAnchorBaseline();
|
||||
}
|
||||
|
||||
return new SpellbookWindowController(
|
||||
|
|
|
|||
|
|
@ -314,4 +314,59 @@ public sealed class EffectsUiControllerTests
|
|||
return found;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void ApplyLayoutPass(UiElement parent)
|
||||
{
|
||||
foreach (UiElement child in parent.Children)
|
||||
{
|
||||
child.ApplyAnchor(parent.Width, parent.Height);
|
||||
ApplyLayoutPass(child);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #412-class: the effects list must track its authored host at EVERY size,
|
||||
/// including when the very first anchor pass runs after the window has
|
||||
/// already been restored to a different size.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The real DAT list element is a <c>UiTemplateListBox</c>, not a
|
||||
/// <c>UiItemList</c>, so the controller creates the item list itself and
|
||||
/// attaches it — a lazily-created child whose fill-anchor baseline is
|
||||
/// captured on first use. Every other test here supplies a synthetic
|
||||
/// UiItemList as the list element and therefore never exercises that path,
|
||||
/// which is why this shipped: the list stayed pinned at its authored 249px
|
||||
/// inside a taller host, and everything below that painted as empty
|
||||
/// background.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void EffectsList_TracksItsHostHeight_EvenWhenFirstLayoutFollowsAResize()
|
||||
{
|
||||
var table = SpellTable.LoadFromReader(new StringReader(
|
||||
"Spell ID,Name,Flags [Hex]\n41,Boon,0x4\n"));
|
||||
var spellbook = new Spellbook(table);
|
||||
ImportedLayout layout = FixtureLoader.LoadPositiveEffects();
|
||||
|
||||
using EffectsUiController controller = EffectsUiController.Bind(
|
||||
layout, spellbook, positive: true, () => 0d, NoTex, id => id,
|
||||
Templates(), "SELECT A SPELL")!;
|
||||
|
||||
UiElement host = layout.FindElement(EffectsUiController.ListId)!;
|
||||
UiItemList list = host.Children.OfType<UiItemList>().Single();
|
||||
|
||||
// No layout pass has run yet — exactly the state a restored window is in
|
||||
// when its persisted size is applied before the first draw frame.
|
||||
layout.Root.Height = 660f;
|
||||
ApplyLayoutPass(layout.Root);
|
||||
Assert.Equal(host.Height, list.Height);
|
||||
|
||||
// ...and it keeps tracking in both directions afterwards.
|
||||
layout.Root.Height = 200f;
|
||||
ApplyLayoutPass(layout.Root);
|
||||
Assert.Equal(host.Height, list.Height);
|
||||
|
||||
layout.Root.Height = 660f;
|
||||
ApplyLayoutPass(layout.Root);
|
||||
Assert.Equal(host.Height, list.Height);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,20 @@ if (root is null)
|
|||
Console.WriteLine($"layout 0x{ids[0]:X8}");
|
||||
Print(root, 0);
|
||||
|
||||
int resizeAt = Array.IndexOf(args, "--resize");
|
||||
if (resizeAt >= 0 && resizeAt + 2 < args.Length)
|
||||
{
|
||||
// Reproduce a window resize exactly, without a running client: retail's
|
||||
// raw-edge policy (UIElement::UpdateForParentSizeChange @ 0x00462640) is a
|
||||
// pure function of the authored rects and the new parent size, which is
|
||||
// what UiElement.ApplyAnchor feeds it every frame.
|
||||
int rw = int.Parse(args[resizeAt + 1]);
|
||||
int rh = int.Parse(args[resizeAt + 2]);
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"resized to {rw}x{rh}:");
|
||||
PrintResized(root, UiPixelRect.FromPositionAndSize(0, 0, rw, rh), 0);
|
||||
}
|
||||
|
||||
if (args.Contains("--built"))
|
||||
{
|
||||
// What the importer actually PRODUCES, next to what the dat authored.
|
||||
|
|
@ -56,6 +70,32 @@ if (args.Contains("--built"))
|
|||
}
|
||||
return 0;
|
||||
|
||||
void PrintResized(ElementInfo e, UiPixelRect parentRect, int depth)
|
||||
{
|
||||
string pad = new(' ', depth * 2);
|
||||
Console.WriteLine(
|
||||
$"{pad}0x{e.Id:X8} type={e.Type,-10} "
|
||||
+ $"x={parentRect.X0,6} y={parentRect.Y0,6} "
|
||||
+ $"w={parentRect.Width,6} h={parentRect.Height,6}");
|
||||
|
||||
foreach (ElementInfo child in e.Children)
|
||||
{
|
||||
var authored = UiPixelRect.FromPositionAndSize(
|
||||
(int)child.X, (int)child.Y, (int)child.Width, (int)child.Height);
|
||||
var originalParent = child.HasOriginalParentSize
|
||||
? UiPixelRect.FromPositionAndSize(
|
||||
0, 0, (int)child.OriginalParentWidth, (int)child.OriginalParentHeight)
|
||||
: UiPixelRect.FromPositionAndSize(0, 0, parentRect.Width, parentRect.Height);
|
||||
|
||||
UiPixelRect next = UiLayoutPolicy.Apply(
|
||||
child.Left, child.Top, child.Right, child.Bottom,
|
||||
authored, originalParent, authored,
|
||||
UiPixelRect.FromPositionAndSize(0, 0, parentRect.Width, parentRect.Height));
|
||||
|
||||
PrintResized(child, next, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
void PrintBuilt(UiElement e, int depth)
|
||||
{
|
||||
string pad = new(' ', depth * 2);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue