using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.UI;
///
/// Generic dropdown menu. Ports retail UIElement_Menu
/// (RegisterElementClass(6) @ acclient_2013_pseudo_c.txt:120163 ) +
/// UIElement_Menu::MakePopup @0x46d310 : 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 for Type-6 elements.
///
public sealed class UiMenu : UiElement
{
/// One menu row: its label + an opaque payload the controller maps back.
public readonly record struct MenuItem(string Label, object? Payload);
/// 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.
public IReadOnlyList Items { get; set; } = System.Array.Empty();
/// The currently-selected payload (drives the highlighted row).
public object? Selected { get; set; }
/// Fired with the picked item's payload when a row is chosen.
public Action? OnSelect { get; set; }
/// Per-payload enabled gate (disabled rows render greyed + are inert). Null ⇒ all enabled.
public Func? EnabledProvider { get; set; }
/// Button-face caption (the active target). Null ⇒ blank face.
public Func? ButtonLabelProvider { get; set; }
/// Settable tooltip, surfaced through the shared
/// hover pipeline — the SAME
/// pattern 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 _Help string to the closed dropdown button itself, since
/// it — not the sibling label text — is the interactive/hoverable
/// surface for the row.
public string? TooltipText { get; set; }
///
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
///
/// G5 (vendor gate finding): retail's authored vendor category popup
/// (LayoutDesc 0x21000043 , root 0x1000034F ) pairs its
/// ListBox (element 0x10000350 , type 0x5 ) with a SIBLING
/// UIElement_Scrollbar (element 0x10000351 , type 0xB ,
/// 16px wide, docked immediately right of the list at x=100) — verified
/// via a live-dat scan (tools/VendorLayoutScan ) against
/// client_local_English.dat : the ListBox reads a single-column
/// shape (attributes resolving to m_nCols=1 /m_nRows=6 ) and
/// the row template (0x10000352 ) 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). becomes the VISIBLE ROW
/// COUNT in this mode (still authored-driven — 108px ListBox height / 18px
/// row height = 6). Chat's own popup (LayoutDesc 0x21000006 ) has
/// NO sibling scrollbar element and keeps the class default false — the
/// legacy column-major grid path below is untouched for it.
///
public bool Scrollable { get; set; }
///
/// Vertical scroll model for the popup when is
/// set. Content/view/line extents are (re)computed every draw from
/// .Count / /
/// , mirroring how
/// configures its own Scroll before every layout pass. Exposed so
/// a controller/test can assert or drive scroll position directly (the
/// popup owns no separate live CHILD widget —
/// see the scrollbar chrome properties below for why).
///
public UiScrollable PopupScroll { get; } = new();
///
/// Authored width of the popup's docked scrollbar (16px, element
/// 0x10000351 's own Width).
///
public float ScrollbarWidth { get; set; } = 16f;
/// Authored extent of the up/down buttons along the scrollbar's
/// own axis (16px, elements 0x10000071 /0x10000072 's own Height —
/// same convention as ).
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;
///
/// 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: UIElement_Menu::UpdateState
/// (pc:120101-120105, 0x0046cad0 ) writes attribute 0xe (m_open )
/// 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
/// 0x1000034E — a 17x19 image docked at the right edge of the 117-wide
/// button (X=100,Y=0), states Normal=0x060012B1 (closed, verified live-dat
/// via tools/VendorLayoutScan resolved reading its two StateMedia images)
/// and Highlight=0x060012B2 (open) — sibling of the label child
/// 0x1000034D (see 's doc). Chat's own button
/// face BAKES its arrow into the single /
/// 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, skips a 0 id) and chat never sets them.
///
public uint ArrowCapClosedSprite { get; set; }
public uint ArrowCapOpenSprite { get; set; }
/// 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.
public float ArrowCapWidth { get; set; } = 17f;
public float ArrowCapHeight { get; set; } = 19f;
/// The arrow-cap sprite id would currently
/// draw (0 if neither is authored) — a read-only projection of _open 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 is exposed for the same
/// reason).
public uint CurrentArrowCapSprite => _open ? ArrowCapOpenSprite : ArrowCapClosedSprite;
public UiDatFont? DatFont { get; set; }
public AcDream.App.Rendering.BitmapFont? Font { get; set; }
/// Retail LayoutDesc property 0x21 (two-pass glyph outline,
/// UIElement_Text::SetOutline @0x0046a81c ). 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).
public bool Outline { get; set; }
/// Retail LayoutDesc property 0x22 (m_curOutlineColor ,
/// ctor default black). Only meaningful when is true.
public Vector4 OutlineColor { get; set; } = UiRenderContext.DefaultOutlineColor;
public Func? 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
///
/// Port of retail UIElement_Menu::RecalculatePopupSize @0x0046caf0
/// (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 (m_topEdge==1 &&
/// m_bottomEdge==1 ), retail resizes the popup to the ListBox's
/// scrollable CONTENT extent — the SUM of laid-out row heights, uncapped
/// (0x0046e5f4..0046e66c feeding
/// UIElement_Scrollable::ResizeScrollableArea @0x00474730 , 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, OptionsPanelLiveMountProbeTests ), so
/// 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, stops being the visible-window
/// height and the popup shows every item with no scroll overflow.
///
public bool PopupSizeToContent { get; set; }
///
/// Retail draws the button caption through the authored label child named
/// by menu attr 8 (UIElement_Menu::NewSelection @0x0046cd60 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.
///
public bool ButtonTextCentered { get; set; }
///
/// 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.
///
public bool ItemTextCentered { get; set; }
public Vector4 TextColor { get; set; } = new(1f, 0.92f, 0.72f, 1f);
/// Available item text — retail white #FFFFFF (gmMainChatUI talk-focus
/// enabled state). Confirmed via decomp: enabled items render white.
public Vector4 TextColorAvailable { get; set; } = new(1f, 1f, 1f, 1f);
/// 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.
public Vector4 TextColorGhosted { get; set; } = new(0.5f, 0.5f, 0.5f, 1f);
private bool _open;
/// Whether the popup is currently open (test/inspection seam,
/// same rationale as / ).
public bool IsOpen => _open;
/// The ONLY writer of : 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.
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;
/// The popup's visible row count. Size-to-content (retail's
/// RecalculatePopupSize path — see ):
/// every item, uncapped; otherwise the authored fixed window
/// ( ). Max(1,·) keeps a detached/empty test
/// menu's geometry finite — a live empty menu never opens (retail
/// Open @0x0046cc42 gates on m_listItems.m_num != 0 ,
/// ported in ).
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;
/// The open popup's outer (bevel-inclusive) height — read-only
/// test seam, same rationale as / :
/// the size-to-content geometry has no other assertable surface short of
/// a full render pass.
public float PopupOuterHeight => OuterH;
///
/// G7 (vendor gate finding, item 2 — popup direction): port of retail
/// UIElement_Menu::Open (pc:120210-120252, 0x0046cc30 )'s Y placement:
/// edi_3 = attr5 ? ScreenY0(button) - popupHeight // ABOVE
/// : ScreenY1(button) // BELOW (button's own bottom edge)
/// where attr5 is UIElement::GetAttribute_Bool(this, 5, ...) — a PER-MENU
/// authored bool property, not a global convention. GetAttribute_Bool
/// (pc:106749-106778, 0x00460be0 ) defaults an ABSENT property to false
/// (InqProperty fails -> *arg3 = 0 ). Verified against both fixtures:
/// chat's channel menu (LayoutDesc 0x21000006 , element 0x10000014 )
/// authors property "5" = BoolValue: true (opens UP), while vendor's category
/// dropdown (LayoutDesc 0x21000012 , element 0x100000BF ) 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 Open — the
/// direction is a fixed per-menu authored choice, not a runtime decision, so this
/// port does not add one either (see 's own note on why no
/// clamp was added). Default true preserves chat's exact upward geometry
/// byte-for-byte (the class's only behavior before this property existed);
/// sets this false to
/// match its own authored (absent) attribute.
///
public bool OpenUpward { get; set; } = true;
/// 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 = - . Downward: the
/// popup's TOP touches the button's BOTTOM (y=Height) — retail's ScreenY1 .
/// Shared by drawing, hit-testing, and event math so all three agree.
private float PopupTop => OpenUpward ? -OuterH : Height;
public UiMenu() { CapturesPointerDrag = true; }
/// 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).
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
}
/// G6: the closed/open arrow-cap overlay (see '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).
private void DrawArrowCap(UiRenderContext ctx, Func 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);
}
/// 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.
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
}
/// 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.
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(); }
}
/// Legacy column-major popup (chat's own shape — no authored sibling
/// scrollbar element; see 's doc comment). Unchanged from
/// before G5.
private void DrawGridPopup(UiRenderContext ctx, Func 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);
}
}
/// A row label's X offset within its column — the authored row
/// template's own justification (see ).
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;
///
/// G5: single-column popup with a docked scrollbar — port of the vendor category
/// dropdown's authored shape (LayoutDesc 0x21000043 , see 's
/// doc comment). Draws exactly rows (the authored visible
/// window), sliced from starting at , plus
/// the scrollbar chrome using the SAME thumb geometry itself
/// uses ( ).
///
private void DrawScrollablePopup(UiRenderContext ctx, Func 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);
}
/// Recomputes 's extents from the current item
/// count/geometry — mirrors 's own "configure the
/// shared scroll model right before using it" pattern.
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);
}
/// 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.
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 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);
}
}
/// Draw the universal 8-piece retail window bevel (corners + tiled edges +
/// tiled centre fill) framing the rect ( , ,
/// , ). Reuses the same geometry +
/// ids as ; no resize
/// grips (a menu popup is not resizable).
private void DrawBevel(UiRenderContext ctx, Func 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 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;
}
///
/// 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 's own MouseDown shape).
///
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;
}
/// Continues an in-progress thumb drag ( );
/// mirrors 's own MouseMove when _draggingThumb
/// case, reusing for the exact same thumb height.
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);
}
}