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; } public int RowsPerColumn { get; set; } = 7; // items per column (dat item template) public float RowHeight { get; set; } = 17f; // dat item template 0x1000001E H=17 public float ColumnWidth { get; set; } = 191f; // dat item template W=191 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. private const float TextIndent = 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. private const float ButtonTextIndent = 20f; public UiDatFont? DatFont { get; set; } public AcDream.App.Rendering.BitmapFont? Font { get; set; } 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 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; // Interior = the row content; Outer = interior + the 8-piece bevel ring. private int ColumnCount => (Items.Count + RowsPerColumn - 1) / System.Math.Max(1, RowsPerColumn); private float InteriorW => ColumnCount * ColumnWidth; private float InteriorH => RowsPerColumn * RowHeight; private float OuterW => InteriorW + 2 * Border; private float OuterH => InteriorH + 2 * Border; 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); } DrawLabel(ctx, ButtonLabelProvider?.Invoke() ?? "", ButtonTextIndent, (Height - LineH()) * 0.5f, TextColor); } // 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 } /// 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; // Column-major popup opening UPWARD from the button, wrapped in the universal // 8-piece window bevel (retail UIElement_Menu::MakePopup spawns the popup as a // bevelled floating window). 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 { float outerTop = -OuterH; // popup bottom sits at the button top (y=0) 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 + TextIndent, inY + row * RowHeight + textY, avail ? TextColorAvailable : TextColorGhosted); } } finally { ctx.PopAlpha(); } } /// 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); else ctx.DrawString(s, x, y, color, Font); } protected override bool OnHitTest(float lx, float ly) => _open ? (lx >= 0 && lx < OuterW && ly >= -OuterH && ly < Height) : base.OnHitTest(lx, ly); public override bool OnEvent(in UiEvent e) { if (e.Type != UiEventType.MouseDown) return false; float lx = e.Data1, ly = e.Data2; if (_open && ly < 0) // clicked inside the upward popup { // 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 - (-OuterH + Border); 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); } } _open = false; return true; } _open = !_open; // toggle on button click return true; } }