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);
+ }
+}