diff --git a/src/AcDream.App/UI/Layout/EffectsUiController.cs b/src/AcDream.App/UI/Layout/EffectsUiController.cs index 3ab84179..dba834b5 100644 --- a/src/AcDream.App/UI/Layout/EffectsUiController.cs +++ b/src/AcDream.App/UI/Layout/EffectsUiController.cs @@ -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, diff --git a/src/AcDream.App/UI/Layout/SpellbookWindowController.cs b/src/AcDream.App/UI/Layout/SpellbookWindowController.cs index a4f52b15..f22a1c06 100644 --- a/src/AcDream.App/UI/Layout/SpellbookWindowController.cs +++ b/src/AcDream.App/UI/Layout/SpellbookWindowController.cs @@ -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( diff --git a/tests/AcDream.App.Tests/UI/Layout/EffectsUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/EffectsUiControllerTests.cs index 23fefebf..f588c917 100644 --- a/tests/AcDream.App.Tests/UI/Layout/EffectsUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/EffectsUiControllerTests.cs @@ -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); + } + } + + /// + /// #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. + /// + /// + /// The real DAT list element is a UiTemplateListBox, not a + /// UiItemList, 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. + /// + [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().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); + } } diff --git a/tools/LayoutDump/Program.cs b/tools/LayoutDump/Program.cs index a30e43a5..c54b98c8 100644 --- a/tools/LayoutDump/Program.cs +++ b/tools/LayoutDump/Program.cs @@ -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);