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

@ -74,12 +74,52 @@ public sealed class ChatWindowController
public UiScrollbar Scrollbar { get; private set; } = null!;
/// <summary>Channel-selector menu widget.</summary>
public UiChannelMenu Menu { get; private set; } = null!;
public UiMenu Menu { get; private set; } = null!;
// ── Private state ──────────────────────────────────────────────────────
private ChatChannelKind _activeChannel = ChatChannelKind.Say;
// ── Channel knowledge (ported from old UiChannelMenu — gmMainChatUI::InitTalkFocusMenu @0x4cdc50) ──
private static readonly (string Label, ChatChannelKind? Channel)[] ChannelItems =
{
("Squelch (ignore)", null),
("Tell to Selected", null),
("Chat to All", ChatChannelKind.Say),
("Tell to Fellows", ChatChannelKind.Fellowship),
("Tell to General Chat", ChatChannelKind.General),
("Tell to LFG Chat", ChatChannelKind.Lfg),
("Tell to Society Chat", ChatChannelKind.Society),
("Tell to Monarch", ChatChannelKind.Monarch),
("Tell to Patron", ChatChannelKind.Patron),
("Tell to Vassals", ChatChannelKind.Vassals),
("Tell to Allegiance", ChatChannelKind.Allegiance),
("Tell to Trade Chat", ChatChannelKind.Trade),
("Tell to Roleplay Chat", ChatChannelKind.Roleplay),
("Tell to Olthoi Chat", ChatChannelKind.Olthoi),
};
private static string ChannelButtonLabel(ChatChannelKind k) => k 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",
};
private static bool ChannelAvailable(ChatChannelKind k)
=> k is ChatChannelKind.Say or ChatChannelKind.General or ChatChannelKind.Trade or ChatChannelKind.Lfg;
/// <summary>Window height before maximize (stored to restore on un-maximize).</summary>
private float _normalHeight;
/// <summary>Window top before maximize.</summary>
@ -110,7 +150,7 @@ public sealed class ChatWindowController
/// <param name="debugFont">Fallback debug bitmap font (used when
/// <paramref name="datFont"/> is null).</param>
/// <param name="resolve">Dat RenderSurface id → (GL tex handle, px width, px height).
/// Forwarded to <see cref="UiScrollbar"/> and <see cref="UiChannelMenu"/>.</param>
/// Forwarded to <see cref="UiScrollbar"/> and <see cref="UiMenu"/>.</param>
public static ChatWindowController? Bind(
ElementInfo rootInfo,
ImportedLayout layout,
@ -216,29 +256,23 @@ public sealed class ChatWindowController
c.Scrollbar = bar;
}
// ── Channel menu — replace the imported menu placeholder ──────────
var menuEl = layout.FindElement(MenuId);
if (menuEl?.Parent is { } menuParent)
// ── Channel menu — bind the factory-built Type-6 UiMenu ──────────
if (layout.FindElement(MenuId) is UiMenu menu)
{
c.Menu = new UiChannelMenu
menu.DatFont = datFont; menu.Font = debugFont; menu.SpriteResolve = resolve;
menu.NormalSprite = MenuNormal; menu.PressedSprite = MenuPressed;
menu.PopupBgSprite = MenuPopupBg;
menu.ItemNormalSprite = MenuItemRow; menu.ItemHighlightSprite = MenuItemSelected;
menu.Items = System.Array.ConvertAll(ChannelItems,
t => new UiMenu.MenuItem(t.Label, (object?)t.Channel));
menu.Selected = (object?)c._activeChannel;
menu.EnabledProvider = p => p is not ChatChannelKind ch || ChannelAvailable(ch);
menu.ButtonLabelProvider = () => ChannelButtonLabel(c._activeChannel);
menu.OnSelect = p =>
{
Left = menuEl.Left,
Top = menuEl.Top,
Width = menuEl.Width,
Height = menuEl.Height,
Anchors = menuEl.Anchors,
DatFont = datFont,
Font = debugFont,
SpriteResolve = resolve,
NormalSprite = MenuNormal,
PressedSprite = MenuPressed,
PopupBgSprite = MenuPopupBg,
ItemNormalSprite = MenuItemRow,
ItemHighlightSprite = MenuItemSelected,
if (p is ChatChannelKind ch) { c._activeChannel = ch; menu.Selected = p; }
};
c.Menu.OnChannelChanged = k => c._activeChannel = k;
menuParent.RemoveChild(menuEl);
menuParent.AddChild(c.Menu);
c.Menu = menu;
}
// ── Send button — Enter-alternate submit trigger ──────────────────
@ -269,8 +303,8 @@ public sealed class ChatWindowController
c.Input.Width = System.MathF.Max(40f, inputRight - c.Input.Left);
c.Input.ResetAnchorCapture();
}
var onChanged = c.Menu.OnChannelChanged;
c.Menu.OnChannelChanged = k => { onChanged?.Invoke(k); ReflowInputRow(); };
var onSelect = c.Menu.OnSelect;
c.Menu.OnSelect = p => { onSelect?.Invoke(p); ReflowInputRow(); };
ReflowInputRow();
}

View file

@ -59,6 +59,7 @@ public static class DatWidgetFactory
UiElement e = info.Type switch
{
1 => new UiButton(info, resolve), // UIElement_Button (reg :125828)
6 => new UiMenu(), // UIElement_Menu (reg :120163)
7 => BuildMeter(info, resolve, datFont), // UIElement_Meter
11 => new UiScrollbar(), // UIElement_Scrollbar (reg :124137)
_ => new UiDatElement(info, resolve), // generic fallback for all other types

View file

@ -1,48 +1,43 @@
using System;
using System.Collections.Generic;
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>).
/// 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 UiChannelMenu : UiElement
public sealed class UiMenu : 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>One menu row: its label + an opaque payload the controller maps back.</summary>
public readonly record struct MenuItem(string Label, object? Payload);
/// <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
};
/// <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 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
@ -53,14 +48,6 @@ public sealed class UiChannelMenu : UiElement
// 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; }
@ -85,40 +72,13 @@ public sealed class UiChannelMenu : UiElement
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;
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 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",
};
public UiMenu() { CapturesPointerDrag = true; }
protected override void OnDraw(UiRenderContext ctx)
{
@ -130,7 +90,7 @@ public sealed class UiChannelMenu : UiElement
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);
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
@ -153,7 +113,8 @@ public sealed class UiChannelMenu : UiElement
/// 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;
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
}
@ -165,7 +126,7 @@ public sealed class UiChannelMenu : UiElement
var resolve = SpriteResolve;
if (!_open || resolve is null) return;
// Two-column popup opening UPWARD from the button, wrapped in the universal
// 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,
@ -179,22 +140,21 @@ public sealed class UiChannelMenu : UiElement
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++)
for (int i = 0; i < Items.Count; 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);
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 = (ItemH - LineH()) * 0.5f; // center the label in its row
for (int i = 0; i < Items.Length; i++)
float textY = (RowHeight - LineH()) * 0.5f; // center the label in its row
for (int i = 0; i < Items.Count; 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,
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);
}
}
@ -256,15 +216,15 @@ public sealed class UiChannelMenu : UiElement
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))
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))
{
Selected = ch;
OnChannelChanged?.Invoke(ch);
Selected = Items[idx].Payload;
OnSelect?.Invoke(Selected);
}
}
_open = false;