diff --git a/src/AcDream.App/UI/UiChannelMenu.cs b/src/AcDream.App/UI/UiChannelMenu.cs new file mode 100644 index 00000000..9726eb08 --- /dev/null +++ b/src/AcDream.App/UI/UiChannelMenu.cs @@ -0,0 +1,109 @@ +using System; +using System.Numerics; +using AcDream.UI.Abstractions; + +namespace AcDream.App.UI; + +/// +/// Chat channel selector (the "Chat ▸" button). Port of retail +/// UIElement_Menu as used by gmMainChatUI::InitTalkFocusMenu @0x4cdc50: +/// a button whose label is the active channel; clicking opens a popup of channels; +/// selecting one calls SetTalkFocus (here: ). +/// +public sealed class UiChannelMenu : UiElement +{ + public readonly record struct Item(string Label, ChatChannelKind Channel); + + /// Retail talk-focus channels (subset acdream's ChatInputParser routes). + 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? OnChannelChanged { get; set; } + + public UiDatFont? DatFont { get; set; } + public AcDream.App.Rendering.BitmapFont? Font { get; set; } + public Func? 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; + } +} diff --git a/tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs b/tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs new file mode 100644 index 00000000..c9f7b73b --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs @@ -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(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); + } +}