From 955f7a69a8556a3dd25255ddd8185ea420fd7b50 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 16 Jun 2026 17:18:27 +0200 Subject: [PATCH] =?UTF-8?q?feat(D.2b):=20UiMenu=20(Type=206)=20=E2=80=94?= =?UTF-8?q?=20generic=20dropdown;=20channel=20knowledge=20moves=20to=20con?= =?UTF-8?q?troller=20(widget-generalization=20Task=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../retail-divergence-register.md | 1 + .../UI/Layout/ChatWindowController.cs | 82 ++++++--- src/AcDream.App/UI/Layout/DatWidgetFactory.cs | 1 + .../UI/{UiChannelMenu.cs => UiMenu.cs} | 158 ++++++---------- .../UI/Layout/ChatWindowControllerTests.cs | 8 +- .../UI/Layout/DatWidgetFactoryTests.cs | 9 + .../UI/UiChannelMenuTests.cs | 125 ------------- tests/AcDream.App.Tests/UI/UiMenuTests.cs | 170 ++++++++++++++++++ 8 files changed, 302 insertions(+), 252 deletions(-) rename src/AcDream.App/UI/{UiChannelMenu.cs => UiMenu.cs} (56%) delete mode 100644 tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs create mode 100644 tests/AcDream.App.Tests/UI/UiMenuTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 308c03bb..052cca56 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -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 | --- diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index 64c0fc6b..a6281eaa 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -74,12 +74,52 @@ public sealed class ChatWindowController public UiScrollbar Scrollbar { get; private set; } = null!; /// Channel-selector menu widget. - 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; + /// Window height before maximize (stored to restore on un-maximize). private float _normalHeight; /// Window top before maximize. @@ -110,7 +150,7 @@ public sealed class ChatWindowController /// Fallback debug bitmap font (used when /// is null). /// Dat RenderSurface id → (GL tex handle, px width, px height). - /// Forwarded to and . + /// Forwarded to and . 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(); } diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index 20b688a1..556fc3ee 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -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 diff --git a/src/AcDream.App/UI/UiChannelMenu.cs b/src/AcDream.App/UI/UiMenu.cs similarity index 56% rename from src/AcDream.App/UI/UiChannelMenu.cs rename to src/AcDream.App/UI/UiMenu.cs index a64e1aa4..b3e1595d 100644 --- a/src/AcDream.App/UI/UiChannelMenu.cs +++ b/src/AcDream.App/UI/UiMenu.cs @@ -1,48 +1,43 @@ using System; +using System.Collections.Generic; using System.Numerics; -using AcDream.UI.Abstractions; namespace AcDream.App.UI; /// -/// Chat channel selector — the "Chat" button + its talk-focus popup. Mirrors retail -/// gmMainChatUI::InitTalkFocusMenu @0x4cdc50 + UIElement_Menu::MakePopup -/// @0x46d310: 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 ResetAllTalkFocusMenuButtons → -/// SetState(disabled), colorPink). +/// Generic dropdown menu. Ports retail UIElement_Menu +/// (RegisterElementClass(6) @ acclient_2013_pseudo_c.txt:120163) + +/// UIElement_Menu::MakePopup @0x46d310: 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 for Type-6 elements. /// -public sealed class UiChannelMenu : UiElement +public sealed class UiMenu : UiElement { - /// One menu row: its label + the channel it selects (null = special/no-op - /// item such as Squelch or Tell-to-Selected, deferred). - public readonly record struct Item(string Label, ChatChannelKind? Channel); + /// One menu row: its label + an opaque payload the controller maps back. + public readonly record struct MenuItem(string Label, object? Payload); - /// The 14 retail talk-focus items in retail order — left column rows 0–6, - /// right column rows 7–13 (matching the live retail menu). - 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 - }; + /// 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. + public IReadOnlyList Items { get; set; } = System.Array.Empty(); + + /// The currently-selected payload (drives the highlighted row). + public object? Selected { get; set; } + + /// Fired with the picked item's payload when a row is chosen. + public Action? OnSelect { get; set; } + + /// Per-payload enabled gate (disabled rows render greyed + are inert). Null ⇒ all enabled. + public Func? EnabledProvider { get; set; } + + /// Button-face caption (the active target). Null ⇒ blank face. + public Func? 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; - /// The channel the player's typed text currently goes to. - public ChatChannelKind Selected { get; private set; } = ChatChannelKind.Say; - public Action? OnChannelChanged { get; set; } - - /// Per-channel availability gate (retail greys channels you are not in). - /// Defaults to a static approximation; the controller can inject live channel state. - public Func? AvailabilityProvider { get; set; } - public UiDatFont? DatFont { get; set; } public AcDream.App.Rendering.BitmapFont? Font { get; set; } public Func? 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; } - - /// 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 . - private bool IsAvailable(ChatChannelKind ch) - => AvailabilityProvider?.Invoke(ch) - ?? ch is ChatChannelKind.Say or ChatChannelKind.General - or ChatChannelKind.Trade or ChatChannelKind.Lfg; - - /// The button face label = the active talk target (retail updates the - /// button to whichever target you pick). "Chat" = Chat-to-All (Say). - 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. 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; diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs index 717c92da..f8abfa55 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs @@ -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); diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs index d2e8c439..4f546920 100644 --- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs @@ -115,6 +115,15 @@ public class DatWidgetFactoryTests Assert.IsType(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(e); + } + // ── Test 6: Meter slice extraction (the important one) ─────────────────── /// diff --git a/tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs b/tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs deleted file mode 100644 index b3e9db8e..00000000 --- a/tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs +++ /dev/null @@ -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( - 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); - } -} diff --git a/tests/AcDream.App.Tests/UI/UiMenuTests.cs b/tests/AcDream.App.Tests/UI/UiMenuTests.cs new file mode 100644 index 00000000..1e4e1bd5 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiMenuTests.cs @@ -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( + 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); + } +}