feat(D.2b): UiChannelMenu — channel selector popup (UIElement_Menu port)
Port of retail gmMainChatUI::InitTalkFocusMenu @0x4cdc50 + HandleSelection @0x4cd540 → SetTalkFocus. Button shows active channel label; click opens a 12-item popup that extends UPWARD (chat sits at screen bottom); selecting an entry calls OnChannelChanged and updates Selected. BitmapFont? Font uses the fully-qualified type name to match UiChatInput convention. Includes 6 xunit tests covering channel table shape, default selection, and popup-pick routing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bcc45d668e
commit
c2170ab18f
2 changed files with 185 additions and 0 deletions
109
src/AcDream.App/UI/UiChannelMenu.cs
Normal file
109
src/AcDream.App/UI/UiChannelMenu.cs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.UI.Abstractions;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Chat channel selector (the "Chat ▸" button). Port of retail
|
||||
/// <c>UIElement_Menu</c> as used by <c>gmMainChatUI::InitTalkFocusMenu @0x4cdc50</c>:
|
||||
/// a button whose label is the active channel; clicking opens a popup of channels;
|
||||
/// selecting one calls <c>SetTalkFocus</c> (here: <see cref="OnChannelChanged"/>).
|
||||
/// </summary>
|
||||
public sealed class UiChannelMenu : UiElement
|
||||
{
|
||||
public readonly record struct Item(string Label, ChatChannelKind Channel);
|
||||
|
||||
/// <summary>Retail talk-focus channels (subset acdream's ChatInputParser routes).</summary>
|
||||
public static readonly Item[] Channels =
|
||||
{
|
||||
new("Say", ChatChannelKind.Say),
|
||||
new("General", ChatChannelKind.General),
|
||||
new("Trade", ChatChannelKind.Trade),
|
||||
new("LFG", ChatChannelKind.Lfg),
|
||||
new("Fellowship", ChatChannelKind.Fellowship),
|
||||
new("Allegiance", ChatChannelKind.Allegiance),
|
||||
new("Patron", ChatChannelKind.Patron),
|
||||
new("Vassals", ChatChannelKind.Vassals),
|
||||
new("Monarch", ChatChannelKind.Monarch),
|
||||
new("Roleplay", ChatChannelKind.Roleplay),
|
||||
new("Society", ChatChannelKind.Society),
|
||||
new("Olthoi", ChatChannelKind.Olthoi),
|
||||
};
|
||||
|
||||
public ChatChannelKind Selected { get; private set; } = ChatChannelKind.Say;
|
||||
public Action<ChatChannelKind>? OnChannelChanged { 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; }
|
||||
public uint NormalSprite { get; set; }
|
||||
public uint PressedSprite { get; set; }
|
||||
public Vector4 TextColor { get; set; } = new(1f, 0.85f, 0.4f, 1f);
|
||||
|
||||
private bool _open;
|
||||
private const float ItemH = 16f;
|
||||
private const float PopupW = 90f;
|
||||
|
||||
public UiChannelMenu() { CapturesPointerDrag = true; }
|
||||
|
||||
private string Label => FindLabel(Selected);
|
||||
private static string FindLabel(ChatChannelKind k)
|
||||
{
|
||||
foreach (var it in Channels) if (it.Channel == k) return it.Label;
|
||||
return "Chat";
|
||||
}
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
if (SpriteResolve is { } resolve)
|
||||
{
|
||||
var (tex, _, _) = resolve(_open ? PressedSprite : NormalSprite);
|
||||
if (tex != 0) ctx.DrawSprite(tex, 0, 0, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
|
||||
}
|
||||
DrawLabel(ctx, Label + " >", 2f, (Height - LineH()) * 0.5f);
|
||||
|
||||
if (_open)
|
||||
{
|
||||
float h = Channels.Length * ItemH;
|
||||
float top = -h; // popup opens UPWARD (chat sits at screen bottom)
|
||||
ctx.DrawRect(0, top, MathF.Max(Width, PopupW), h, new(0f, 0f, 0f, 0.85f));
|
||||
for (int i = 0; i < Channels.Length; i++)
|
||||
DrawLabel(ctx, Channels[i].Label, 2f, top + i * ItemH);
|
||||
}
|
||||
}
|
||||
|
||||
private float LineH() => DatFont?.LineHeight ?? Font?.LineHeight ?? 14f;
|
||||
private void DrawLabel(UiRenderContext ctx, string s, float x, float y)
|
||||
{
|
||||
if (DatFont is { } df) ctx.DrawStringDat(df, s, x, y, TextColor);
|
||||
else ctx.DrawString(s, x, y, TextColor, Font);
|
||||
}
|
||||
|
||||
protected override bool OnHitTest(float lx, float ly)
|
||||
=> _open ? (lx >= 0 && lx < MathF.Max(Width, PopupW)
|
||||
&& ly >= -Channels.Length * ItemH && ly < Height)
|
||||
: base.OnHitTest(lx, ly);
|
||||
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
if (e.Type == UiEventType.MouseDown)
|
||||
{
|
||||
float ly = e.Data2;
|
||||
if (_open && ly < 0)
|
||||
{
|
||||
int idx = (int)((ly + Channels.Length * ItemH) / ItemH);
|
||||
if (idx >= 0 && idx < Channels.Length)
|
||||
{
|
||||
Selected = Channels[idx].Channel;
|
||||
OnChannelChanged?.Invoke(Selected);
|
||||
}
|
||||
_open = false;
|
||||
return true;
|
||||
}
|
||||
_open = !_open;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
76
tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs
Normal file
76
tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
using AcDream.App.UI;
|
||||
using AcDream.UI.Abstractions;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public class UiChannelMenuTests
|
||||
{
|
||||
[Fact]
|
||||
public void Channels_HasExpected12Entries()
|
||||
{
|
||||
Assert.Equal(12, UiChannelMenu.Channels.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Channels_FirstEntry_IsSay()
|
||||
{
|
||||
Assert.Equal(ChatChannelKind.Say, UiChannelMenu.Channels[0].Channel);
|
||||
Assert.Equal("Say", UiChannelMenu.Channels[0].Label);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Channels_LastEntry_IsOlthoi()
|
||||
{
|
||||
var last = UiChannelMenu.Channels[^1];
|
||||
Assert.Equal(ChatChannelKind.Olthoi, last.Channel);
|
||||
Assert.Equal("Olthoi", last.Label);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Channels_ContainsAllExpectedKinds()
|
||||
{
|
||||
var kinds = new HashSet<ChatChannelKind>(UiChannelMenu.Channels.Select(c => c.Channel));
|
||||
Assert.Contains(ChatChannelKind.Say, kinds);
|
||||
Assert.Contains(ChatChannelKind.General, kinds);
|
||||
Assert.Contains(ChatChannelKind.Trade, kinds);
|
||||
Assert.Contains(ChatChannelKind.Lfg, kinds);
|
||||
Assert.Contains(ChatChannelKind.Fellowship, kinds);
|
||||
Assert.Contains(ChatChannelKind.Allegiance, kinds);
|
||||
Assert.Contains(ChatChannelKind.Patron, kinds);
|
||||
Assert.Contains(ChatChannelKind.Vassals, kinds);
|
||||
Assert.Contains(ChatChannelKind.Monarch, kinds);
|
||||
Assert.Contains(ChatChannelKind.Roleplay, kinds);
|
||||
Assert.Contains(ChatChannelKind.Society, kinds);
|
||||
Assert.Contains(ChatChannelKind.Olthoi, kinds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultSelected_IsSay()
|
||||
{
|
||||
var menu = new UiChannelMenu();
|
||||
Assert.Equal(ChatChannelKind.Say, menu.Selected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnChannelChanged_FiredWhenSelectionMadeViaEvent()
|
||||
{
|
||||
var menu = new UiChannelMenu { Width = 80f, Height = 18f };
|
||||
|
||||
// Open the popup (click inside the button area — Data2 >= 0).
|
||||
var openEvt = new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5);
|
||||
Assert.True(menu.OnEvent(openEvt));
|
||||
|
||||
// Click on the second item (General) in the upward popup.
|
||||
// Popup renders UPWARD: top = -(12 * 16) = -192.
|
||||
// Item i=1 (General) occupies y in [-192 + 16, -192 + 32) = [-176, -160).
|
||||
// A click at ly = -176 + 8 = -168 hits item index = (int)((-168 + 192) / 16) = (int)(24/16) = 1.
|
||||
ChatChannelKind? fired = null;
|
||||
menu.OnChannelChanged = k => fired = k;
|
||||
|
||||
var selectEvt = new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -168);
|
||||
Assert.True(menu.OnEvent(selectEvt));
|
||||
|
||||
Assert.Equal(ChatChannelKind.General, fired);
|
||||
Assert.Equal(ChatChannelKind.General, menu.Selected);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue