fix #385: Options dropdowns — white centered text + size-to-content popup

User gate report (Campaign OP happy-testing round, 2026-08-13): every
Config-tab dropdown drew its text gold + left-aligned and its popup a
fixed 6 rows regardless of item count. All three were unmeasured styling
divergences — the authored data (new probe menuprobe3, live DAT) says:

- button label child 0x10000355: fontColor white, hJustify=Center
- row template 0x1000035A: fontColor white, hJustify=Center
- popup ListBox 0x10000358: edge-docked L=T=R=B=1, the authored condition
  arming retail UIElement_Menu::RecalculatePopupSize @0x0046caf0 —
  popup resizes to the ListBox's summed content height, uncapped
  (0x0046e5f4..0046e66c via ResizeScrollableArea's 0x32 broadcast)

UiMenu gains three opt-in properties (ButtonTextCentered,
ItemTextCentered, PopupSizeToContent) plus retail Open @0x0046cc42's
empty-list gate; chat + vendor keep the class defaults, so their shipped
behavior is untouched. ConfigOptionsPageController.ApplyMenuChrome wires
all four corrections for the 8 Config menus with the probe citation.

The same probe found vendor's authored popup ListBox is ALSO docked while
our vendor dropdown ships G5's fixed 6-row window — filed as #386 +
register row AD-88 (UNCLEAR: the G5 retail screenshot and the decomp
mechanism conflict) instead of silently reworking a user-gated surface.

The "resolution change resizes the window" observation from the same
report is #374's designed windowed-mode behavior (display-mode switching
is #376/#377) — no change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-13 08:51:57 +02:00
parent 028920420d
commit a9b6435f55
8 changed files with 422 additions and 9 deletions

View file

@ -314,6 +314,21 @@ public static class ConfigOptionsPageController
// glyph to clear space for (see MenuChromeSprites' own doc).
menu.TextIndent = 0f;
menu.ButtonTextIndent = 0f;
// User gate report 2026-08-13 (happy-testing round): dropdown text
// drew gold + left-aligned and the popup a fixed 6 rows. All four
// corrections below are MEASURED authored facts (menuprobe3,
// OptionsPanelLiveMountProbeTests.ProbeMenuPopupSizingAndTextStyle),
// not styling choices:
// - button label child 0x10000355: fontColor white, hJustify=Center;
// - row template 0x1000035A: fontColor white, hJustify=Center;
// - popup ListBox 0x10000358: edges L=T=R=B=1 (docked), which arms
// retail's RecalculatePopupSize @0x0046caf0 size-to-content path
// (see UiMenu.PopupSizeToContent's own doc for the mechanism).
menu.TextColor = Vector4.One;
menu.ButtonTextCentered = true;
menu.ItemTextCentered = true;
menu.PopupSizeToContent = true;
}
/// <summary>The live read/write seam every row on this page writes/reads

View file

