From c623b57ad3698c960f5a14b05f00c7c3b84e29fd Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 17 Aug 2026 00:24:33 +0200 Subject: [PATCH] =?UTF-8?q?fix(ui):=20Options=20panel=20Config=20tab=20con?= =?UTF-8?q?tent=20escapes=20the=20window=20frame=20=E2=80=94=20stale=20vie?= =?UTF-8?q?wport=20anchor=20capture,=20not=20a=20missing=20clip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Config tab's footer sat mid-panel with further rows drawing below the window's bottom edge. Live-DAT measured: the mounted tab-host root is authored 300x362 (retail's real default window size), but the Config page slot underneath keeps its own larger design geometry (298x575 against a 300x600 canvas) until retail's real four-edge UiLayoutPolicy (UIElement::UpdateForParentSizeChange @0x00462640) shrinks it on the first ApplyAnchor pass -- verified stable, this part already worked. The actual bug: UiTemplateListBox.Viewport (the UiScrollablePanel that hosts + clips every row) is a programmatic C# element seeded at Bind time, BEFORE the tree's first draw frame -- before the ListBox has ever shrunk. Its legacy anchor baseline is captured lazily on its own first ApplyAnchor call, which lands AFTER the ListBox has already shrunk earlier in that same frame (parent-before-child draw order). That capture measures a negative bottom margin the stretch math preserves forever: the viewport stayed locked at its original 560px design height, clipping rows to a bound retail never actually gave the window on screen. Fix: force the viewport's anchor capture to happen immediately after seeding it, while its Width/Height still exactly equal a zero-margin baseline against the CURRENT (pre-shrink) parent, instead of lazily on the first draw frame against an already-shrunk parent. This is #372's sequel -- #372 fixed the 0x0 collapse case; this is the "ListBox itself later shrinks" case #372's own fixture never exercised. Three new tests (UiTemplateListBoxViewportTests using the live-DAT-measured 298x575/276x560 numbers, plus two ConfigOptionsPageControllerTests against the real production Bind path and the committed host fixture) all fail pre-fix, confirmed by temporarily reverting the change. Scoped to UiTemplateListBox's own viewport; UiScrollablePanel/ApplyAnchor/ ComputeAnchoredRect are untouched, so chat's transcript scrolling and every other UiScrollablePanel/UiItemList consumer are unaffected. fix #412 Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 88 ++++++++++++++ src/AcDream.App/UI/UiTemplateListBox.cs | 36 ++++++ .../ConfigOptionsPageControllerTests.cs | 115 ++++++++++++++++++ .../UI/UiTemplateListBoxViewportTests.cs | 71 +++++++++++ 4 files changed, 310 insertions(+) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index bc0424b2..c300f8dd 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,94 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## #412 — Options panel Config tab content escapes the window frame (footer mid-panel, rows drawing below the window's bottom edge) + +**Status:** DONE 2026-08-16/17 (overnight hover/UI round, Batch A bug 2). + +**Symptom (user screenshot):** the Config tab's Sound/Camera/Graphics/Rendering +Quality sections rendered with the Apply/Reset/Defaults footer sitting +mid-panel and further rows (Full Screen, Sync With Refresh Rate, Screen +Brightness, Adaptive Degrade, the quality dropdowns...) drawing BELOW the +window's bottom edge, outside the panel frame — not clipped to the window, +not reachable by scrolling. The user noted seeing this class of bug before +("another bug that I saw before as well") — a related but distinct symptom, +the CONTENT-behind-the-footer bleed-through, was already fixed as #381; this +is content escaping the WHOLE window, not just showing through the footer +strip. + +**Root cause — a stale anchor-baseline capture, not a missing clip.** +Live-DAT measured (`0x2100006E` slot `0x1000018D`): the merged tab-host root +is authored 300×362 (retail's real default window size — matches the floaty +frame's own 310×372 root), but the Config page slot underneath +(`0x10000213`) keeps its own larger authored design geometry, 298×575 +against a 300×600 design canvas — retail's real +`UIElement::UpdateForParentSizeChange @0x00462640` four-edge policy +(`L=T=R=B=1`, "preserve original margin on every edge") correctly shrinks +that slot to ~298×337 on the first `ApplyAnchor` layout pass, and the Config +ListBox (`0x10000200`, 276×560) shrinks right behind it via the SAME +per-element `UiLayoutPolicy` mechanism — both verified stable over repeated +simulated frames. The actual bug: `UiTemplateListBox.Viewport` (the +`UiScrollablePanel` that hosts + clips every row) is a PROGRAMMATIC C# +element seeded at `ConfigOptionsPageController.Bind` time — BEFORE the +tree's first real draw frame, i.e. before the ListBox has ever shrunk. Its +legacy `Left|Top|Right|Bottom` anchor baseline is captured lazily, on ITS +own first `ApplyAnchor` call, which lands AFTER the ListBox has already +shrunk earlier in that SAME frame (parent-before-child draw order) — so the +capture measures a NEGATIVE bottom margin (`parentH(297) - (0+560) = -263`) +that `ComputeAnchoredRect`'s stretch math preserves FOREVER: the viewport +stayed locked at its original 560px design height, clipping rows to a bound +retail never actually gave the window on screen. Rows past the real ~297px +stayed "visible" per the cull test and painted straight through the footer +and past the window's real bottom edge. + +**Fix (mechanism, not a workaround):** `UiTemplateListBox.Viewport`'s getter +now calls `_viewport.CaptureCurrentAnchorBaseline()` immediately after +seeding it — forcing the anchor capture to happen NOW, while the viewport's +own Width/Height still exactly equal a zero-margin baseline against its +CURRENT (pre-shrink) parent, instead of lazily on the first real draw frame +against an ALREADY-shrunk parent. `ComputeAnchoredRect` then tracks whatever +height the ListBox actually ends up at after its own `LayoutPolicy` runs, on +every subsequent frame — exactly #372's original intent (#372 fixed the 0×0 +collapse case; this is #372's sequel for the "ListBox itself later shrinks" +case, which #372's own fixture never exercised because its harness ListBox +had no parent to shrink it). +`src/AcDream.App/UI/UiTemplateListBox.cs`. + +**Tests:** `UiTemplateListBoxViewportTests.Viewport_TracksTheListBox_WhenTheListBoxItselfShrinksOnFirstLayout` +(synthetic two-level `UiLayoutPolicy` parent chain using the live-DAT-measured +298×575/276×560 numbers) and two `ConfigOptionsPageControllerTests` fixture +regressions +(`ConfigSlot_MatchesItsAuthoredOversizedDesign_BeforeAnyLayoutPass`, +`ConfigTab_ContentFitsInsideItsMountedWindow_AfterOneDrawFramesLayoutPass`) +against the REAL production `ConfigOptionsPageController.Bind` path and the +committed `options_panel_2100006E_1000018D.json` fixture. All three fail +pre-fix (red-green confirmed by temporarily reverting the fix) and pass +post-fix. Full App suite (5437/3 skips), Runtime (1735/0), and the complete +solution (14,575 tests) pass with the fix in place. + +**Blast-radius note (task-required):** the fix is scoped to +`UiTemplateListBox`'s own lazily-created viewport — it does not touch +`UiScrollablePanel`, `UiElement.ApplyAnchor`, or `ComputeAnchoredRect` +themselves, so chat's transcript scrolling and every other +`UiScrollablePanel`/`UiItemList` consumer (inventory grids, spell/component +catalogs, Chat tab's own filter blocks) are unaffected — confirmed by the +full solution run passing with no new failures anywhere outside the two +files this fix touches. The ONLY other `UiTemplateListBox` consumers are the +Character and Chat Options tabs, which share the identical +Bind-before-first-frame ordering and are now protected by the SAME fix. + +**Live check not performed:** the fix is proven via live-DAT-measured +geometry (real installed DAT numbers feeding both the regression tests and +this writeup) plus the fixture path that mirrors the exact production +`RetailUiRuntime.MountOptionsPanel` sequence, but the actual visual +Config-tab-in-window check was not done live (no interactive desktop +consent available this session — see #409's own note on the same +constraint). Owed: open Options -> Config with `ACDREAM_RETAIL_UI=1` and +confirm the footer and every row stay inside the window frame, with the +scrollbar reaching every row. + +--- + ## #411 — Hover feedback over interactive UI elements: no cursor swap, and item cells have no rollover state **Status:** CLOSED 2026-08-16 at the #409 hover-feedback completion round — the diff --git a/src/AcDream.App/UI/UiTemplateListBox.cs b/src/AcDream.App/UI/UiTemplateListBox.cs index f4dfaecc..f10241ff 100644 --- a/src/AcDream.App/UI/UiTemplateListBox.cs +++ b/src/AcDream.App/UI/UiTemplateListBox.cs @@ -177,6 +177,42 @@ public sealed class UiTemplateListBox : UiDatElement Height = Height, }; base.AddChild(_viewport); + + // #412-class fix (2026-08-16, overnight hover/UI round, Batch A bug 2): + // #372's seed above only fixed the 0×0 collapse for a ListBox whose OWN + // size never changes after the viewport is created. It does NOT hold for + // the Options panel's real mount: this ListBox (0x10000200 etc.) is a + // DAT-imported element carrying its own retail four-edge UiLayoutPolicy + // (UIElement::UpdateForParentSizeChange @0x00462640), and a page + // controller's Bind (which lazily creates this viewport, calling + // AddItemFromTemplateList) runs BEFORE the tree's first real draw frame — + // i.e. before ANY ApplyAnchor pass has ever run. The Options tab-host's + // page slot (298×575 authored) is taller than its actual 300×362 mounted + // container, so on the FIRST draw frame the slot's LayoutPolicy shrinks + // it top-down (e.g. to ~298×337), and THIS ListBox — also LayoutPolicy- + // driven, recomputed fresh every call, no capture-staleness of its own — + // shrinks right behind it (e.g. to ~282×297) in the SAME frame, BEFORE + // its per-child loop ever reaches the viewport below it. The viewport + // above was seeded at BIND time against the ListBox's PRE-shrink size + // (276×560) but its own legacy Left|Top|Right|Bottom anchor baseline is + // only CAPTURED lazily, on ITS first ApplyAnchor call — which lands AFTER + // the ListBox has already shrunk in that same frame. That capture then + // measures a NEGATIVE bottom margin (parentH(297) - (0+560) = -263) which + // ComputeAnchoredRect's stretch math preserves forever (h = parentH - mB - + // mT = 297 - (-263) - 0 = 560): the viewport is permanently locked at its + // ORIGINAL oversized height, clipping its rows to a bound retail never + // actually gave it on screen. Every row past the real ~297px stays + // "visible" per LayoutScrollableChildren's cull test and paints straight + // through the footer and past the window's real bottom edge — the exact + // "dozens of rows below the window frame" symptom (#412-class report: + // Full Screen/Sync/Screen Brightness/Adaptive Degrade/quality dropdowns + // drawing outside the panel). Forcing the capture to happen NOW, while + // Width/Height still exactly equal the ListBox's CURRENT (pre-shrink, but + // zero-margin) size, makes the captured margins (0,0,0,0) instead of + // negative — ComputeAnchoredRect then tracks whatever height the ListBox + // ACTUALLY ends up at after its own LayoutPolicy runs, on every frame + // after this one, exactly like #372 intended. + _viewport.CaptureCurrentAnchorBaseline(); } return _viewport; } diff --git a/tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs index c1cd9b66..d0374b5d 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs @@ -1108,4 +1108,119 @@ public sealed class ConfigOptionsPageControllerTests + $"built widget rendered {actual.Value}."); } } + + // ── #412-class regression: Config tab content escaping the window frame ── + // + // 2026-08-16/17 overnight hover/UI round, Batch A bug 2. The user's + // screenshot showed the Config tab's Apply/Reset/Defaults footer sitting + // mid-panel with further rows (Full Screen, Sync With Refresh Rate, + // Screen Brightness, Adaptive Degrade, the quality dropdowns...) drawing + // BELOW the window's bottom edge, outside the panel frame. Live-DAT + // measured root cause: the tab host's authored page slot (0x10000213, + // 298x575) is taller than its actual mounted container (the merged + // 0x1000018D root, authored 300x362 — retail's own + // UIElement::UpdateForParentSizeChange @0x00462640 four-edge policy + // shrinks it correctly on the first ApplyAnchor pass). The Config ListBox + // (0x10000200, 276x560) shrinks right behind it via the SAME per-element + // UiLayoutPolicy. But UiTemplateListBox.Viewport (the UiScrollablePanel + // that actually hosts + clips every row) is a programmatic C# element + // seeded at Bind time — BEFORE any ApplyAnchor pass has ever run — with + // the ListBox's THEN-current (pre-shrink) 276x560 size. Its own legacy + // Left|Top|Right|Bottom anchor baseline is captured lazily, on its first + // ApplyAnchor call, which lands AFTER the ListBox has already shrunk in + // that same frame — producing a negative captured bottom margin that + // ComputeAnchoredRect's stretch math preserves forever: the viewport + // stayed locked at its original 560px height, well past the real ~297px + // available, so rows drew (and were culled) against a bound retail never + // actually gave the window on screen. Fixed in UiTemplateListBox.Viewport + // by forcing the capture immediately after seeding, while the viewport's + // own Width/Height still exactly equal a zero-margin baseline. + + [Fact] + public void ConfigSlot_MatchesItsAuthoredOversizedDesign_BeforeAnyLayoutPass() + { + // Pins the LIVE-DAT-measured authored facts this whole bug turns on: + // the tab host's merged root is the SLOT's own (cropped) 300x362 + // extent, but the Config page slot underneath keeps ITS OWN larger + // authored design geometry (298x575, drawn against a 300x600 design + // canvas) until a layout pass actually reflows it. + (OptionsPanelController controller, _, bool bound) = BindReal(); + Assert.True(bound); + + Assert.Equal(300f, controller.TabPanel.Width); + Assert.Equal(362f, controller.TabPanel.Height); + + var configSlot = UiElement.FindDescendant(controller.TabPanel, ConfigPageSlotId)!; + Assert.Equal(575f, configSlot.Height); + Assert.NotNull(configSlot.LayoutPolicy); + } + + [Fact] + public void ConfigTab_ContentFitsInsideItsMountedWindow_AfterOneDrawFramesLayoutPass() + { + (OptionsPanelController controller, _, bool bound) = BindReal(); + Assert.True(bound); + + var configSlot = UiElement.FindDescendant(controller.TabPanel, ConfigPageSlotId)!; + var listBox = Assert.IsType( + UiElement.FindDescendant(configSlot, ConfigOptionsPageController.ListBoxElementId)); + UiElement viewport = Assert.Single(listBox.Children); + + // Drive the SAME top-down ApplyAnchor walk DrawSelfAndChildren runs + // every real frame — parent before children, all the way down — + // TWICE, to prove the result is a stable steady state and not an + // artifact of a single simulated pass. + for (int frame = 0; frame < 2; frame++) + ApplyAnchorRecursive(controller.TabPanel); + + // The viewport must track the REAL (shrunk) ListBox extent, not stay + // locked at its original oversized 560px design height. + Assert.True( + viewport.Height <= listBox.Height + 0.5f, + $"viewport height {viewport.Height} exceeds its ListBox's actual " + + $"height {listBox.Height} — rows will draw/cull past where " + + "the window actually is (the #412-class bug)."); + + // Nothing in the Config page may extend past the slot's own bottom + // edge, and the slot itself may not extend past the mounted window's + // own bottom edge — the exact "content escapes the window frame" + // symptom the user's screenshot showed. + float slotBottom = configSlot.Top + configSlot.Height; + Assert.True( + slotBottom <= controller.TabPanel.Height + 0.5f, + $"Config slot bottom {slotBottom} exceeds the mounted window's " + + $"own height {controller.TabPanel.Height}."); + + foreach (uint footerId in new[] + { + ConfigOptionsPageController_ApplyButtonId, + ConfigOptionsPageController_ResetButtonId, + ConfigOptionsPageController_DefaultsButtonId, + }) + { + UiElement? btn = UiElement.FindDescendant(configSlot, footerId); + Assert.NotNull(btn); + float bottom = btn!.Top + btn.Height; + Assert.True( + bottom <= slotBottom + 0.5f, + $"footer 0x{footerId:X8} bottom {bottom} exceeds the Config " + + $"slot's own bottom {slotBottom}."); + } + } + + // Apply/Reset/Defaults element ids — ConfigOptionsPageController's own + // constants of the same name are private; mirrored here rather than + // widening that class's surface just for this test. + private const uint ConfigOptionsPageController_ApplyButtonId = 0x100001FCu; + private const uint ConfigOptionsPageController_ResetButtonId = 0x100001FDu; + private const uint ConfigOptionsPageController_DefaultsButtonId = 0x100001FEu; + + private static void ApplyAnchorRecursive(UiElement e) + { + foreach (UiElement child in e.Children) + { + child.ApplyAnchor(e.Width, e.Height); + ApplyAnchorRecursive(child); + } + } } diff --git a/tests/AcDream.App.Tests/UI/UiTemplateListBoxViewportTests.cs b/tests/AcDream.App.Tests/UI/UiTemplateListBoxViewportTests.cs index 98ceeba6..02c6a176 100644 --- a/tests/AcDream.App.Tests/UI/UiTemplateListBoxViewportTests.cs +++ b/tests/AcDream.App.Tests/UI/UiTemplateListBoxViewportTests.cs @@ -76,4 +76,75 @@ public sealed class UiTemplateListBoxViewportTests Assert.True(row1.Visible, "row 1 culled — the #372 blank-tab bug"); Assert.True(row2.Visible, "row 2 culled — the #372 blank-tab bug"); } + + /// + /// #412-class regression (2026-08-16, overnight hover/UI round, Batch A bug + /// 2): the Options panel's Config tab escaped past the window frame — the + /// footer sitting mid-panel with further rows drawing below the window's + /// bottom edge. #372's own fixture above never exercises this because + /// gives the ListBox no parent — its own size + /// never changes after the viewport is seeded. The real Options mount is + /// different: this ListBox is itself a DAT-imported + /// carrying a real from its authored parent + /// (the Config page slot), and a page controller's Bind — which lazily + /// creates this viewport — runs BEFORE the tree's first draw frame, i.e. + /// before the ListBox has ever shrunk to fit its actual (smaller than + /// authored) container. This reproduces that ordering with a real + /// LayoutPolicy-driven parent standing in for the page slot. + /// + [Fact] + public void Viewport_TracksTheListBox_WhenTheListBoxItselfShrinksOnFirstLayout() + { + // A stand-in for the Config page slot: authored 298×575 against an + // authored 300×600 design canvas (live-DAT-measured values), but its + // real mounted container is only 300×362 — exactly retail's + // UIElement::UpdateForParentSizeChange four-edge policy (L=T=R=B=1, + // "preserve original margin on every edge"). + var slotPolicy = new UiLayoutPolicy( + leftMode: 1, topMode: 1, rightMode: 1, bottomMode: 1, + originalChild: UiPixelRect.FromPositionAndSize(2, 25, 298, 575), + originalParent: UiPixelRect.FromPositionAndSize(0, 0, 300, 600)); + var slot = new UiPanel + { + Left = 2, Top = 25, Width = 298, Height = 575, + LayoutPolicy = slotPolicy, + }; + var root = new UiPanel { Width = 300, Height = 362 }; + root.AddChild(slot); + + // The ListBox itself ALSO carries a real LayoutPolicy (live-DAT + // measured: authored 276×560 against the slot's own 298×575 design + // extent) — this is what shrinks it out from under the viewport. + var box = MakeListBox(276f, 560f); + var listBoxPolicy = new UiLayoutPolicy( + leftMode: 1, topMode: 1, rightMode: 1, bottomMode: 1, + originalChild: UiPixelRect.FromPositionAndSize(0, 0, 276, 560), + originalParent: UiPixelRect.FromPositionAndSize(0, 0, 298, 575)); + box.LayoutPolicy = listBoxPolicy; + slot.AddChild(box); + + // Seed the viewport with rows BEFORE any layout pass has ever run — + // exactly ConfigOptionsPageController.Bind's own ordering (it runs + // before RetailWindowFrame.Mount's first draw frame). + box.AddPrebuiltRow(new UiText { Width = 260f, Height = 20f }); + UiScrollablePanel viewport = box.ViewportForTest!; + + // Drive ONE simulated draw-frame's top-down ApplyAnchor walk — the + // SAME order DrawSelfAndChildren runs every frame: parent before + // children, all the way down. + slot.ApplyAnchor(root.Width, root.Height); // slot shrinks: 575 -> ~337 + box.ApplyAnchor(slot.Width, slot.Height); // listbox shrinks: 560 -> ~297 (still ahead of the viewport) + viewport.ApplyAnchor(box.Width, box.Height); // the viewport's FIRST EVER ApplyAnchor call + + // Pre-fix: the viewport's legacy anchor baseline captured a NEGATIVE + // bottom margin against the ALREADY-SHRUNK ListBox (parentH(~297) - + // (0+560) < 0), which ComputeAnchoredRect's stretch math preserves + // forever — the viewport stayed locked at its original 560px height, + // clipping rows to a bound retail never actually gave it on screen. + Assert.Equal(box.Height, viewport.Height, 3); + Assert.True( + viewport.Height < 400f, + $"viewport height {viewport.Height} did not shrink with its ListBox " + + "(560 == the pre-fix stale-capture bug)"); + } }