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

@ -139,6 +139,7 @@ accepted-divergence entries (#96, #49, #50).
| AP-39 | Chat lines carry one color per `ChatKind` (per-line solid color); retail `UIElement_Text` supports per-glyph styled runs (bold, different hue per segment) | `src/AcDream.App/UI/UiChatView.cs:13` | Retail glyph-run parsing lives inside keystone.dll with no PDB/decomp; per-line per-kind coloring is the correct tonal palette and covers all existing chat types | Chat lines retail renders with multiple colors or bold names (e.g. "PlayerName says: text") render as one flat color; subtle visual difference but functionally complete | `UIElement_Text` glyph-run styling (keystone.dll, no decomp) |
| AP-40 | Single default translucency for the chat window chrome; no focused/unfocused opacity transition; dat font face/size taken from the vitals `vitalsDatFont` (same dat font, not a chat-specific size lookup) | `src/AcDream.App/Rendering/GameWindow.cs` (chatController binding line) | Retail fades the chat window to ~80% alpha when unfocused (`gmMainChatUI::UpdateAlpha @0x4cdea0`); the opacity animation deferred to the Plan-2 window-manager input integration; sharing `vitalsDatFont` is safe — retail uses the same AC-default font for both | The chat window is always fully opaque/same-font rather than subtly fading when idle; no wrong text, but the focused/unfocused breathing rhythm is absent | `gmMainChatUI::UpdateAlpha` @0x4cdea0; `UCF::SetAceFont @0x4d3940` |
| AP-41 | Scrollbar thumb 3-slice cap fallback only: single-tile draw (`0x06004C63`) used only when `ThumbTopSprite`/`ThumbBotSprite` are unset; the chat controller passes all three cap ids so the 3-slice path is drawn in practice | `src/AcDream.App/UI/UiScrollbar.cs:35` | The fallback single-tile path is unreachable when caps are bound (chat controller always sets them); the 3-slice path is the active code path | Only if a future caller omits the cap ids will the fallback fire — no visual regression in the chat window | `UIElement_Scrollbar::UpdateLayout @0x4710d0`; cap sprites `0x06004C60` (top) + `0x06004C66` (bottom) from base layout `0x2100003E` |
| AP-42 | `UiMenu` item model is flat (label + opaque payload, single-level popup); retail `UIElement_Menu::MakePopup @0x46d310` supports hierarchical nested submenus via recursive popup chain | `src/AcDream.App/UI/UiMenu.cs` | The chat talk-focus menu is single-level (14 rows, 2 columns, no submenu); hierarchy is latent and unreachable through the chat window — no behavioral difference in the current usage | A future menu with nested submenus would render flat (only the top-level items drawn, no drill-down) | `UIElement_Menu::MakePopup` @0x46d310 |
---

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;

View file

@ -41,7 +41,7 @@ public class ChatWindowControllerTests
/// transcript (Type-12, no media) [0x10000011] ← skipped by factory
/// track (Type-3) [0x10000012]
/// inputBar (Type-3) [0x10000013]
/// menu (Type-3) [0x10000014]
/// menu (Type-6) [0x10000014]
/// input (Type-12, no media) [0x10000016] ← skipped by factory
/// send (Type-3) [0x10000019]
/// maxmin (Type-3) [0x1000046F]
@ -67,7 +67,7 @@ public class ChatWindowControllerTests
var menuNode = new ElementInfo
{
Id = 0x10000014u, Type = 3, X = 0, Y = 0, Width = 46, Height = 17,
Id = 0x10000014u, Type = 6, X = 0, Y = 0, Width = 46, Height = 17,
};
var inputNode = new ElementInfo
{
@ -180,8 +180,8 @@ public class ChatWindowControllerTests
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex);
Assert.NotNull(ctrl);
// Switch channel to General.
ctrl!.Menu.OnChannelChanged!.Invoke(ChatChannelKind.General);
// Switch channel to General via the generic OnSelect (payload is ChatChannelKind).
ctrl!.Menu.OnSelect!.Invoke((object?)ChatChannelKind.General);
ctrl.Input.OnSubmit!.Invoke("hey all");
Assert.Single(bus.Published);

View file

@ -115,6 +115,15 @@ public class DatWidgetFactoryTests
Assert.IsType<UiScrollbar>(e);
}
// ── Test 5d: Type 6 → UiMenu ─────────────────────────────────────────────
[Fact]
public void Type6_Menu_MakesUiMenu()
{
var e = DatWidgetFactory.Create(new ElementInfo { Type = 6, Width = 46, Height = 18 }, NoTex, null);
Assert.IsType<UiMenu>(e);
}
// ── Test 6: Meter slice extraction (the important one) ───────────────────
/// <summary>

View file