@ -190,6 +190,47 @@ public sealed class UiMenu : UiElement
public uint ItemNormalSprite { get; set; } // 0x0600124E — a row background (191×17)
public uint ItemHighlightSprite { get; set; } // 0x0600124D — the active channel's row
/// <summary>
/// Port of retail <c>UIElement_Menu::RecalculatePopupSize @0x0046caf0</c>
/// (user gate report 2026-08-13: the Sound Features popup drew a fixed 6
/// rows tall for a 3-item list): when the authored popup ListBox is
/// edge-docked on both top+bottom (<c>m_topEdge==1 &amp;&amp;
/// m_bottomEdge==1</c>), retail resizes the popup to the ListBox's
/// scrollable CONTENT extent — the SUM of laid-out row heights, uncapped
/// (<c>0x0046e5f4..0046e66c</c> feeding
/// <c>UIElement_Scrollable::ResizeScrollableArea @0x00474730</c>, whose
/// 0x32 broadcast RecalculatePopupSize answers) — so the popup shrinks
/// AND grows to the item count. Whether the path is active is an
/// authored per-menu fact, not a convention: the Config option-menu
/// popup ListBox (0x21000043/0x10000358) reads edges L=T=R=B=1
/// (menuprobe3, <c>OptionsPanelLiveMountProbeTests</c>), so
/// <see cref="AcDream.App.UI.Layout.ConfigOptionsPageController"/> sets
/// this true; chat's grid popup and vendor's shipped 6-row window keep
/// the class default false (vendor's authored ListBox is ALSO docked —
/// tracked as its own issue, not silently reworked here).
/// When set, <see cref="RowsPerColumn"/> stops being the visible-window
/// height and the popup shows every item with no scroll overflow.
/// </summary>
public bool PopupSizeToContent { get; set; }
/// <summary>
/// Retail draws the button caption through the authored label child named
/// by menu attr 8 (<c>UIElement_Menu::NewSelection @0x0046cd60</c> writes
/// the selected item's text into it) — its justification comes from the
/// LayoutDesc, not menu code. The Config option-menu label child
/// (0x21000043/0x10000355) authors hJustify=Center (menuprobe3);
/// chat's own label child authors Left, the class default.
/// </summary>
public bool ButtonTextCentered { get; set; }
/// <summary>
/// Same authored-justification rule for the popup rows: each row is an
/// authored text template (menu attr 9). The Config option-menu row
/// template (0x21000043/0x1000035A) authors hJustify=Center
/// (menuprobe3); chat's and vendor's row templates author Left.
/// </summary>
public bool ItemTextCentered { get; set; }
public Vector4 TextColor { get; set; } = new(1f, 0.92f, 0.72f, 1f);
/// <summary>Available item text — retail white #FFFFFF (gmMainChatUI talk-focus
/// enabled state). Confirmed via decomp: enabled items render white.</summary>
@ -232,10 +273,28 @@ public sealed class UiMenu : UiElement
private float InteriorW => Scrollable
? ColumnWidth + ScrollbarWidth
: ColumnCount * ColumnWidth;
private float InteriorH => RowsPerColumn * RowHeight;
/// <summary>The popup's visible row count. Size-to-content (retail's
/// RecalculatePopupSize path — see <see cref="PopupSizeToContent"/>):
/// every item, uncapped; otherwise the authored fixed window
/// (<see cref="RowsPerColumn"/>). Max(1,·) keeps a detached/empty test
/// menu's geometry finite — a live empty menu never opens (retail
/// <c>Open @0x0046cc42</c> gates on <c>m_listItems.m_num != 0</c>,
/// ported in <see cref="OnEvent"/>).</summary>
private int EffectiveVisibleRows => Scrollable && PopupSizeToContent
? System.Math.Max(1, Items.Count)
: RowsPerColumn;
private float InteriorH => EffectiveVisibleRows * RowHeight;
private float OuterW => InteriorW + 2 * Border;
private float OuterH => InteriorH + 2 * Border;
/// <summary>The open popup's outer (bevel-inclusive) height — read-only
/// test seam, same rationale as <see cref="PopupScroll"/>/<see cref="IsOpen"/>:
/// the size-to-content geometry has no other assertable surface short of
/// a full render pass.</summary>
public float PopupOuterHeight => OuterH;
/// <summary>
/// G7 (vendor gate finding, item 2 — popup direction): port of retail
/// <c>UIElement_Menu::Open</c> (pc:120210-120252, <c>0x0046cc30</c>)'s Y placement:
@ -282,7 +341,15 @@ public sealed class UiMenu : UiElement
var (tex, tw, _) = resolve(_open ? PressedSprite : NormalSprite);
if (tex != 0 && tw > 0) DrawButtonFace(ctx, tex, tw);
}
DrawLabel(ctx, ButtonLabelProvider?.Invoke() ?? "", ButtonTextIndent, (Height - LineH()) * 0.5f, TextColor);
string caption = ButtonLabelProvider?.Invoke() ?? "";
// Centered captions centre within the label-child band — the authored
// label child spans the button MINUS the arrow-cap overlay's right
// socket (0x10000355 is 100 wide of the 117 button, docked; the
// arrow child overlays the last 17px — menuprobe3).
float capX = ButtonTextCentered
? MathF.Max(0f, (Width - (ArrowCapClosedSprite != 0 ? ArrowCapWidth : 0f) - MeasureText(caption)) * 0.5f)
: ButtonTextIndent;
DrawLabel(ctx, caption, capX, (Height - LineH()) * 0.5f, TextColor);
// G6: the open/closed arrow-cap overlay — see ArrowCapClosedSprite's doc comment.
if (resolve is not null) DrawArrowCap(ctx, resolve);
@ -373,11 +440,21 @@ public sealed class UiMenu : UiElement
int col = i / RowsPerColumn, row = i % RowsPerColumn;
// Items grey out when unavailable; when EnabledProvider is null all items are enabled.
bool avail = EnabledProvider?.Invoke(Items[i].Payload) ?? true;
DrawLabel(ctx, Items[i].Label, inX + col * ColumnWidth + TextIndent, inY + row * RowHeight + textY,
DrawLabel(ctx, Items[i].Label, inX + col * ColumnWidth + ItemTextX(Items[i].Label),
inY + row * RowHeight + textY,
avail ? TextColorAvailable : TextColorGhosted);
}
}
/// <summary>A row label's X offset within its column — the authored row
/// template's own justification (see <see cref="ItemTextCentered"/>).</summary>
private float ItemTextX(string label) => ItemTextCentered
? MathF.Max(0f, (ColumnWidth - MeasureText(label)) * 0.5f)
: TextIndent;
private float MeasureText(string s)
=> DatFont?.MeasureWidth(s) ?? Font?.MeasureWidth(s) ?? s.Length * 7f;
/// <summary>
/// G5: single-column popup with a docked scrollbar — port of the vendor category
/// dropdown's authored shape (LayoutDesc <c>0x21000043</c>, see <see cref="Scrollable"/>'s
@ -397,7 +474,7 @@ public sealed class UiMenu : UiElement
DrawSprite(ctx, resolve, PopupBgSprite, inX, inY, ColumnWidth, InteriorH);
int start = VisibleTopRow;
int count = System.Math.Min(RowsPerColumn, Items.Count - start);
int count = System.Math.Min(EffectiveVisibleRows, Items.Count - start);
float textY = (RowHeight - LineH()) * 0.5f;
for (int i = 0; i < count; i++)
{
@ -411,7 +488,7 @@ public sealed class UiMenu : UiElement
int idx = start + i;
float y = inY + i * RowHeight;
bool avail = EnabledProvider?.Invoke(Items[idx].Payload) ?? true;
DrawLabel(ctx, Items[idx].Label, inX + TextIndent, y + textY,
DrawLabel(ctx, Items[idx].Label, inX + ItemTextX(Items[idx].Label), y + textY,
avail ? TextColorAvailable : TextColorGhosted);
}
@ -425,7 +502,10 @@ public sealed class UiMenu : UiElement
{
int lineHeight = System.Math.Max(1, (int)MathF.Round(RowHeight));
PopupScroll.LineHeight = lineHeight;
PopupScroll.SetExtents(Items.Count * lineHeight, RowsPerColumn * lineHeight);
// Size-to-content: view == content, so HasOverflow is false and the
// scrollbar draws its chrome with no thumb (retail's authored
// scrollbar sibling stretches with the docked popup the same way).
PopupScroll.SetExtents(Items.Count * lineHeight, EffectiveVisibleRows * lineHeight);
}
/// <summary>Index of the first visible row — nearest-row snap of the (possibly
@ -436,7 +516,7 @@ public sealed class UiMenu : UiElement
get
{
int lineHeight = System.Math.Max(1, (int)MathF.Round(RowHeight));
int maxStart = System.Math.Max(0, Items.Count - RowsPerColumn);
int maxStart = System.Math.Max(0, Items.Count - EffectiveVisibleRows);
int row = (int)MathF.Round((float)PopupScroll.ScrollY / lineHeight);
return System.Math.Clamp(row, 0, maxStart);
}
@ -581,6 +661,10 @@ public sealed class UiMenu : UiElement
return true;
}
// Retail Open @0x0046cc42 refuses an empty list (gates on
// m_listBox->m_listItems.m_num != 0) — a bare click on an itemless
// menu is a no-op rather than an empty popup.
if (!_open && Items.Count == 0) return true;
SetOpen(!_open); // toggle on button click
return true;
}
@ -598,7 +682,7 @@ public sealed class UiMenu : UiElement
{
int row = (int)(iy / RowHeight);
int idx = VisibleTopRow + row;
if (row >= 0 && row < RowsPerColumn && idx >= 0 && idx < Items.Count
if (row >= 0 && row < EffectiveVisibleRows && idx >= 0 && idx < Items.Count
&& (EnabledProvider?.Invoke(Items[idx].Payload) ?? true))
{
OnSelect?.Invoke(Items[idx].Payload);