acdream/src/AcDream.App/UI/UiMenu.cs
Erik a9b6435f55 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>
2026-08-13 08:51:57 +02:00

740 lines
39 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>
/// Generic dropdown menu. Ports retail <c>UIElement_Menu</c>
/// (<c>RegisterElementClass(6) @ acclient_2013_pseudo_c.txt:120163</c>) +
/// <c>UIElement_Menu::MakePopup @0x46d310</c>: the button is labelled with
/// the active target; clicking opens a column-major popup on the dat-driven menu
/// chrome (panel + per-row + selected-row sprites). Items and all chat-channel
/// knowledge are populated by the controller, not baked into this widget. Built
/// by <see cref="AcDream.App.UI.Layout.DatWidgetFactory"/> for Type-6 elements.
/// </summary>
public sealed class UiMenu : UiElement
{
/// <summary>One menu row: its label + an opaque payload the controller maps back.</summary>
public readonly record struct MenuItem(string Label, object? Payload);
/// <summary>The rows, populated by the controller. Laid out column-major:
/// rows 0..RowsPerColumn-1 in column 0, then the next group in column 1, etc.</summary>
public IReadOnlyList<MenuItem> Items { get; set; } = System.Array.Empty<MenuItem>();
/// <summary>The currently-selected payload (drives the highlighted row).</summary>
public object? Selected { get; set; }
/// <summary>Fired with the picked item's payload when a row is chosen.</summary>
public Action<object?>? OnSelect { get; set; }
/// <summary>Per-payload enabled gate (disabled rows render greyed + are inert). Null ⇒ all enabled.</summary>
public Func<object?, bool>? EnabledProvider { get; set; }
/// <summary>Button-face caption (the active target). Null ⇒ blank face.</summary>
public Func<string>? ButtonLabelProvider { get; set; }
/// <summary>Settable tooltip, surfaced through the shared
/// <see cref="UiElement.GetTooltipText"/> hover pipeline — the SAME
/// pattern <see cref="UiButton.TooltipText"/> already established
/// (OP6 rework, review S3). Lets a menu-row controller (e.g. the
/// Config tab's Sound Features / texture-detail menus) attach retail's
/// own <c>_Help</c> string to the closed dropdown button itself, since
/// it — not the sibling label text — is the interactive/hoverable
/// surface for the row.</summary>
public string? TooltipText { get; set; }
/// <inheritdoc />
public override string? GetTooltipText() =>
string.IsNullOrWhiteSpace(TooltipText) ? null : TooltipText;
public int RowsPerColumn { get; set; } = 7; // items per column (dat item template);
// ALSO the visible-row window height when Scrollable
public float RowHeight { get; set; } = 17f; // dat item template 0x1000001E H=17
public float ColumnWidth { get; set; } = 191f; // dat item template W=191
/// <summary>
/// G5 (vendor gate finding): retail's authored vendor category popup
/// (LayoutDesc <c>0x21000043</c>, root <c>0x1000034F</c>) pairs its
/// ListBox (element <c>0x10000350</c>, type <c>0x5</c>) with a SIBLING
/// <c>UIElement_Scrollbar</c> (element <c>0x10000351</c>, type <c>0xB</c>,
/// 16px wide, docked immediately right of the list at x=100) — verified
/// via a live-dat scan (<c>tools/VendorLayoutScan</c>) against
/// <c>client_local_English.dat</c>: the ListBox reads a single-column
/// shape (attributes resolving to <c>m_nCols=1</c>/<c>m_nRows=6</c>) and
/// the row template (<c>0x10000352</c>) is 100×18 — a SCROLLABLE single
/// column with 6 visible rows, not our earlier column-major grid
/// approximation (which showed all 18 categories at once across 3
/// columns, never matching the retail screenshot's ~one-column-with-
/// scrollbar look). <see cref="RowsPerColumn"/> becomes the VISIBLE ROW
/// COUNT in this mode (still authored-driven — 108px ListBox height / 18px
/// row height = 6). Chat's own popup (LayoutDesc <c>0x21000006</c>) has
/// NO sibling scrollbar element and keeps the class default false — the
/// legacy column-major grid path below is untouched for it.
/// </summary>
public bool Scrollable { get; set; }
/// <summary>
/// Vertical scroll model for the popup when <see cref="Scrollable"/> is
/// set. Content/view/line extents are (re)computed every draw from
/// <see cref="Items"/>.Count / <see cref="RowsPerColumn"/> /
/// <see cref="RowHeight"/>, mirroring how <see cref="UiItemList"/>
/// configures its own <c>Scroll</c> before every layout pass. Exposed so
/// a controller/test can assert or drive scroll position directly (the
/// popup owns no separate live <see cref="UiScrollbar"/> CHILD widget —
/// see the scrollbar chrome properties below for why).
/// </summary>
public UiScrollable PopupScroll { get; } = new();
/// <summary>
/// Authored width of the popup's docked scrollbar (16px, element
/// <c>0x10000351</c>'s own Width).
/// </summary>
public float ScrollbarWidth { get; set; } = 16f;
/// <summary>Authored extent of the up/down buttons along the scrollbar's
/// own axis (16px, elements <c>0x10000071</c>/<c>0x10000072</c>'s own Height —
/// same convention as <see cref="UiScrollbar.DecrementButtonExtent"/>).</summary>
public float ScrollButtonExtent { get; set; } = 16f;
// Scrollbar chrome sprites. UiMenu draws these itself (rather than hosting a
// live UiScrollbar child) because the popup renders in the OVERLAY pass (see
// OnDrawOverlay's doc comment) — a normal child widget would draw in the
// regular main pass and suffer the exact translucent-sibling artifact that
// pass exists to avoid. The geometry math is shared with UiScrollbar via its
// public static ThumbRect helper, so both draw identical thumbs.
public uint ScrollTrackSprite { get; set; }
public uint ScrollThumbSprite { get; set; }
public uint ScrollThumbTopSprite { get; set; }
public uint ScrollThumbBottomSprite { get; set; }
public uint ScrollUpSprite { get; set; }
public uint ScrollDownSprite { get; set; }
private bool _draggingPopupThumb;
private float _popupThumbDragOffset;
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
// the box instead of overlapping it. Settable (not const) because this is a CHAT-
// specific authored offset: vendor's category dropdown reuses a plain row-highlight
// sprite with no baked checkbox (LayoutDesc 0x21000043 row template 0x10000352 has
// no child glyph — verified via a live-dat scan), so VendorUiController overrides
// this to 0 — retail's own left-justified UiText convention for an icon-less label
// (every other dat-driven UiText in this codebase sets Padding=0f; see
// DatWidgetFactory.BuildText). Leaving chat's TextIndent baked in here would overflow
// vendor's 100px-wide row for its longest label ("Spell Components" measures 92px at
// the default retail font — 19+92=111 > 100, an 11px overflow; with 0, 92 < 100 fits).
public float TextIndent { get; set; } = 19f;
// The button face sprite (0x06004D65/66) bakes a status LED (red→green) into its
// left socket (~x420 of the 46px button); the caption starts past it so it doesn't
// render over the LED. Settable for the same reason as TextIndent: vendor's button
// face substitutes a row sprite with no LED, and its authored label child
// (0x1000034D) is itself HJustify=Left at X=0 — VendorUiController overrides this
// to 0 to match.
public float ButtonTextIndent { get; set; } = 20f;
/// <summary>
/// G6 (vendor gate finding, item 1 — missing arrow indicator): a SEPARATE small
/// image piece some menus author to the right of the button face, whose visible
/// state flips between closed and open. Retail: <c>UIElement_Menu::UpdateState</c>
/// (pc:120101-120105, <c>0x0046cad0</c>) writes attribute <c>0xe</c> (<c>m_open</c>)
/// on every open/close, which drives the arrow-cap
/// child's own StateDesc selection between its "Normal" (closed) and "Highlight"
/// (open) states. Vendor's category dropdown authors this as element
/// <c>0x1000034E</c> — a 17x19 image docked at the right edge of the 117-wide
/// button (X=100,Y=0), states Normal=<c>0x060012B1</c> (closed, verified live-dat
/// via <c>tools/VendorLayoutScan resolved</c> reading its two StateMedia images)
/// and Highlight=<c>0x060012B2</c> (open) — sibling of the label child
/// <c>0x1000034D</c> (see <see cref="ButtonTextIndent"/>'s doc). Chat's own button
/// face BAKES its arrow into the single <see cref="NormalSprite"/>/
/// <see cref="PressedSprite"/> texture already (the right cap of the 46px
/// 0x06004D65/66 LED-arrow art), so it needs no separate overlay — these default to
/// 0 (no-op, <see cref="DrawArrowCap"/> skips a 0 id) and chat never sets them.
/// </summary>
public uint ArrowCapClosedSprite { get; set; }
public uint ArrowCapOpenSprite { get; set; }
/// <summary>Authored native size of the arrow-cap overlay (17x19 for vendor's
/// dropdown) — drawn unstretched, right-anchored to the button's own width, exactly
/// mirroring the authored element's own X=Width-17,Y=0 placement.</summary>
public float ArrowCapWidth { get; set; } = 17f;
public float ArrowCapHeight { get; set; } = 19f;
/// <summary>The arrow-cap sprite id <see cref="DrawArrowCap"/> would currently
/// draw (0 if neither is authored) — a read-only projection of <c>_open</c> onto
/// the two configured sprites, exposed so a controller/test can assert the
/// closed/open flip without a full render pass (the class has no OnDraw test seam
/// otherwise, matching how <see cref="PopupScroll"/> is exposed for the same
/// reason).</summary>
public uint CurrentArrowCapSprite => _open ? ArrowCapOpenSprite : ArrowCapClosedSprite;
public UiDatFont? DatFont { get; set; }
public AcDream.App.Rendering.BitmapFont? Font { get; set; }
/// <summary>Retail LayoutDesc property <c>0x21</c> (two-pass glyph outline,
/// <c>UIElement_Text::SetOutline @0x0046a81c</c>). No authored menu element
/// carries it today; settable for parity with the other text-bearing widgets
/// (round-5 review S2 — per-STATE switching is AP-192).</summary>
public bool Outline { get; set; }
/// <summary>Retail LayoutDesc property <c>0x22</c> (<c>m_curOutlineColor</c>,
/// ctor default black). Only meaningful when <see cref="Outline"/> is true.</summary>
public Vector4 OutlineColor { get; set; } = UiRenderContext.DefaultOutlineColor;
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
// Button face sprites (dat menu element 0x10000014).
public uint NormalSprite { get; set; }
public uint PressedSprite { get; set; }
// Popup chrome sprites (dat menu popup template, layout 0x21000006).
public uint PopupBgSprite { get; set; } // 0x0600124C — panel fill (191×2 tiles)
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>
public Vector4 TextColorAvailable { get; set; } = new(1f, 1f, 1f, 1f);
/// <summary>Disabled/unavailable item text — retail GREYS these (UIElement state 0xd
/// disabled StateDesc colour). NOT the salmon colorPink (0x81c528) we had before — that
/// belongs to the chat-MESSAGE palette and was misapplied. Exact float lives in the dat
/// StateDesc (not a code symbol); ~0.5 neutral grey here pending a live cdb dump.</summary>
public Vector4 TextColorGhosted { get; set; } = new(0.5f, 0.5f, 0.5f, 1f);
private bool _open;
/// <summary>Whether the popup is currently open (test/inspection seam,
/// same rationale as <see cref="PopupScroll"/>/<see cref="CurrentArrowCapSprite"/>).</summary>
public bool IsOpen => _open;
/// <summary>The ONLY writer of <see cref="_open"/>: keeps the root's
/// transient-popup registration (#374 — an open popup gets first claim
/// on pointer routing, because the sibling z-order walk would otherwise
/// hand popup-area clicks to whatever front sibling overlaps it) in
/// lockstep with the widget's own state. A detached menu (no root yet)
/// still toggles locally — registration happens against the root that
/// dispatches the events, which by construction exists whenever a real
/// pointer event reaches this widget.</summary>
private void SetOpen(bool value)
{
if (_open == value) return;
_open = value;
if (FindRoot() is not { } root) return;
if (value) root.SetActivePopup(this, () => SetOpen(false));
else root.ClearActivePopup(this);
}
// Interior = the row content; Outer = interior + the 8-piece bevel ring.
// Scrollable: always exactly one column (RowsPerColumn is the VISIBLE window,
// not a wrap threshold), widened by the docked scrollbar's own authored width.
private int ColumnCount => Scrollable
? 1
: (Items.Count + RowsPerColumn - 1) / System.Math.Max(1, RowsPerColumn);
private float InteriorW => Scrollable
? ColumnWidth + ScrollbarWidth
: ColumnCount * ColumnWidth;
/// <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:
/// <c>edi_3 = attr5 ? ScreenY0(button) - popupHeight // ABOVE
/// : ScreenY1(button)</c> // BELOW (button's own bottom edge)
/// where <c>attr5</c> is <c>UIElement::GetAttribute_Bool(this, 5, ...)</c> — a PER-MENU
/// authored bool property, not a global convention. <c>GetAttribute_Bool</c>
/// (pc:106749-106778, <c>0x00460be0</c>) defaults an ABSENT property to <c>false</c>
/// (<c>InqProperty</c> fails -&gt; <c>*arg3 = 0</c>). Verified against both fixtures:
/// chat's channel menu (LayoutDesc <c>0x21000006</c>, element <c>0x10000014</c>)
/// authors property "5" = <c>BoolValue: true</c> (opens UP), while vendor's category
/// dropdown (LayoutDesc <c>0x21000012</c>, element <c>0x100000BF</c>) has NO property
/// "5" at all in its resolved attribute bag (opens DOWN, the absent-defaults-false
/// case). There is no dynamic screen-edge flip/clamp anywhere in <c>Open</c> — the
/// direction is a fixed per-menu authored choice, not a runtime decision, so this
/// port does not add one either (see <see cref="OpenUpward"/>'s own note on why no
/// clamp was added). Default <c>true</c> preserves chat's exact upward geometry
/// byte-for-byte (the class's only behavior before this property existed);
/// <see cref="AcDream.App.UI.Layout.VendorUiController"/> sets this <c>false</c> to
/// match its own authored (absent) attribute.
/// </summary>
public bool OpenUpward { get; set; } = true;
/// <summary>Local-space Y of the popup's own top-left corner, relative to the
/// button's local origin (button occupies y=0..Height). Upward: the popup's BOTTOM
/// touches the button's TOP (y=0), so top = -<see cref="OuterH"/>. Downward: the
/// popup's TOP touches the button's BOTTOM (y=Height) — retail's <c>ScreenY1</c>.
/// Shared by drawing, hit-testing, and event math so all three agree.</summary>
private float PopupTop => OpenUpward ? -OuterH : Height;
public UiMenu() { CapturesPointerDrag = true; }
/// <summary>The menu draws its own button face + popup; its dat label/row children
/// must NOT be built (an invisible label child would intercept the button click).</summary>
public override bool ConsumesDatChildren => true;
protected override void OnDraw(UiRenderContext ctx)
{
var resolve = SpriteResolve;
// Button face (3-sliced so it can widen to fit the label) + the active-target label.
if (resolve is not null)
{
var (tex, tw, _) = resolve(_open ? PressedSprite : NormalSprite);
if (tex != 0 && tw > 0) DrawButtonFace(ctx, tex, tw);
}
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);
}
// 3-slice caps for the 46px LED-arrow button face (0x06004D65): a LEFT cap holding the
// round LED socket, a stretchable plain-gold MIDDLE, and a RIGHT cap holding the arrow
// point. Slicing keeps the LED + arrow undistorted when the button widens to its label.
private const float FaceCapL = 20f, FaceCapR = 12f;
private void DrawButtonFace(UiRenderContext ctx, uint tex, float tw)
{
float uL = FaceCapL / tw, uR = (tw - FaceCapR) / tw;
float midDest = Width - FaceCapL - FaceCapR;
ctx.DrawSprite(tex, 0f, 0f, FaceCapL, Height, 0f, 0f, uL, 1f, Vector4.One); // LED cap
if (midDest > 0f)
ctx.DrawSprite(tex, FaceCapL, 0f, midDest, Height, uL, 0f, uR, 1f, Vector4.One); // gold body (stretched)
ctx.DrawSprite(tex, Width - FaceCapR, 0f, FaceCapR, Height, uR, 0f, 1f, 1f, Vector4.One); // arrow cap
}
/// <summary>G6: the closed/open arrow-cap overlay (see <see cref="ArrowCapClosedSprite"/>'s
/// doc comment) — right-anchored, drawn at native size, unstretched (retail's own image
/// element draw, no 3-slice). No-op when neither sprite id is authored (chat's case).</summary>
private void DrawArrowCap(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve)
{
uint id = _open ? ArrowCapOpenSprite : ArrowCapClosedSprite;
if (id == 0) return;
var (tex, tw, th) = resolve(id);
if (tex == 0 || tw == 0 || th == 0) return;
float dx = Width - ArrowCapWidth;
ctx.DrawSprite(tex, dx, 0f, ArrowCapWidth, ArrowCapHeight, 0f, 0f, 1f, 1f, Vector4.One);
}
/// <summary>The button width that fits "LED cap + channel label + arrow cap" — retail
/// sizes the talk-focus button to its selected label. The controller widens the button
/// to this and reflows the input field to start after it.</summary>
public float NaturalButtonWidth()
{
string text = ButtonLabelProvider?.Invoke() ?? "";
float textW = DatFont?.MeasureWidth(text) ?? Font?.MeasureWidth(text) ?? text.Length * 7f;
return ButtonTextIndent + textW + 4f + FaceCapR; // text start (clears LED) + text + gap + arrow cap
}
/// <summary>The open popup draws in the OVERLAY pass so it sits on top of the whole
/// UI — otherwise the translucent chat panel (drawn after this element in the main
/// pass) greys out the part of the popup that overlaps it.</summary>
protected override void OnDrawOverlay(UiRenderContext ctx)
{
var resolve = SpriteResolve;
if (!_open || 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
// in submission order so labels land on top.
ctx.PushAlphaAbsolute(1f);
try
{
if (Scrollable)
DrawScrollablePopup(ctx, resolve);
else
DrawGridPopup(ctx, resolve);
}
finally { ctx.PopAlpha(); }
}
/// <summary>Legacy column-major popup (chat's own shape — no authored sibling
/// scrollbar element; see <see cref="Scrollable"/>'s doc comment). Unchanged from
/// before G5.</summary>
private void DrawGridPopup(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve)
{
float outerTop = PopupTop; // G7: direction-aware (see PopupTop's doc)
float inX = Border, inY = outerTop + Border; // interior origin (inside the bevel)
DrawBevel(ctx, resolve, 0f, outerTop, OuterW, OuterH);
DrawSprite(ctx, resolve, PopupBgSprite, inX, inY, InteriorW, InteriorH); // panel fill behind rows
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);
DrawSprite(ctx, resolve, selected ? ItemHighlightSprite : ItemNormalSprite, x, y, ColumnWidth, RowHeight);
}
float textY = (RowHeight - LineH()) * 0.5f; // center the label in its row
for (int i = 0; i < Items.Count; i++)
{
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 + 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
/// doc comment). Draws exactly <see cref="RowsPerColumn"/> rows (the authored visible
/// window), sliced from <see cref="Items"/> starting at <see cref="VisibleTopRow"/>, plus
/// the scrollbar chrome using the SAME thumb geometry <see cref="UiScrollbar"/> itself
/// uses (<see cref="UiScrollbar.ThumbRect"/>).
/// </summary>
private void DrawScrollablePopup(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve)
{
ConfigurePopupScroll();
float outerTop = PopupTop; // G7: direction-aware (see PopupTop's doc)
float inX = Border, inY = outerTop + Border;
DrawBevel(ctx, resolve, 0f, outerTop, OuterW, OuterH);
DrawSprite(ctx, resolve, PopupBgSprite, inX, inY, ColumnWidth, InteriorH);
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);
DrawSprite(ctx, resolve, selected ? ItemHighlightSprite : ItemNormalSprite, inX, y, ColumnWidth, RowHeight);
}
for (int i = 0; i < count; i++)
{
int idx = start + i;
float y = inY + i * RowHeight;
bool avail = EnabledProvider?.Invoke(Items[idx].Payload) ?? true;
DrawLabel(ctx, Items[idx].Label, inX + ItemTextX(Items[idx].Label), y + textY,
avail ? TextColorAvailable : TextColorGhosted);
}
DrawPopupScrollbar(ctx, resolve, inX + ColumnWidth, inY);
}
/// <summary>Recomputes <see cref="PopupScroll"/>'s extents from the current item
/// count/geometry — mirrors <see cref="UiItemList.LayoutCells"/>'s own "configure the
/// shared scroll model right before using it" pattern.</summary>
private void ConfigurePopupScroll()
{
int lineHeight = System.Math.Max(1, (int)MathF.Round(RowHeight));
PopupScroll.LineHeight = 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
/// mid-drag, pixel-continuous) scroll offset, so drawn rows never render partially
/// clipped.</summary>
private int VisibleTopRow
{
get
{
int lineHeight = System.Math.Max(1, (int)MathF.Round(RowHeight));
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);
}
}
private void DrawPopupScrollbar(
UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve, float x, float y)
{
DrawSprite(ctx, resolve, ScrollTrackSprite, x, y, ScrollbarWidth, InteriorH);
float decExtent = System.Math.Clamp(ScrollButtonExtent, 0f, InteriorH);
float incExtent = System.Math.Clamp(ScrollButtonExtent, 0f, InteriorH - decExtent);
DrawSprite(ctx, resolve, ScrollUpSprite, x, y, ScrollbarWidth, decExtent);
DrawSprite(ctx, resolve, ScrollDownSprite, x, y + InteriorH - incExtent, ScrollbarWidth, incExtent);
if (!PopupScroll.HasOverflow) return;
float trackTop = decExtent;
float trackLen = MathF.Max(0f, InteriorH - decExtent - incExtent);
var (ty, th) = UiScrollbar.ThumbRect(PopupScroll, trackTop, trackLen);
const float capH = 3f;
if (ScrollThumbTopSprite != 0 && ScrollThumbBottomSprite != 0 && th >= 2f * capH)
{
DrawSprite(ctx, resolve, ScrollThumbTopSprite, x, y + ty, ScrollbarWidth, capH);
DrawSprite(ctx, resolve, ScrollThumbSprite, x, y + ty + capH, ScrollbarWidth, th - 2f * capH);
DrawSprite(ctx, resolve, ScrollThumbBottomSprite, x, y + ty + th - capH, ScrollbarWidth, capH);
}
else
{
DrawSprite(ctx, resolve, ScrollThumbSprite, x, y + ty, ScrollbarWidth, th);
}
}
/// <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 +
/// <see cref="RetailChromeSprites"/> ids as <see cref="UiNineSlicePanel"/>; no resize
/// grips (a menu popup is not resizable).</summary>
private void DrawBevel(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
float x, float y, float w, float h)
{
var r = UiNineSlicePanel.ComputeFrameRects(w, h, Border);
void P(uint id, in UiNineSlicePanel.Rect d) => DrawSprite(ctx, resolve, id, x + d.X, y + d.Y, d.W, d.H);
P(RetailChromeSprites.CenterFill, r.Center);
P(RetailChromeSprites.TopEdge, r.Top);
P(RetailChromeSprites.BottomEdge, r.Bottom);
P(RetailChromeSprites.LeftEdge, r.Left);
P(RetailChromeSprites.RightEdge, r.Right);
P(RetailChromeSprites.CornerTL, r.TL);
P(RetailChromeSprites.CornerTR, r.TR);
P(RetailChromeSprites.CornerBL, r.BL);
P(RetailChromeSprites.CornerBR, r.BR);
}
private float LineH() => DatFont?.LineHeight ?? Font?.LineHeight ?? 14f;
private void DrawSprite(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
uint id, float x, float y, float w, float h)
{
if (id == 0) return;
var (tex, tw, th) = resolve(id);
if (tex == 0 || tw == 0 || th == 0) return;
// Tile at native size (the panel fill is 191×2; rows are 191×17 = 1:1).
ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, w / tw, h / th, Vector4.One);
}
private void DrawLabel(UiRenderContext ctx, string s, float x, float y, Vector4 color)
{
if (DatFont is { } df) ctx.DrawStringDat(df, s, x, y, color, Outline, OutlineColor);
else ctx.DrawString(s, x, y, color, Font);
}
protected override bool OnHitTest(float lx, float ly)
{
if (!_open) return base.OnHitTest(lx, ly);
if (lx < 0 || lx >= OuterW) return false;
// G7: the union of the button itself + the popup, whichever side it opens on.
return OpenUpward ? (ly >= -OuterH && ly < Height) : (ly >= 0 && ly < Height + OuterH);
}
public override bool OnEvent(in UiEvent e)
{
// G5: scrollbar drag/wheel handling for the scrollable popup. Checked BEFORE
// the MouseDown-only early return below since these span MouseMove/Scroll too.
if (Scrollable && _open)
{
if (e.Type == UiEventType.MouseMove && _draggingPopupThumb)
{
DragPopupThumb(e.Data2);
return true;
}
if (e.Type == UiEventType.MouseUp && _draggingPopupThumb)
{
// Ending a thumb drag must not also close the popup — UiRoot fires a
// trailing Click on the same target after MouseUp, which this class
// does not handle (falls through as a no-op), so the popup stays open.
_draggingPopupThumb = false;
return true;
}
if (e.Type == UiEventType.Scroll)
{
ConfigurePopupScroll();
PopupScroll.ScrollByLines(-e.Data0);
return true;
}
}
if (e.Type != UiEventType.MouseDown) return false;
float lx = e.Data1, ly = e.Data2;
// G7: direction-aware — the popup occupies ly<0 when it opens upward (chat),
// or ly>=Height (past the button's own bottom edge) when it opens downward
// (vendor). See PopupTop's doc comment.
bool clickedInPopup = OpenUpward ? ly < 0 : ly >= Height;
if (_open && clickedInPopup)
{
// Map into the bevel interior, then to (col,row). Clicks in the bevel ring
// (outside the interior) just close the menu.
float ix = lx - Border, iy = ly - (PopupTop + Border);
if (Scrollable)
return HandleScrollablePopupMouseDown(ix, iy);
if (ix >= 0 && ix < InteriorW && iy >= 0 && iy < InteriorH)
{
int col = (int)(ix / ColumnWidth);
int row = (int)(iy / RowHeight);
int idx = col * RowsPerColumn + row;
// Only pick enabled items.
if (row >= 0 && row < RowsPerColumn && idx >= 0 && idx < Items.Count
&& (EnabledProvider?.Invoke(Items[idx].Payload) ?? true))
{
// The widget REPORTS the pick; the controller owns Selected (it sets
// Selected only for payloads it acts on). This mirrors retail
// UIElement_Menu::NewSelection delegating to the owner rather than
// self-selecting — so a deferred/no-op item (e.g. the Squelch /
// Tell-to-Selected specials, null payload) leaves the current
// selection + highlight unchanged when the controller ignores it.
OnSelect?.Invoke(Items[idx].Payload);
}
}
SetOpen(false);
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;
}
/// <summary>
/// G5: mouse-down dispatch for the scrollable popup — a click on the item
/// column picks a row (offset by the current scroll position, closing the
/// popup exactly like the grid path); a click on the scrollbar's up/down
/// buttons, track, or thumb drives scrolling and does NOT close the popup
/// (mirrors <see cref="UiScrollbar.OnEvent"/>'s own MouseDown shape).
/// </summary>
private bool HandleScrollablePopupMouseDown(float ix, float iy)
{
if (ix >= 0 && ix < ColumnWidth && iy >= 0 && iy < InteriorH)
{
int row = (int)(iy / RowHeight);
int idx = VisibleTopRow + row;
if (row >= 0 && row < EffectiveVisibleRows && idx >= 0 && idx < Items.Count
&& (EnabledProvider?.Invoke(Items[idx].Payload) ?? true))
{
OnSelect?.Invoke(Items[idx].Payload);
}
SetOpen(false);
return true;
}
float scrollbarX = ColumnWidth;
if (ix >= scrollbarX && ix < scrollbarX + ScrollbarWidth && iy >= 0 && iy < InteriorH)
{
ConfigurePopupScroll();
float decExtent = System.Math.Clamp(ScrollButtonExtent, 0f, InteriorH);
float incExtent = System.Math.Clamp(ScrollButtonExtent, 0f, InteriorH - decExtent);
if (iy < decExtent) { PopupScroll.ScrollByLines(-1); return true; }
if (iy >= InteriorH - incExtent) { PopupScroll.ScrollByLines(1); return true; }
float trackTop = decExtent;
float trackLen = MathF.Max(0f, InteriorH - decExtent - incExtent);
var (ty, th) = UiScrollbar.ThumbRect(PopupScroll, trackTop, trackLen);
if (iy >= ty && iy <= ty + th)
{
_draggingPopupThumb = true;
_popupThumbDragOffset = iy - ty;
}
else
{
PopupScroll.ScrollByPage(iy < ty ? -1 : 1);
}
return true; // scrollbar interaction never closes the popup
}
// Clicked the bevel ring — close, matching the grid path.
SetOpen(false);
return true;
}
/// <summary>Continues an in-progress thumb drag (<see cref="_draggingPopupThumb"/>);
/// mirrors <see cref="UiScrollbar.OnEvent"/>'s own <c>MouseMove when _draggingThumb</c>
/// case, reusing <see cref="UiScrollbar.ThumbRect"/> for the exact same thumb height.</summary>
private void DragPopupThumb(float ly)
{
float iy = ly - (PopupTop + Border); // G7: direction-aware
ConfigurePopupScroll();
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 (_, thumbH) = UiScrollbar.ThumbRect(PopupScroll, trackTop, trackLen);
float travel = MathF.Max(1f, trackLen - thumbH);
float ratio = (iy - _popupThumbDragOffset - trackTop) / travel;
PopupScroll.SetPositionRatio(ratio);
}
}