@ -1,125 +0,0 @@
using System.Linq;
using AcDream.App.UI;
using AcDream.UI.Abstractions;
namespace AcDream.App.Tests.UI;
public class UiChannelMenuTests
{
// PopupH = Rows(7) * ItemH(17) = 119; popup opens upward so top = -119.
// Item idx -> col = idx/7, row = idx%7; row band y in [top+row*17, top+(row+1)*17).
// Right column needs lx >= ColW(191).
[Fact]
public void Items_HasExpected14Entries()
{
Assert.Equal(14, UiChannelMenu.Items.Length);
}
[Fact]
public void Items_FirstEntry_IsSquelch_Special()
{
Assert.Equal("Squelch (ignore)", UiChannelMenu.Items[0].Label);
Assert.Null(UiChannelMenu.Items[0].Channel);
}
[Fact]
public void Items_LastEntry_IsOlthoi()
{
var last = UiChannelMenu.Items[^1];
Assert.Equal("Tell to Olthoi Chat", last.Label);
Assert.Equal(ChatChannelKind.Olthoi, last.Channel);
}
[Fact]
public void Items_ContainAll12ChannelKinds()
{
var kinds = new HashSet<ChatChannelKind>(
UiChannelMenu.Items.Where(i => i.Channel is not null).Select(i => i.Channel!.Value));
foreach (var k in new[]
{
ChatChannelKind.Say, ChatChannelKind.General, ChatChannelKind.Trade, ChatChannelKind.Lfg,
ChatChannelKind.Fellowship, ChatChannelKind.Allegiance, ChatChannelKind.Patron,
ChatChannelKind.Vassals, ChatChannelKind.Monarch, ChatChannelKind.Roleplay,
ChatChannelKind.Society, ChatChannelKind.Olthoi,
})
Assert.Contains(k, kinds);
}
[Fact]
public void DefaultSelected_IsSay()
{
Assert.Equal(ChatChannelKind.Say, new UiChannelMenu().Selected);
}
[Fact]
public void Select_AvailableLeftColumnItem_FiresChannel()
{
var menu = new UiChannelMenu { Width = 80f, Height = 18f };
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open
ChatChannelKind? fired = null;
menu.OnChannelChanged = k => fired = k;
// "Chat to All" (Say) is index 2 = left col, row 2: y in [-85,-68). Say is available.
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -76)));
Assert.Equal(ChatChannelKind.Say, fired);
Assert.Equal(ChatChannelKind.Say, menu.Selected);
}
[Fact]
public void Select_AvailableRightColumnItem_FiresChannel()
{
var menu = new UiChannelMenu { Width = 80f, Height = 18f };
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open
ChatChannelKind? fired = null;
menu.OnChannelChanged = k => fired = k;
// "Tell to Trade Chat" (Trade) is index 11 = right col (lx>=191), row 4: y in [-51,-34).
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 200, -42)));
Assert.Equal(ChatChannelKind.Trade, fired);
Assert.Equal(ChatChannelKind.Trade, menu.Selected);
}
[Fact]
public void Select_SpecialItem_DoesNotFire()
{
var menu = new UiChannelMenu { Width = 80f, Height = 18f };
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open
int fired = 0;
menu.OnChannelChanged = _ => fired++;
// "Squelch (ignore)" is index 0 = left col, row 0 (null channel): y in [-119,-102).
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -110)));
Assert.Equal(0, fired);
}
[Fact]
public void Select_UnavailableChannel_DoesNotFire()
{
var menu = new UiChannelMenu { Width = 80f, Height = 18f };
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open
int fired = 0;
menu.OnChannelChanged = _ => fired++;
// "Tell to Fellows" (Fellowship) is index 3 = left col, row 3: y in [-68,-51).
// Fellowship is unavailable by the default static gate, so the click is inert.
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -60)));
Assert.Equal(0, fired);
}
[Fact]
public void AvailabilityProvider_Overrides_DefaultGate()
{
var menu = new UiChannelMenu { Width = 80f, Height = 18f, AvailabilityProvider = _ => true };
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open
ChatChannelKind? fired = null;
menu.OnChannelChanged = k => fired = k;
// With every channel available, "Tell to Fellows" (idx 3, row 3) now fires.
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -60)));
Assert.Equal(ChatChannelKind.Fellowship, fired);
}
}

View file

