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); } }