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:
parent
805ab5f40b
commit
955f7a69a8
8 changed files with 302 additions and 252 deletions
|
|
@ -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-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-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-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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -74,12 +74,52 @@ public sealed class ChatWindowController
|
||||||
public UiScrollbar Scrollbar { get; private set; } = null!;
|
public UiScrollbar Scrollbar { get; private set; } = null!;
|
||||||
|
|
||||||
/// <summary>Channel-selector menu widget.</summary>
|
/// <summary>Channel-selector menu widget.</summary>
|
||||||
public UiChannelMenu Menu { get; private set; } = null!;
|
public UiMenu Menu { get; private set; } = null!;
|
||||||
|
|
||||||
// ── Private state ──────────────────────────────────────────────────────
|
// ── Private state ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
private ChatChannelKind _activeChannel = ChatChannelKind.Say;
|
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>
|
/// <summary>Window height before maximize (stored to restore on un-maximize).</summary>
|
||||||
private float _normalHeight;
|
private float _normalHeight;
|
||||||
/// <summary>Window top before maximize.</summary>
|
/// <summary>Window top before maximize.</summary>
|
||||||
|
|
@ -110,7 +150,7 @@ public sealed class ChatWindowController
|
||||||
/// <param name="debugFont">Fallback debug bitmap font (used when
|
/// <param name="debugFont">Fallback debug bitmap font (used when
|
||||||
/// <paramref name="datFont"/> is null).</param>
|
/// <paramref name="datFont"/> is null).</param>
|
||||||
/// <param name="resolve">Dat RenderSurface id → (GL tex handle, px width, px height).
|
/// <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(
|
public static ChatWindowController? Bind(
|
||||||
ElementInfo rootInfo,
|
ElementInfo rootInfo,
|
||||||
ImportedLayout layout,
|
ImportedLayout layout,
|
||||||
|
|
@ -216,29 +256,23 @@ public sealed class ChatWindowController
|
||||||
c.Scrollbar = bar;
|
c.Scrollbar = bar;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Channel menu — replace the imported menu placeholder ──────────
|
// ── Channel menu — bind the factory-built Type-6 UiMenu ──────────
|
||||||
var menuEl = layout.FindElement(MenuId);
|
if (layout.FindElement(MenuId) is UiMenu menu)
|
||||||
if (menuEl?.Parent is { } menuParent)
|
|
||||||
{
|
{
|
||||||
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,
|
if (p is ChatChannelKind ch) { c._activeChannel = ch; menu.Selected = p; }
|
||||||
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,
|
|
||||||
};
|
};
|
||||||
c.Menu.OnChannelChanged = k => c._activeChannel = k;
|
c.Menu = menu;
|
||||||
menuParent.RemoveChild(menuEl);
|
|
||||||
menuParent.AddChild(c.Menu);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Send button — Enter-alternate submit trigger ──────────────────
|
// ── 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.Width = System.MathF.Max(40f, inputRight - c.Input.Left);
|
||||||
c.Input.ResetAnchorCapture();
|
c.Input.ResetAnchorCapture();
|
||||||
}
|
}
|
||||||
var onChanged = c.Menu.OnChannelChanged;
|
var onSelect = c.Menu.OnSelect;
|
||||||
c.Menu.OnChannelChanged = k => { onChanged?.Invoke(k); ReflowInputRow(); };
|
c.Menu.OnSelect = p => { onSelect?.Invoke(p); ReflowInputRow(); };
|
||||||
ReflowInputRow();
|
ReflowInputRow();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ public static class DatWidgetFactory
|
||||||
UiElement e = info.Type switch
|
UiElement e = info.Type switch
|
||||||
{
|
{
|
||||||
1 => new UiButton(info, resolve), // UIElement_Button (reg :125828)
|
1 => new UiButton(info, resolve), // UIElement_Button (reg :125828)
|
||||||
|
6 => new UiMenu(), // UIElement_Menu (reg :120163)
|
||||||
7 => BuildMeter(info, resolve, datFont), // UIElement_Meter
|
7 => BuildMeter(info, resolve, datFont), // UIElement_Meter
|
||||||
11 => new UiScrollbar(), // UIElement_Scrollbar (reg :124137)
|
11 => new UiScrollbar(), // UIElement_Scrollbar (reg :124137)
|
||||||
_ => new UiDatElement(info, resolve), // generic fallback for all other types
|
_ => new UiDatElement(info, resolve), // generic fallback for all other types
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,43 @@
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using AcDream.UI.Abstractions;
|
|
||||||
|
|
||||||
namespace AcDream.App.UI;
|
namespace AcDream.App.UI;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Chat channel selector — the "Chat" button + its talk-focus popup. Mirrors retail
|
/// Generic dropdown menu. Ports retail <c>UIElement_Menu</c>
|
||||||
/// <c>gmMainChatUI::InitTalkFocusMenu @0x4cdc50</c> + <c>UIElement_Menu::MakePopup
|
/// (<c>RegisterElementClass(6) @ acclient_2013_pseudo_c.txt:120163</c>) +
|
||||||
/// @0x46d310</c>: the button is labelled with the active target; clicking opens a
|
/// <c>UIElement_Menu::MakePopup @0x46d310</c>: the button is labelled with
|
||||||
/// TWO-COLUMN popup of 14 talk-focus items on the dat-driven menu chrome (panel +
|
/// the active target; clicking opens a column-major popup on the dat-driven menu
|
||||||
/// per-row + selected-row sprites, 191×17 rows, from LayoutDesc 0x21000006 elements
|
/// chrome (panel + per-row + selected-row sprites). Items and all chat-channel
|
||||||
/// 0x1000001C/1D/1E). Items are code-populated exactly as retail populates them.
|
/// knowledge are populated by the controller, not baked into this widget. Built
|
||||||
/// Unavailable channels render greyed (retail <c>ResetAllTalkFocusMenuButtons</c> →
|
/// by <see cref="AcDream.App.UI.Layout.DatWidgetFactory"/> for Type-6 elements.
|
||||||
/// SetState(disabled), <c>colorPink</c>).
|
|
||||||
/// </summary>
|
/// </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
|
/// <summary>One menu row: its label + an opaque payload the controller maps back.</summary>
|
||||||
/// item such as Squelch or Tell-to-Selected, deferred).</summary>
|
public readonly record struct MenuItem(string Label, object? Payload);
|
||||||
public readonly record struct Item(string Label, ChatChannelKind? Channel);
|
|
||||||
|
|
||||||
/// <summary>The 14 retail talk-focus items in retail order — left column rows 0–6,
|
/// <summary>The rows, populated by the controller. Laid out column-major:
|
||||||
/// right column rows 7–13 (matching the live retail menu).</summary>
|
/// rows 0..RowsPerColumn-1 in column 0, then the next group in column 1, etc.</summary>
|
||||||
public static readonly Item[] Items =
|
public IReadOnlyList<MenuItem> Items { get; set; } = System.Array.Empty<MenuItem>();
|
||||||
{
|
|
||||||
new("Squelch (ignore)", null), // 0 special (squelch — deferred)
|
/// <summary>The currently-selected payload (drives the highlighted row).</summary>
|
||||||
new("Tell to Selected", null), // 1 special (selected target — deferred)
|
public object? Selected { get; set; }
|
||||||
new("Chat to All", ChatChannelKind.Say), // 2
|
|
||||||
new("Tell to Fellows", ChatChannelKind.Fellowship), // 3
|
/// <summary>Fired with the picked item's payload when a row is chosen.</summary>
|
||||||
new("Tell to General Chat", ChatChannelKind.General), // 4
|
public Action<object?>? OnSelect { get; set; }
|
||||||
new("Tell to LFG Chat", ChatChannelKind.Lfg), // 5
|
|
||||||
new("Tell to Society Chat", ChatChannelKind.Society), // 6
|
/// <summary>Per-payload enabled gate (disabled rows render greyed + are inert). Null ⇒ all enabled.</summary>
|
||||||
new("Tell to Monarch", ChatChannelKind.Monarch), // 7
|
public Func<object?, bool>? EnabledProvider { get; set; }
|
||||||
new("Tell to Patron", ChatChannelKind.Patron), // 8
|
|
||||||
new("Tell to Vassals", ChatChannelKind.Vassals), // 9
|
/// <summary>Button-face caption (the active target). Null ⇒ blank face.</summary>
|
||||||
new("Tell to Allegiance", ChatChannelKind.Allegiance), // 10
|
public Func<string>? ButtonLabelProvider { get; set; }
|
||||||
new("Tell to Trade Chat", ChatChannelKind.Trade), // 11
|
|
||||||
new("Tell to Roleplay Chat", ChatChannelKind.Roleplay), // 12
|
public int RowsPerColumn { get; set; } = 7; // items per column (dat item template)
|
||||||
new("Tell to Olthoi Chat", ChatChannelKind.Olthoi), // 13
|
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)
|
private const int Border = RetailChromeSprites.Border; // 8-piece bevel thickness (5px)
|
||||||
// The row sprites 0x0600124E/4D bake a checkbox/checkmark into the leftmost ~17px
|
// 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
|
// 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.
|
// render over the LED.
|
||||||
private const float ButtonTextIndent = 20f;
|
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 UiDatFont? DatFont { get; set; }
|
||||||
public AcDream.App.Rendering.BitmapFont? Font { get; set; }
|
public AcDream.App.Rendering.BitmapFont? Font { get; set; }
|
||||||
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { 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;
|
private bool _open;
|
||||||
// Interior = the row content; Outer = interior + the 8-piece bevel ring.
|
// Interior = the row content; Outer = interior + the 8-piece bevel ring.
|
||||||
private static float InteriorW => 2 * ColW; // 382
|
private int ColumnCount => (Items.Count + RowsPerColumn - 1) / System.Math.Max(1, RowsPerColumn);
|
||||||
private static float InteriorH => Rows * ItemH; // 119
|
private float InteriorW => ColumnCount * ColumnWidth;
|
||||||
private static float OuterW => InteriorW + 2 * Border;
|
private float InteriorH => RowsPerColumn * RowHeight;
|
||||||
private static float OuterH => InteriorH + 2 * Border;
|
private float OuterW => InteriorW + 2 * Border;
|
||||||
|
private float OuterH => InteriorH + 2 * Border;
|
||||||
|
|
||||||
public UiChannelMenu() { CapturesPointerDrag = true; }
|
public UiMenu() { 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)
|
protected override void OnDraw(UiRenderContext ctx)
|
||||||
{
|
{
|
||||||
|
|
@ -130,7 +90,7 @@ public sealed class UiChannelMenu : UiElement
|
||||||
var (tex, tw, _) = resolve(_open ? PressedSprite : NormalSprite);
|
var (tex, tw, _) = resolve(_open ? PressedSprite : NormalSprite);
|
||||||
if (tex != 0 && tw > 0) DrawButtonFace(ctx, tex, tw);
|
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
|
// 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>
|
/// to this and reflows the input field to start after it.</summary>
|
||||||
public float NaturalButtonWidth()
|
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
|
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;
|
var resolve = SpriteResolve;
|
||||||
if (!_open || resolve is null) return;
|
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
|
// 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
|
// bevelled floating window). Force OPAQUE (a menu reads solid even though the
|
||||||
// chat window is translucent). Draw bevel → panel fill → row sprites → labels,
|
// 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);
|
DrawBevel(ctx, resolve, 0f, outerTop, OuterW, OuterH);
|
||||||
DrawSprite(ctx, resolve, PopupBgSprite, inX, inY, InteriorW, InteriorH); // panel fill behind rows
|
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;
|
int col = i / RowsPerColumn, row = i % RowsPerColumn;
|
||||||
float x = inX + col * ColW, y = inY + row * ItemH;
|
float x = inX + col * ColumnWidth, y = inY + row * RowHeight;
|
||||||
bool selected = Items[i].Channel is { } c && c == Selected;
|
bool selected = Equals(Items[i].Payload, Selected);
|
||||||
DrawSprite(ctx, resolve, selected ? ItemHighlightSprite : ItemNormalSprite, x, y, ColW, ItemH);
|
DrawSprite(ctx, resolve, selected ? ItemHighlightSprite : ItemNormalSprite, x, y, ColumnWidth, RowHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
float textY = (ItemH - LineH()) * 0.5f; // center the label in its row
|
float textY = (RowHeight - LineH()) * 0.5f; // center the label in its row
|
||||||
for (int i = 0; i < Items.Length; i++)
|
for (int i = 0; i < Items.Count; i++)
|
||||||
{
|
{
|
||||||
int col = i / Rows, row = i % Rows;
|
int col = i / RowsPerColumn, row = i % RowsPerColumn;
|
||||||
// Channel items grey out when unavailable; the special items (Squelch /
|
// Items grey out when unavailable; when EnabledProvider is null all items are enabled.
|
||||||
// Tell-to-Selected, null channel) are normal white items in retail.
|
bool avail = EnabledProvider?.Invoke(Items[i].Payload) ?? true;
|
||||||
bool avail = Items[i].Channel is not { } c || IsAvailable(c);
|
DrawLabel(ctx, Items[i].Label, inX + col * ColumnWidth + TextIndent, inY + row * RowHeight + textY,
|
||||||
DrawLabel(ctx, Items[i].Label, inX + col * ColW + TextIndent, inY + row * ItemH + textY,
|
|
||||||
avail ? TextColorAvailable : TextColorGhosted);
|
avail ? TextColorAvailable : TextColorGhosted);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -256,15 +216,15 @@ public sealed class UiChannelMenu : UiElement
|
||||||
float ix = lx - Border, iy = ly - (-OuterH + Border);
|
float ix = lx - Border, iy = ly - (-OuterH + Border);
|
||||||
if (ix >= 0 && ix < InteriorW && iy >= 0 && iy < InteriorH)
|
if (ix >= 0 && ix < InteriorW && iy >= 0 && iy < InteriorH)
|
||||||
{
|
{
|
||||||
int col = ix < ColW ? 0 : 1;
|
int col = (int)(ix / ColumnWidth);
|
||||||
int row = (int)(iy / ItemH);
|
int row = (int)(iy / RowHeight);
|
||||||
int idx = col * Rows + row;
|
int idx = col * RowsPerColumn + row;
|
||||||
// Only pick available channel items (special + greyed items are inert).
|
// Only pick enabled items.
|
||||||
if (row >= 0 && row < Rows && idx >= 0 && idx < Items.Length
|
if (row >= 0 && row < RowsPerColumn && idx >= 0 && idx < Items.Count
|
||||||
&& Items[idx].Channel is { } ch && IsAvailable(ch))
|
&& (EnabledProvider?.Invoke(Items[idx].Payload) ?? true))
|
||||||
{
|
{
|
||||||
Selected = ch;
|
Selected = Items[idx].Payload;
|
||||||
OnChannelChanged?.Invoke(ch);
|
OnSelect?.Invoke(Selected);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_open = false;
|
_open = false;
|
||||||
|
|
@ -41,7 +41,7 @@ public class ChatWindowControllerTests
|
||||||
/// transcript (Type-12, no media) [0x10000011] ← skipped by factory
|
/// transcript (Type-12, no media) [0x10000011] ← skipped by factory
|
||||||
/// track (Type-3) [0x10000012]
|
/// track (Type-3) [0x10000012]
|
||||||
/// inputBar (Type-3) [0x10000013]
|
/// inputBar (Type-3) [0x10000013]
|
||||||
/// menu (Type-3) [0x10000014]
|
/// menu (Type-6) [0x10000014]
|
||||||
/// input (Type-12, no media) [0x10000016] ← skipped by factory
|
/// input (Type-12, no media) [0x10000016] ← skipped by factory
|
||||||
/// send (Type-3) [0x10000019]
|
/// send (Type-3) [0x10000019]
|
||||||
/// maxmin (Type-3) [0x1000046F]
|
/// maxmin (Type-3) [0x1000046F]
|
||||||
|
|
@ -67,7 +67,7 @@ public class ChatWindowControllerTests
|
||||||
|
|
||||||
var menuNode = new ElementInfo
|
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
|
var inputNode = new ElementInfo
|
||||||
{
|
{
|
||||||
|
|
@ -180,8 +180,8 @@ public class ChatWindowControllerTests
|
||||||
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex);
|
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex);
|
||||||
|
|
||||||
Assert.NotNull(ctrl);
|
Assert.NotNull(ctrl);
|
||||||
// Switch channel to General.
|
// Switch channel to General via the generic OnSelect (payload is ChatChannelKind).
|
||||||
ctrl!.Menu.OnChannelChanged!.Invoke(ChatChannelKind.General);
|
ctrl!.Menu.OnSelect!.Invoke((object?)ChatChannelKind.General);
|
||||||
ctrl.Input.OnSubmit!.Invoke("hey all");
|
ctrl.Input.OnSubmit!.Invoke("hey all");
|
||||||
|
|
||||||
Assert.Single(bus.Published);
|
Assert.Single(bus.Published);
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,15 @@ public class DatWidgetFactoryTests
|
||||||
Assert.IsType<UiScrollbar>(e);
|
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) ───────────────────
|
// ── Test 6: Meter slice extraction (the important one) ───────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
170
tests/AcDream.App.Tests/UI/UiMenuTests.cs
Normal file
170
tests/AcDream.App.Tests/UI/UiMenuTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue