diff --git a/docs/plugin-ui-markup.md b/docs/plugin-ui-markup.md index ff591cecf..c779859a4 100644 --- a/docs/plugin-ui-markup.md +++ b/docs/plugin-ui-markup.md @@ -117,7 +117,18 @@ list boxes (owner live-client report, 2026-09-07), so a plugin `` now draws the flat VTank/Decal `HudCombo` box (list-matching fill/border, a left-aligned value, and a small ▾) by default; `style="retail"` opts back into the gold face for a panel that genuinely wants it. Any other value -throws `FormatException` at `Build`. +throws `FormatException` at `Build`. The plain style covers the WHOLE menu, +closed and open: a follow-up owner report (still 2026-09-07 — "Drop down +menus look horrible, there is also a checkmark on the text there") found the +OPEN popup still drew retail's tan/orange gradient panel, its ornate gold +scrollbar, and a baked checkmark glyph on the current entry even with +`style="plain"`. The open popup now matches ``'s own chrome too: a +flat fill + 1px border, one row per entry in the list text color, the +current entry filled like a list selection, the hovered entry a slightly +lighter fill, and no checkmark; more entries than the row cap show a plain +1px-bordered scrollbar track with a flat thumb, no DAT scrollbar art. +`style="retail"` keeps the sprite popup (gradient panel, checkmark-bearing +row art, ornate scrollbar) exactly as before, unchanged. Common to every element via `ApplyCommon`: `name`/`id` (a stable control name), `visible` (literal `true`/`false` or a bound `bool` property), diff --git a/src/AcDream.App/UI/UiMenu.cs b/src/AcDream.App/UI/UiMenu.cs index 810886a0f..95a03f664 100644 --- a/src/AcDream.App/UI/UiMenu.cs +++ b/src/AcDream.App/UI/UiMenu.cs @@ -160,6 +160,22 @@ public sealed class UiMenu : UiElement private bool _draggingPopupThumb; private float _popupThumbDragOffset; + /// Index into of the row under the pointer while + /// the plain popup is open, or -1. Presentation-only (see + /// 's doc) — retail's sprite popup has no + /// equivalent hover concept, so this never affects the retail draw path. + private int _hoveredPopupIndex = -1; + + /// Test seam, same rationale as . + internal int HoveredPopupIndexForTest => _hoveredPopupIndex; + + /// + /// The plain popup needs continuous MouseMove while open to keep its hover + /// highlight tracking the cursor (retail's sprite popup has no such state, so + /// this only matters when is false). + /// + public override bool ReceivesHoverMouseMove => _open && !RetailButtonArt; + private const int Border = RetailChromeSprites.Border; // 8-piece bevel thickness (5px) // The row sprites 0x0600124E/4D bake a checkbox/checkmark into the leftmost ~17px // square; the label starts just past it (box width + small gap) so text aligns with @@ -339,6 +355,28 @@ public sealed class UiMenu : UiElement /// with the list rows beneath it. public const float PlainPadding = 3f; + // ── Plain OPEN-popup chrome (RetailButtonArt = false). Owner live-client + // report 2026-09-07 ("Drop down menus look horrible, there is also a + // checkmark on the text there"): the S7 fix above only replaced the + // CLOSED-state button face — opening the dropdown still drew retail's + // tan/orange gradient panel (PopupBgSprite), the row-highlight sprites + // (whose art bakes a checkbox/checkmark glyph into the leftmost ~17px — + // see TextIndent's doc comment), and the ornate scrollbar chrome. VTank's + // own open combo (VVS HudCombo, docs/research/vtank-kb/08-ui-views.md §2) + // is a plain dark list — no gradient, no baked checkmark — so the plain + // popup below reuses UiMarkupList's own list palette (same rationale as + // PlainBackgroundColor/PlainBorderColor above) rather than inventing a + // third color scheme. + /// The current entry's row fill — identical value to + /// so a plugin's open dropdown + /// reads as the same widget family as its lists. + public Vector4 PlainSelectedColor { get; set; } = new(0.28f, 0.23f, 0.08f, 0.95f); + /// A slightly lighter fill for the row under the pointer (no + /// separate glyph or sprite swap — fills only, mirroring + /// 's "tint, never a sprite swap" rule + /// for the closed state). + public Vector4 PlainHoverColor { get; set; } = new(0.40f, 0.33f, 0.14f, 0.95f); + private bool _open; /// @@ -377,6 +415,7 @@ public sealed class UiMenu : UiElement OnOpen?.Invoke(); } _open = value; + _hoveredPopupIndex = -1; // stale hover from the last time this popup was open if (FindRoot() is not { } root) return; if (value) root.SetActivePopup(this, () => SetOpen(false)); else root.ClearActivePopup(this); @@ -617,8 +656,29 @@ public sealed class UiMenu : UiElement /// pass) greys out the part of the popup that overlaps it. protected override void OnDrawOverlay(UiRenderContext ctx) { + if (!_open) return; + + // Owner live-client report 2026-09-07: the S7 closed-state fix left the + // OPEN popup drawing retail's gradient/checkmark art regardless of + // RetailButtonArt. Plain mode needs no SpriteResolve at all — it draws + // only untextured fills/outlines (see DrawGridPopupPlain/ + // DrawScrollablePopupPlain's own doc comments). + if (!RetailButtonArt) + { + ctx.PushAlphaAbsolute(1f); + try + { + if (Scrollable) + DrawScrollablePopupPlain(ctx); + else + DrawGridPopupPlain(ctx); + } + finally { ctx.PopAlpha(); } + return; + } + var resolve = SpriteResolve; - if (!_open || resolve is null) return; + if (resolve is null) return; // Force OPAQUE (a menu reads solid even though the chat window is translucent). // Draw bevel → panel fill → row sprites → labels, all through the sprite bucket @@ -772,6 +832,152 @@ public sealed class UiMenu : UiElement } } + // ── Plain OPEN-popup drawing (RetailButtonArt = false) ────────────────── + // + // Owner live-client report 2026-09-07: no DAT art at all — a flat fill + // background, a 1px border, one row per entry in the list text color, the + // current entry filled like a list selection, the hovered entry a slightly + // lighter fill, and NO checkmark (retail's row-highlight sprites bake a + // checkbox/checkmark glyph into their leftmost ~17px — see TextIndent's + // doc comment — which a flat DrawFill simply cannot draw, so plain mode + // has none by construction). These mirror DrawGridPopup/DrawScrollablePopup's + // shape exactly (same column/row math, same VisibleTopRow/EnabledProvider + // rules) so hit-testing (OnHitTest/OnEvent, unchanged) stays byte-identical + // to what it already computes for the retail path. + + /// Plain counterpart of — flat fill + + /// 1px outline instead of the bevel/panel sprites, per-row selected/hover + /// fills instead of highlight sprites, / + /// labels left-aligned at + /// instead of the authored / + /// justification (plain mode has no baked + /// checkbox glyph to align past, and no authored per-menu justification + /// convention — VTank's own list rows are always left-aligned). + private void DrawGridPopupPlain(UiRenderContext ctx) + { + float outerTop = PopupTop; + float inX = Border, inY = outerTop + Border; + + ctx.DrawFill(0f, outerTop, OuterW, OuterH, PlainBackgroundColor); + ctx.DrawRectOutline(0f, outerTop, OuterW, OuterH, PlainBorderColor, 1f); + + for (int i = 0; i < Items.Count; i++) + { + int col = i / RowsPerColumn, row = i % RowsPerColumn; + float x = inX + col * ColumnWidth, y = inY + row * RowHeight; + bool selected = Equals(Items[i].Payload, Selected); + if (selected) + ctx.DrawFill(x, y, ColumnWidth, RowHeight, PlainSelectedColor); + else if (i == _hoveredPopupIndex) + ctx.DrawFill(x, y, ColumnWidth, RowHeight, PlainHoverColor); + } + + float textY = (RowHeight - LineH()) * 0.5f; + for (int i = 0; i < Items.Count; i++) + { + int col = i / RowsPerColumn, row = i % RowsPerColumn; + bool avail = EnabledProvider?.Invoke(Items[i].Payload) ?? true; + DrawLabel(ctx, Items[i].Label, inX + col * ColumnWidth + PlainPadding, + inY + row * RowHeight + textY, + avail ? PlainTextColor : TextColorGhosted); + } + } + + /// Plain counterpart of — same + /// -sliced single column, plain + /// selected/hover row fills, and a plain scrollbar + /// () instead of the sprite chrome. + private void DrawScrollablePopupPlain(UiRenderContext ctx) + { + ConfigurePopupScroll(); + + float outerTop = PopupTop; + float inX = Border, inY = outerTop + Border; + + ctx.DrawFill(0f, outerTop, OuterW, OuterH, PlainBackgroundColor); + ctx.DrawRectOutline(0f, outerTop, OuterW, OuterH, PlainBorderColor, 1f); + + int start = VisibleTopRow; + int count = System.Math.Min(EffectiveVisibleRows, Items.Count - start); + float textY = (RowHeight - LineH()) * 0.5f; + for (int i = 0; i < count; i++) + { + int idx = start + i; + float y = inY + i * RowHeight; + bool selected = Equals(Items[idx].Payload, Selected); + if (selected) + ctx.DrawFill(inX, y, ColumnWidth, RowHeight, PlainSelectedColor); + else if (idx == _hoveredPopupIndex) + ctx.DrawFill(inX, y, ColumnWidth, RowHeight, PlainHoverColor); + } + for (int i = 0; i < count; i++) + { + int idx = start + i; + bool avail = EnabledProvider?.Invoke(Items[idx].Payload) ?? true; + DrawLabel(ctx, Items[idx].Label, inX + PlainPadding, inY + i * RowHeight + textY, + avail ? PlainTextColor : TextColorGhosted); + } + + DrawPopupScrollbarPlain(ctx, inX + ColumnWidth, inY); + } + + /// + /// Plain counterpart of : a 1px-bordered + /// track and a flat thumb, both in — no DAT + /// thumb/track/arrow-button art at all. Shares the exact same + /// geometry (so the thumb's drawn + /// position matches 's hit-test + /// math), but draws no separate up/down button glyphs — plain mode has no + /// art for them and the click regions already work through geometry alone + /// ( is unchanged). + /// + private void DrawPopupScrollbarPlain(UiRenderContext ctx, float x, float y) + { + if (!IsPopupScrollbarPresentationVisible) return; + + ctx.DrawFill(x, y, ScrollbarWidth, InteriorH, PlainBackgroundColor); + ctx.DrawRectOutline(x, y, ScrollbarWidth, InteriorH, PlainBorderColor, 1f); + + if (!PopupScroll.HasOverflow) return; + + float decExtent = System.Math.Clamp(ScrollButtonExtent, 0f, InteriorH); + float incExtent = System.Math.Clamp(ScrollButtonExtent, 0f, InteriorH - decExtent); + float trackTop = decExtent; + float trackLen = MathF.Max(0f, InteriorH - decExtent - incExtent); + var (ty, th) = UiScrollbar.ThumbRect(PopupScroll, trackTop, trackLen); + ctx.DrawFill(x + 1f, y + ty, MathF.Max(0f, ScrollbarWidth - 2f), th, PlainBorderColor); + } + + /// + /// Recomputes the hovered popup row from a MouseMove's local (lx,ly) — + /// same convention 's MouseDown handling already uses + /// (/-relative). Plain-mode-only: + /// see 's doc comment for why this is + /// never invoked on the retail sprite-popup path. + /// + private void UpdatePlainPopupHover(float lx, float ly) + { + float ix = lx - Border, iy = ly - (PopupTop + Border); + _hoveredPopupIndex = Scrollable ? HoveredScrollableIndex(ix, iy) : HoveredGridIndex(ix, iy); + } + + private int HoveredGridIndex(float ix, float iy) + { + if (ix < 0 || ix >= InteriorW || iy < 0 || iy >= InteriorH) return -1; + int col = (int)(ix / ColumnWidth); + int row = (int)(iy / RowHeight); + int idx = col * RowsPerColumn + row; + return row >= 0 && row < RowsPerColumn && idx >= 0 && idx < Items.Count ? idx : -1; + } + + private int HoveredScrollableIndex(float ix, float iy) + { + if (ix < 0 || ix >= ColumnWidth || iy < 0 || iy >= InteriorH) return -1; + int row = (int)(iy / RowHeight); + int idx = VisibleTopRow + row; + return row >= 0 && row < EffectiveVisibleRows && idx >= 0 && idx < Items.Count ? idx : -1; + } + /// Draw the universal 8-piece retail window bevel (corners + tiled edges + /// tiled centre fill) framing the rect (,, /// ,). Reuses the same geometry + @@ -846,11 +1052,25 @@ public sealed class UiMenu : UiElement } } + // Plain-mode hover tracking (see ReceivesHoverMouseMove's doc comment): + // continuous MouseMove while the plain popup is open recomputes the + // hovered row for DrawGridPopupPlain/DrawScrollablePopupPlain. Checked + // BEFORE the MouseUp/HoverLeave/MouseDown-only gates below since, like + // the Scrollable drag block above, it spans an event type none of them + // handle. + if (!RetailButtonArt && _open && e.Type == UiEventType.MouseMove) + { + UpdatePlainPopupHover(e.Data1, e.Data2); + return true; + } + if (e.Type is UiEventType.MouseUp or UiEventType.HoverLeave or UiEventType.CaptureChanged) { _facePressed = false; // the momentary face flick ends here + if (e.Type == UiEventType.HoverLeave) + _hoveredPopupIndex = -1; return false; } diff --git a/tests/AcDream.App.Tests/UI/UiMenuPlainStyleTests.cs b/tests/AcDream.App.Tests/UI/UiMenuPlainStyleTests.cs index 013181e6d..66c5bf689 100644 --- a/tests/AcDream.App.Tests/UI/UiMenuPlainStyleTests.cs +++ b/tests/AcDream.App.Tests/UI/UiMenuPlainStyleTests.cs @@ -208,4 +208,243 @@ public sealed class UiMenuPlainStyleTests Assert.Equal(1, QuadCount(segs, FontTexture)); Assert.Equal(0, QuadCount(segs, 0u)); } + + // ── OPEN-popup coverage (owner live-client report 2026-09-07: "Drop down + // menus look horrible, there is also a checkmark on the text there") ──── + // + // The S7 fix above only replaced the CLOSED-state button face. The tests + // below pin the OPEN popup: plain mode draws no sprite/gradient/checkmark + // art at all (only untextured fills via DrawFill/DrawRectOutline, exactly + // like UiMarkupList's own chrome), while the retail popup — the class + // default, and every non-markup UiMenu caller — is unchanged (the + // existing golden above only covers the closed state; the golden here + // covers the open popup). + + private const float PlainRowHeight = 18f; + private const float PlainColumnWidth = 90f; + + private static UiMenu MakePopupMenu( + bool retailButtonArt, int itemCount, int rowsPerColumn, bool scrollable, + System.Action? countResolveCall = null) + { + var items = Enumerable.Range(0, itemCount) + .Select(i => new UiMenu.MenuItem(i == 0 ? "W" : $"row{i}", (object?)i)) + .ToArray(); + return new UiMenu + { + Width = 100f, Height = 20f, + DatFont = MakeFont(), + // Retail tests read the texture id straight back (id => (id, w, h)) so a + // texture id is a specific sprite by construction — the same convention + // UiAncestorClipTests uses. Plain tests wrap this to prove it is NEVER + // invoked (no gradient/sprite of ANY kind, not just the ones this class + // happens to name). + SpriteResolve = id => + { + countResolveCall?.Invoke(1); + return (id, 8, 8); + }, + RetailButtonArt = retailButtonArt, + NormalSprite = 0x06004D65u, + PressedSprite = 0x06004D66u, + PopupBgSprite = 0x0600124Cu, + ItemNormalSprite = 0x0600124Eu, + ItemHighlightSprite = 0x0600124Du, + // Non-zero retail scrollbar chrome ids (UiScrollbar.cs's own doc-cited + // values) so a plain test can assert these are never resolved — a zero + // id would be indistinguishable from "never set", and would collide + // with the untextured-fill bucket's own texture-0 key. + ScrollTrackSprite = 0x06004C5Fu, + ScrollThumbSprite = 0x06004C63u, + ScrollThumbTopSprite = 0x06004C60u, + ScrollThumbBottomSprite = 0x06004C66u, + ScrollUpSprite = 0x06004C6Cu, + ScrollDownSprite = 0x06004C69u, + ColumnWidth = PlainColumnWidth, + RowHeight = PlainRowHeight, + RowsPerColumn = rowsPerColumn, + Scrollable = scrollable, + OpenUpward = false, // downward: PopupTop == Height, simplest math for these tests + Items = items, + ButtonLabelProvider = () => "W", + }; + } + + private static bool HasFillQuad( + System.Collections.Generic.IReadOnlyList<(uint Texture, System.Collections.Generic.IReadOnlyList Verts)> segs, + float x, float y, float w, float h, Vector4 color, float tol = 0.05f) + { + foreach (var seg in segs) + { + if (seg.Texture != 0u) continue; + var v = seg.Verts; + for (int b = 0; b + FloatsPerQuad <= v.Count; b += FloatsPerQuad) + { + float qx = v[b], qy = v[b + 1]; + float qw = v[b + 8] - qx, qh = v[b + 9] - qy; + float r = v[b + 4], g = v[b + 5], bl = v[b + 6], a = v[b + 7]; + if (MathF.Abs(qx - x) < tol && MathF.Abs(qy - y) < tol + && MathF.Abs(qw - w) < tol && MathF.Abs(qh - h) < tol + && MathF.Abs(r - color.X) < tol && MathF.Abs(g - color.Y) < tol + && MathF.Abs(bl - color.Z) < tol && MathF.Abs(a - color.W) < tol) + return true; + } + } + return false; + } + + /// Opens the popup (MouseDown on the closed face) then, if given, + /// hovers a row via MouseMove — the same (Data1,Data2) local-coordinate + /// convention already uses for MouseDown. + private static void OpenAndHover(UiMenu menu, int? hoverRow = null) + { + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, Data1: 10, Data2: 10))); + Assert.True(menu.IsOpen); + if (hoverRow is { } row) + { + // ix = lx - Border, iy = ly - (PopupTop + Border); PopupTop == Height (20) + // for these OpenUpward=false menus, Border == RetailChromeSprites.Border (5). + int ly = 20 + RetailChromeSprites.Border + row * (int)PlainRowHeight + (int)(PlainRowHeight / 2); + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseMove, Data1: 10, Data2: ly))); + } + } + + [Fact] + public void Plain_OpenPopup_GridMode_DrawsFlatFillsSelectedAndHover_NoSpriteResolveCalls() + { + int resolveCalls = 0; + var menu = MakePopupMenu(retailButtonArt: false, itemCount: 3, rowsPerColumn: 7, scrollable: false, + countResolveCall: n => resolveCalls += n); + menu.Selected = 1; // row 1 is "current" + OpenAndHover(menu, hoverRow: 2); // row 2 is hovered (not selected) + + var (renderer, ctx) = MakeContext(200f, 200f); + menu.DrawOverlays(ctx); + var segs = renderer.DebugSpriteSegmentVerts; + + Assert.Equal(0, resolveCalls); // no DAT art resolved at all — not even by id + Assert.Equal(0, QuadCount(segs, 0x0600124Cu)); // retail PopupBgSprite never drawn + Assert.Equal(0, QuadCount(segs, 0x0600124Du)); // retail ItemHighlightSprite (bakes the checkmark) never drawn + Assert.Equal(0, QuadCount(segs, 0x0600124Eu)); // retail ItemNormalSprite never drawn + + float outerTop = menu.Height; // OpenUpward=false + float outerW = menu.PopupOuterWidth, outerH = menu.PopupOuterHeight; + float inX = RetailChromeSprites.Border, inY = outerTop + RetailChromeSprites.Border; + + Assert.True(HasFillQuad(segs, 0f, outerTop, outerW, outerH, menu.PlainBackgroundColor), + "expected the plain popup background fill"); + Assert.True(HasFillQuad(segs, inX, inY + 1 * PlainRowHeight, PlainColumnWidth, PlainRowHeight, menu.PlainSelectedColor), + "expected row 1 (selected/current) filled with PlainSelectedColor"); + Assert.True(HasFillQuad(segs, inX, inY + 2 * PlainRowHeight, PlainColumnWidth, PlainRowHeight, menu.PlainHoverColor), + "expected row 2 (hovered) filled with PlainHoverColor"); + + // background(1) + outline(4 sides) + selected row(1) + hovered row(1) = 7, + // nothing else untextured. + Assert.Equal(7, QuadCount(segs, 0u)); + } + + [Fact] + public void Plain_OpenPopup_RowText_LeftAlignedAtPlainPadding() + { + var menu = MakePopupMenu(retailButtonArt: false, itemCount: 1, rowsPerColumn: 7, scrollable: false); + OpenAndHover(menu); + + var (renderer, ctx) = MakeContext(200f, 200f); + menu.DrawOverlays(ctx); + + // Item 0's label is "W" — the one glyph MakeFont() defines — so exactly + // one FontTexture quad renders, at column 0's PlainPadding inset (no + // authored TextIndent/centering in plain mode). + var glyphSeg = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == FontTexture); + Assert.Equal(RetailChromeSprites.Border + UiMenu.PlainPadding, glyphSeg.Verts[0], 3); + } + + [Fact] + public void Plain_OpenPopup_ScrollableOverflow_DrawsPlainTrackAndFlatThumb_NoDatArt() + { + int resolveCalls = 0; + var menu = MakePopupMenu(retailButtonArt: false, itemCount: 12, rowsPerColumn: 5, scrollable: true, + countResolveCall: n => resolveCalls += n); + menu.Selected = 0; // row 0 (visible) is "current" + OpenAndHover(menu); + + var (renderer, ctx) = MakeContext(200f, 200f); + menu.DrawOverlays(ctx); + var segs = renderer.DebugSpriteSegmentVerts; + + Assert.True(menu.PopupScroll.HasOverflow); + Assert.Equal(0, resolveCalls); + Assert.Equal(0, QuadCount(segs, menu.ScrollTrackSprite)); + Assert.Equal(0, QuadCount(segs, menu.ScrollThumbSprite)); + + float outerTop = menu.Height; + float inX = RetailChromeSprites.Border, inY = outerTop + RetailChromeSprites.Border; + float scrollbarX = inX + PlainColumnWidth; + + Assert.True(HasFillQuad(segs, inX, inY, PlainColumnWidth, PlainRowHeight, menu.PlainSelectedColor), + "expected visible row 0 (selected/current) filled with PlainSelectedColor"); + Assert.True(HasFillQuad(segs, scrollbarX, inY, menu.ScrollbarWidth, 5 * PlainRowHeight, menu.PlainBackgroundColor), + "expected the scrollbar track background fill"); + + // popup bg(1)+outline(4) + selected row(1) + scrollbar bg(1)+outline(4) + thumb(1) = 12. + Assert.Equal(12, QuadCount(segs, 0u)); + } + + [Fact] + public void Plain_ScrollablePopup_ContentFits_DrawsTrackWithNoThumb() + { + var menu = MakePopupMenu(retailButtonArt: false, itemCount: 3, rowsPerColumn: 5, scrollable: true); + OpenAndHover(menu); + + var (renderer, ctx) = MakeContext(200f, 200f); + menu.DrawOverlays(ctx); + var segs = renderer.DebugSpriteSegmentVerts; + + Assert.False(menu.PopupScroll.HasOverflow); + + // popup bg(1)+outline(4) + scrollbar bg(1)+outline(4) = 10, no thumb quad + // (nothing selected/hovered here either). + Assert.Equal(10, QuadCount(segs, 0u)); + } + + [Fact] + public void Plain_OpenPopup_HitTesting_SelectsHoveredRow_ClosesPopup() + { + // The new hover-tracking MouseMove handling must not change what a + // MouseDown on the same row does — same rows, same scroll, same pick. + object? picked = null; + var menu = MakePopupMenu(retailButtonArt: false, itemCount: 3, rowsPerColumn: 7, scrollable: false); + menu.OnSelect = p => picked = p; + OpenAndHover(menu, hoverRow: 2); + + int ly = 20 + RetailChromeSprites.Border + 2 * (int)PlainRowHeight + (int)(PlainRowHeight / 2); + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, Data1: 10, Data2: ly))); + + Assert.Equal(2, picked); + Assert.False(menu.IsOpen); + } + + [Fact] + public void Retail_OpenPopup_DrawIsByteForByteUnchanged_RegressionGolden() + { + // A golden pin for the OPEN popup on a retail-styled (RetailButtonArt=true, + // the class default) menu — proves the S7-follow-up refactor of + // OnDrawOverlay (adding the plain branch) left the retail branch + // byte-identical: same bevel, same panel-fill sprite, same per-row + // highlight/normal sprite, and critically NO untextured fill anywhere + // (the plain path is a fully separate branch, never blended in). + var menu = MakePopupMenu(retailButtonArt: true, itemCount: 2, rowsPerColumn: 7, scrollable: false); + menu.Selected = 1; + OpenAndHover(menu); + + var (renderer, ctx) = MakeContext(200f, 200f); + menu.DrawOverlays(ctx); + var segs = renderer.DebugSpriteSegmentVerts; + + Assert.Equal(1, QuadCount(segs, RetailChromeSprites.CenterFill)); // bevel drawn + Assert.Equal(1, QuadCount(segs, 0x0600124Cu)); // PopupBgSprite panel fill + Assert.Equal(1, QuadCount(segs, 0x0600124Du)); // ItemHighlightSprite (row 1, selected) + Assert.Equal(1, QuadCount(segs, 0x0600124Eu)); // ItemNormalSprite (row 0) + Assert.Equal(0, QuadCount(segs, 0u)); // no untextured fill in the retail path + } }