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>
740 lines
39 KiB
C#
740 lines
39 KiB
C#
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 (~x4–20 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 &&
|
||
/// 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 -> <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);
|
||
}
|
||
}
|