The generalized channel menu wouldn't open: the factory recursed the Type-6 menu element's dat children, building its invisible Type-12 label child as a UiText. Hit-testing is children-first and UiText consumes MouseDown (selection), so the label child swallowed the menu button click and the dropdown never opened. The transcript similarly gained an invisible Ghosted-button child (a 16x16 selection dead-zone). The old hand-made build never had these — it skipped Type 12 and hand-placed the widgets with no children. Fix: behavioral widgets (Meter/Menu/Button/Scrollbar/Text/Field) draw their full appearance and reproduce their dat sub-elements procedurally, so they are LEAF — the importer must not build their dat children as separate (click-stealing) widgets. Add UiElement.ConsumesDatChildren (default false; the 6 behavioral widgets override true) and gate LayoutImporter recursion on it (replacing the UiMeter-only special case). Only generic containers (UiDatElement, panels) recurse. Visually confirmed in the live client (channel menu opens; General/Trade selected and sent). Vitals unchanged (UiMeter was already leaf). Full suite: 404 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
246 lines
13 KiB
C#
246 lines
13 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; }
|
||
|
||
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<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
|
||
|
||
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;
|
||
// 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; }
|
||
|
||
/// <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);
|
||
}
|
||
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
|
||
}
|
||
|
||
/// <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;
|
||
|
||
// 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(); }
|
||
}
|
||
|
||
/// <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);
|
||
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;
|
||
}
|
||
}
|