feat(D.2b): UiMenu (Type 6) — generic dropdown; channel knowledge moves to controller (widget-generalization Task 4)

UiChannelMenu → UiMenu: removed ChatChannelKind, the 14-item array, the
button-text map, and the availability default. Generic surface: MenuItem
(label + object? Payload), Selected (object?), OnSelect, EnabledProvider,
ButtonLabelProvider, RowsPerColumn/RowHeight/ColumnWidth (all settable).
All draw/event mechanics unchanged — same popup geometry, same click
coordinates, same 8-piece bevel, same 3-slice button face.

ChatWindowController gains ChannelItems[], ChannelButtonLabel(), and
ChannelAvailable() (verbatim from old widget), and populates the
factory-built Type-6 UiMenu via find-by-id rather than constructing a
replacement widget. The Menu property type is now UiMenu. OnChannelChanged
wrap replaced with the generic OnSelect wrap for the ReflowInputRow hook.

DatWidgetFactory registers Type 6 → new UiMenu().

Tests: UiChannelMenuTests → UiMenuTests (10 tests, all green); factory
Type6 test added; ChatWindowControllerTests updated to use OnSelect.
Divergence register: AP-42 added (flat item model vs retail nested-submenu
MakePopup @0x46d310 — latent, unreachable through the chat menu).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-16 17:18:27 +02:00
parent 805ab5f40b
commit 955f7a69a8
8 changed files with 302 additions and 252 deletions

View file

