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