@ -0,0 +1,170 @@
using System.Collections.Generic;
using System.Linq;
using AcDream.App.UI;
using AcDream.UI.Abstractions;
namespace AcDream.App.Tests.UI;
public class UiMenuTests
{
// PopupH = RowsPerColumn(7) * RowHeight(17) = 119; popup opens upward so top = -119.
// Item idx -> col = idx/7, row = idx%7; row band y in [top+row*17, top+(row+1)*17).
// Right column needs lx >= ColumnWidth(191) + Border(5) = lx >= 196 after bevel offset,
// but the original tests used lx=200 which maps ix=195 -> col=(int)(195/191)=1. OK.
// The 14 channel items verbatim (matches ChannelItems in ChatWindowController).
private static readonly UiMenu.MenuItem[] ChannelItems =
{
new("Squelch (ignore)", (object?)null),
new("Tell to Selected", (object?)null),
new("Chat to All", (object?)ChatChannelKind.Say),
new("Tell to Fellows", (object?)ChatChannelKind.Fellowship),
new("Tell to General Chat", (object?)ChatChannelKind.General),
new("Tell to LFG Chat", (object?)ChatChannelKind.Lfg),
new("Tell to Society Chat", (object?)ChatChannelKind.Society),
new("Tell to Monarch", (object?)ChatChannelKind.Monarch),
new("Tell to Patron", (object?)ChatChannelKind.Patron),
new("Tell to Vassals", (object?)ChatChannelKind.Vassals),
new("Tell to Allegiance", (object?)ChatChannelKind.Allegiance),
new("Tell to Trade Chat", (object?)ChatChannelKind.Trade),
new("Tell to Roleplay Chat", (object?)ChatChannelKind.Roleplay),
new("Tell to Olthoi Chat", (object?)ChatChannelKind.Olthoi),
};
// Availability gate identical to ChatWindowController.ChannelAvailable.
private static bool ChannelAvailable(object? p)
=> p is ChatChannelKind ch
? ch is ChatChannelKind.Say or ChatChannelKind.General
or ChatChannelKind.Trade or ChatChannelKind.Lfg
: false; // null-payload (Squelch/Tell-to-Selected) = inert
private UiMenu MakeMenu() => new UiMenu
{
Width = 80f, Height = 18f,
Items = ChannelItems,
Selected = (object?)ChatChannelKind.Say,
EnabledProvider = ChannelAvailable,
};
[Fact]
public void Items_HasExpected14Entries()
{
Assert.Equal(14, ChannelItems.Length);
}
[Fact]
public void Items_FirstEntry_IsSquelch_Special()
{
Assert.Equal("Squelch (ignore)", ChannelItems[0].Label);
Assert.Null(ChannelItems[0].Payload);
}
[Fact]
public void Items_LastEntry_IsOlthoi()
{
var last = ChannelItems[^1];
Assert.Equal("Tell to Olthoi Chat", last.Label);
Assert.Equal(ChatChannelKind.Olthoi, last.Payload);
}
[Fact]
public void Items_ContainAll12ChannelKinds()
{
var kinds = new HashSet<ChatChannelKind>(
ChannelItems.Where(i => i.Payload is ChatChannelKind).Select(i => (ChatChannelKind)i.Payload!));
foreach (var k in new[]
{
ChatChannelKind.Say, ChatChannelKind.General, ChatChannelKind.Trade, ChatChannelKind.Lfg,
ChatChannelKind.Fellowship, ChatChannelKind.Allegiance, ChatChannelKind.Patron,
ChatChannelKind.Vassals, ChatChannelKind.Monarch, ChatChannelKind.Roleplay,
ChatChannelKind.Society, ChatChannelKind.Olthoi,
})
Assert.Contains(k, kinds);
}
[Fact]
public void DefaultSelected_IsNull_OnBlankMenu()
{
// A freshly constructed UiMenu has no Selected by default (controller sets it).
Assert.Null(new UiMenu().Selected);
}
[Fact]
public void Select_AvailableLeftColumnItem_FiresOnSelect()
{
var menu = MakeMenu();
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open
object? fired = null;
menu.OnSelect = p => fired = p;
// "Chat to All" (Say) is index 2 = left col, row 2: y in [-85,-68). Say is available.
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -76)));
Assert.Equal(ChatChannelKind.Say, fired);
Assert.Equal(ChatChannelKind.Say, menu.Selected);
}
[Fact]
public void Select_AvailableRightColumnItem_FiresOnSelect()
{
var menu = MakeMenu();
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open
object? fired = null;
menu.OnSelect = p => fired = p;
// "Tell to Trade Chat" (Trade) is index 11 = right col (lx>=191), row 4: y in [-51,-34).
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 200, -42)));
Assert.Equal(ChatChannelKind.Trade, fired);
Assert.Equal(ChatChannelKind.Trade, menu.Selected);
}
[Fact]
public void Select_SpecialItem_DoesNotFire()
{
var menu = MakeMenu();
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open
int fired = 0;
menu.OnSelect = _ => fired++;
// "Squelch (ignore)" is index 0 = left col, row 0 (null payload): y in [-119,-102).
// null payload → ChannelAvailable returns false → inert.
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -110)));
Assert.Equal(0, fired);
}
[Fact]
public void Select_UnavailableChannel_DoesNotFire()
{
var menu = MakeMenu();
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open
int fired = 0;
menu.OnSelect = _ => fired++;
// "Tell to Fellows" (Fellowship) is index 3 = left col, row 3: y in [-68,-51).
// Fellowship is unavailable by the default static gate, so the click is inert.
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -60)));
Assert.Equal(0, fired);
}
[Fact]
public void EnabledProvider_Overrides_DefaultGate()
{
// Override: all items enabled (even Fellowship which is normally greyed).
var menu = new UiMenu
{
Width = 80f, Height = 18f,
Items = ChannelItems,
Selected = (object?)ChatChannelKind.Say,
EnabledProvider = _ => true,
};
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open
object? fired = null;
menu.OnSelect = p => fired = p;
// With every item enabled, "Tell to Fellows" (idx 3, row 3) now fires.
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -60)));
Assert.Equal(ChatChannelKind.Fellowship, fired);
}
}