@ -1,277 +0,0 @@
using System;
using System.Numerics;
using AcDream.UI.Abstractions;
namespace AcDream.App.UI;
/// <summary>
/// Chat channel selector — the "Chat" button + its talk-focus popup. Mirrors retail
/// <c>gmMainChatUI::InitTalkFocusMenu @0x4cdc50</c> + <c>UIElement_Menu::MakePopup
/// @0x46d310</c>: the button is labelled with the active target; clicking opens a
/// TWO-COLUMN popup of 14 talk-focus items on the dat-driven menu chrome (panel +
/// per-row + selected-row sprites, 191×17 rows, from LayoutDesc 0x21000006 elements
/// 0x1000001C/1D/1E). Items are code-populated exactly as retail populates them.
/// Unavailable channels render greyed (retail <c>ResetAllTalkFocusMenuButtons</c> →
/// SetState(disabled), <c>colorPink</c>).
/// </summary>
public sealed class UiChannelMenu : UiElement
{
/// <summary>One menu row: its label + the channel it selects (null = special/no-op
/// item such as Squelch or Tell-to-Selected, deferred).</summary>
public readonly record struct Item(string Label, ChatChannelKind? Channel);
/// <summary>The 14 retail talk-focus items in retail order — left column rows 06,
/// right column rows 713 (matching the live retail menu).</summary>
public static readonly Item[] Items =
{
new("Squelch (ignore)", null), // 0 special (squelch — deferred)
new("Tell to Selected", null), // 1 special (selected target — deferred)
new("Chat to All", ChatChannelKind.Say), // 2
new("Tell to Fellows", ChatChannelKind.Fellowship), // 3
new("Tell to General Chat", ChatChannelKind.General), // 4
new("Tell to LFG Chat", ChatChannelKind.Lfg), // 5
new("Tell to Society Chat", ChatChannelKind.Society), // 6
new("Tell to Monarch", ChatChannelKind.Monarch), // 7
new("Tell to Patron", ChatChannelKind.Patron), // 8
new("Tell to Vassals", ChatChannelKind.Vassals), // 9
new("Tell to Allegiance", ChatChannelKind.Allegiance), // 10
new("Tell to Trade Chat", ChatChannelKind.Trade), // 11
new("Tell to Roleplay Chat", ChatChannelKind.Roleplay), // 12
new("Tell to Olthoi Chat", ChatChannelKind.Olthoi), // 13
};
private const int Rows = 7; // items per column
private const float ItemH = 17f; // row height (dat item template 0x1000001E H=17)
private const float ColW = 191f; // column width (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 (~x420 of the 46px button); the caption starts past it so it doesn't
// render over the LED.
private const float ButtonTextIndent = 20f;
/// <summary>The channel the player's typed text currently goes to.</summary>
public ChatChannelKind Selected { get; private set; } = ChatChannelKind.Say;
public Action<ChatChannelKind>? OnChannelChanged { get; set; }
/// <summary>Per-channel availability gate (retail greys channels you are not in).
/// Defaults to a static approximation; the controller can inject live channel state.</summary>
public Func<ChatChannelKind, bool>? AvailabilityProvider { get; set; }
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 static float InteriorW => 2 * ColW; // 382
private static float InteriorH => Rows * ItemH; // 119
private static float OuterW => InteriorW + 2 * Border;
private static float OuterH => InteriorH + 2 * Border;
public UiChannelMenu() { CapturesPointerDrag = true; }
/// <summary>True if the channel is currently joinable/visible. Defaults to a static
/// approximation matching the common case (Say/General/Trade/LFG); the fellowship +
/// allegiance-hierarchy channels need membership state acdream does not yet track
/// (deferred → greyed). The controller can override via <see cref="AvailabilityProvider"/>.</summary>
private bool IsAvailable(ChatChannelKind ch)
=> AvailabilityProvider?.Invoke(ch)
?? ch is ChatChannelKind.Say or ChatChannelKind.General
or ChatChannelKind.Trade or ChatChannelKind.Lfg;
/// <summary>The button face label = the active talk target (retail updates the
/// button to whichever target you pick). "Chat" = Chat-to-All (Say).</summary>
private string ButtonText => Selected switch
{
ChatChannelKind.Say => "Chat",
ChatChannelKind.General => "General",
ChatChannelKind.Trade => "Trade",
ChatChannelKind.Lfg => "LFG",
ChatChannelKind.Fellowship => "Fellow",
ChatChannelKind.Allegiance => "Alleg",
ChatChannelKind.Patron => "Patron",
ChatChannelKind.Vassals => "Vassals",
ChatChannelKind.Monarch => "Monarch",
ChatChannelKind.Roleplay => "Roleplay",
ChatChannelKind.Society => "Society",
ChatChannelKind.Olthoi => "Olthoi",
_ => "Chat",
};
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, ButtonText, 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()
{
float textW = DatFont?.MeasureWidth(ButtonText) ?? Font?.MeasureWidth(ButtonText) ?? ButtonText.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;
// Two-column 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.Length; i++)
{
int col = i / Rows, row = i % Rows;
float x = inX + col * ColW, y = inY + row * ItemH;
bool selected = Items[i].Channel is { } c && c == Selected;
DrawSprite(ctx, resolve, selected ? ItemHighlightSprite : ItemNormalSprite, x, y, ColW, ItemH);
}
float textY = (ItemH - LineH()) * 0.5f; // center the label in its row
for (int i = 0; i < Items.Length; i++)
{
int col = i / Rows, row = i % Rows;
// Channel items grey out when unavailable; the special items (Squelch /
// Tell-to-Selected, null channel) are normal white items in retail.
bool avail = Items[i].Channel is not { } c || IsAvailable(c);
DrawLabel(ctx, Items[i].Label, inX + col * ColW + TextIndent, inY + row * ItemH + 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 = ix < ColW ? 0 : 1;
int row = (int)(iy / ItemH);
int idx = col * Rows + row;
// Only pick available channel items (special + greyed items are inert).
if (row >= 0 && row < Rows && idx >= 0 && idx < Items.Length
&& Items[idx].Channel is { } ch && IsAvailable(ch))
{
Selected = ch;
OnChannelChanged?.Invoke(ch);
}
}
_open = false;
return true;
}
_open = !_open; // toggle on button click
return true;
}
}