fix(vt): plain-style menu popup — no retail gradient/checkmark/gold scrollbar

Owner live-client report 2026-09-07 ("Drop down menus look horrible, there
is also a checkmark on the text there"): the prior S7 fix only replaced the
CLOSED-state button face when RetailButtonArt=false. The OPEN popup still
drew retail's sprite art unconditionally — the tan/orange gradient panel
(PopupBgSprite), the per-row highlight sprite (which bakes a checkbox/
checkmark glyph into its leftmost ~17px, per TextIndent's doc comment), and
the ornate gold scrollbar chrome — regardless of the menu's style.

UiMenu.OnDrawOverlay now branches on RetailButtonArt before ever reading
SpriteResolve: plain mode draws through two new methods (DrawGridPopupPlain,
DrawScrollablePopupPlain) that use only DrawFill/DrawRectOutline — flat
background + 1px border, the current entry filled like a list selection
(PlainSelectedColor, same value as UiMarkupList.SelectedColor), a new
hover fill (PlainHoverColor) for the row under the pointer, and left-aligned
text at PlainPadding. No checkmark is possible by construction since plain
mode never resolves or draws any sprite. Hover tracking needed a small new
mechanism: UiMenu.ReceivesHoverMouseMove now returns true while a plain
popup is open, so UiRoot's hover dispatch keeps delivering MouseMove to
_hoveredPopupIndex (reset on every open/close transition and on
HoverLeave). Scrollbar overflow (DrawPopupScrollbarPlain) draws a 1px-
bordered track and a flat thumb, both in PlainBorderColor, sharing the
exact UiScrollbar.ThumbRect geometry the hit-test math already uses — no
DAT track/thumb/arrow-button art. Hit-testing (OnHitTest/OnEvent's
MouseDown pick logic) is untouched; the retail sprite branch is now a
separate, unmodified path proven byte-identical by a new golden test.

Mutation proof: reverting UiMenu.cs alone (keeping the new tests) fails the
build outright — the six new tests reference PlainSelectedColor/
PlainHoverColor, which only exist after this change (CS1061 with the old
class). Filters run: AcDream.App.Tests Markup|UiMenu|Menu|Scrollbar
(242 passed, 3 pre-existing unrelated Lane=Manual live-DAT-probe failures
that require ACDREAM_PROBE_LIVE_MOUNT=1 and predate this change) and
AcDream.Plugins.MossTank.Tests Markup (9/9 passed).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-07 10:19:44 +02:00
parent fab134ae4f
commit 5bdf2fa395
2 changed files with 460 additions and 1 deletions

View file

@ -160,6 +160,22 @@ public sealed class UiMenu : UiElement
private bool _draggingPopupThumb;
private float _popupThumbDragOffset;
/// <summary>Index into <see cref="Items"/> of the row under the pointer while
/// the plain popup is open, or -1. Presentation-only (see
/// <see cref="PlainHoverColor"/>'s doc) — retail's sprite popup has no
/// equivalent hover concept, so this never affects the retail draw path.</summary>
private int _hoveredPopupIndex = -1;
/// <summary>Test seam, same rationale as <see cref="CurrentFaceSpriteForTest"/>.</summary>
internal int HoveredPopupIndexForTest => _hoveredPopupIndex;
/// <summary>
/// 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 <see cref="RetailButtonArt"/> is false).
/// </summary>
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.</summary>
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.
/// <summary>The current entry's row fill — identical value to
/// <see cref="UiMarkupList.SelectedColor"/> so a plugin's open dropdown
/// reads as the same widget family as its lists.</summary>
public Vector4 PlainSelectedColor { get; set; } = new(0.28f, 0.23f, 0.08f, 0.95f);
/// <summary>A slightly lighter fill for the row under the pointer (no
/// separate glyph or sprite swap — fills only, mirroring
/// <see cref="PlainOpenBorderColor"/>'s "tint, never a sprite swap" rule
/// for the closed state).</summary>
public Vector4 PlainHoverColor { get; set; } = new(0.40f, 0.33f, 0.14f, 0.95f);
private bool _open;
/// <summary>
@ -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.</summary>
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.
/// <summary>Plain counterpart of <see cref="DrawGridPopup"/> — flat fill +
/// 1px outline instead of the bevel/panel sprites, per-row selected/hover
/// fills instead of highlight sprites, <see cref="PlainTextColor"/>/
/// <see cref="TextColorGhosted"/> labels left-aligned at
/// <see cref="PlainPadding"/> instead of the authored <see cref="TextIndent"/>/
/// <see cref="ItemTextCentered"/> 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).</summary>
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);
}
}
/// <summary>Plain counterpart of <see cref="DrawScrollablePopup"/> — same
/// <see cref="VisibleTopRow"/>-sliced single column, plain
/// selected/hover row fills, and a plain scrollbar
/// (<see cref="DrawPopupScrollbarPlain"/>) instead of the sprite chrome.</summary>
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);
}
/// <summary>
/// Plain counterpart of <see cref="DrawPopupScrollbar"/>: a 1px-bordered
/// track and a flat thumb, both in <see cref="PlainBorderColor"/> — no DAT
/// thumb/track/arrow-button art at all. Shares the exact same
/// <see cref="UiScrollbar.ThumbRect"/> geometry (so the thumb's drawn
/// position matches <see cref="HandleScrollablePopupMouseDown"/>'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
/// (<see cref="HandleScrollablePopupMouseDown"/> is unchanged).
/// </summary>
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);
}
/// <summary>
/// Recomputes the hovered popup row from a MouseMove's local (lx,ly) —
/// same convention <see cref="OnEvent"/>'s MouseDown handling already uses
/// (<see cref="PopupTop"/>/<see cref="Border"/>-relative). Plain-mode-only:
/// see <see cref="ReceivesHoverMouseMove"/>'s doc comment for why this is
/// never invoked on the retail sprite-popup path.
/// </summary>
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;
}
/// <summary>Draw the universal 8-piece retail window bevel (corners + tiled edges +
/// tiled centre fill) framing the rect (<paramref name="x"/>,<paramref name="y"/>,
/// <paramref name="w"/>,<paramref name="h"/>). 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;
}