diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs
index 02fc4eb1..e1392ee0 100644
--- a/src/AcDream.App/UI/Layout/ChatWindowController.cs
+++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs
@@ -137,6 +137,21 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
// ── Channel knowledge (ported from old UiChannelMenu — gmMainChatUI::InitTalkFocusMenu @0x4cdc50) ──
+ ///
+ /// The two non-channel entries retail puts at the head of the talk-focus
+ /// menu. Both act on the CURRENT SELECTION and carry its name in their
+ /// label — retail substitutes it via StringInfo::AddVariable_String
+ /// (gmMainChatUI @0x004CD91C / @0x004CD982), which is why the screenshot
+ /// reads "Tell to +Acdream" rather than "Tell to Selected".
+ ///
+ private enum TalkFocusSpecial
+ {
+ Squelch,
+ TellToSelected,
+ }
+
+ private string? _tellTarget;
+
private static readonly (string Label, ChatChannelKind? Channel)[] ChannelItems =
{
("Squelch (ignore)", null),
@@ -225,7 +240,8 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
ChatWindowState windowFilters,
UiDatFont? datFont,
BitmapFont? debugFont,
- Func resolve)
+ Func resolve,
+ Func? selectedTargetName = null)
{
ArgumentNullException.ThrowIfNull(windowFilters);
@@ -319,7 +335,8 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
// its own authored background sprite (0x0600113A); same reasoning as the
// transcript above.
c.Input.SpriteResolve = resolve;
- c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel);
+ c.Input.OnSubmit = text => ChatCommandRouter.Submit(
+ text, vm, busProvider(), c._activeChannel, c._tellTarget);
// Campaign CH user-gate round 1 (item G): the imported field's right
// edge otherwise holds a FIXED absolute pixel position across a
@@ -373,19 +390,78 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
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));
+ string? SelectedName() => selectedTargetName?.Invoke();
+
+ // Retail rebuilds these two labels around the selected name every
+ // time the menu opens; ours does the same from the live provider.
+ void RebuildItems()
+ {
+ string? target = SelectedName();
+ var items = new List(ChannelItems.Length)
+ {
+ new(target is null
+ ? "Squelch (ignore)"
+ : $"Squelch (ignore) {target}",
+ TalkFocusSpecial.Squelch),
+ new(target is null
+ ? "Tell to Selected"
+ : $"Tell to {target}",
+ TalkFocusSpecial.TellToSelected),
+ };
+ foreach ((string label, ChatChannelKind? channel) in ChannelItems)
+ {
+ if (channel is { } ch)
+ items.Add(new UiMenu.MenuItem(label, ch));
+ }
+ menu.Items = items.ToArray();
+ }
+
+ RebuildItems();
menu.Selected = (object?)c._activeChannel;
- // Specials (Squelch / Tell-to-Selected, null payload) render WHITE/enabled like
- // retail; only the talk-CHANNEL items grey when unavailable.
- menu.EnabledProvider = p => p is not ChatChannelKind ch || ChannelAvailable(ch);
- menu.ButtonLabelProvider = () => ChannelButtonLabel(c._activeChannel);
- // The widget reports the pick; the controller owns Selected. Only a talk-channel
- // payload updates the active channel + highlight — the null-payload specials are
- // deferred no-ops (see the chat re-drive deferred list) and leave selection intact.
+ // Talk-CHANNEL items grey when the channel is unavailable; the two
+ // specials grey when nothing is selected to act on — retail's
+ // SetTalkFocusEnabled(2, 1) @0x004CD9B0 arms the tell slot only
+ // once a talkable object is selected.
+ menu.EnabledProvider = p => p switch
+ {
+ ChatChannelKind ch => ChannelAvailable(ch),
+ TalkFocusSpecial => SelectedName() is not null,
+ _ => true,
+ };
+ menu.ButtonLabelProvider = () =>
+ c._activeChannel == ChatChannelKind.Tell && c._tellTarget is { } t
+ ? t
+ : ChannelButtonLabel(c._activeChannel);
+ menu.OnOpen = RebuildItems;
menu.OnSelect = p =>
{
- if (p is ChatChannelKind ch) { c._activeChannel = ch; menu.Selected = p; }
+ switch (p)
+ {
+ case ChatChannelKind ch:
+ c._activeChannel = ch;
+ c._tellTarget = null;
+ menu.Selected = p;
+ break;
+
+ // Aims the chat bar at the selected player, so ordinary
+ // typed text goes to them as a tell.
+ case TalkFocusSpecial.TellToSelected when SelectedName() is { } name:
+ c._activeChannel = ChatChannelKind.Tell;
+ c._tellTarget = name;
+ menu.Selected = p;
+ break;
+
+ // The /squelch verb is already registered and carries the
+ // ModifyCharacterSquelch request
+ // (CM_Communication::Event_ModifyCharacterSquelch
+ // @0x006A42D0); the menu entry is another way to reach it,
+ // not a second implementation. Selection is left alone.
+ case TalkFocusSpecial.Squelch when SelectedName() is { } squelched:
+ busProvider().Publish(
+ new ExecuteClientCommandCmd(
+ ClientCommandId.Squelch, squelched));
+ break;
+ }
};
c.Menu = menu;
}
diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs
index 9e7e9db9..faad50d6 100644
--- a/src/AcDream.App/UI/RetailUiRuntime.cs
+++ b/src/AcDream.App/UI/RetailUiRuntime.cs
@@ -1525,7 +1525,17 @@ public sealed class RetailUiRuntime : IDisposable
_bindings.Chat.Windows,
_bindings.Assets.DefaultFont,
_bindings.Assets.DebugFont,
- _bindings.Assets.ResolveSprite);
+ _bindings.Assets.ResolveSprite,
+ // The talk-focus menu's "Tell to X" / "Squelch (ignore) X" act on
+ // the current selection and carry its name.
+ selectedTargetName: () =>
+ {
+ uint selected = _bindings.Toolbar.Selection.SelectedObjectId ?? 0u;
+ if (selected == 0u)
+ return null;
+ string? name = _bindings.Toolbar.ResolveName(selected);
+ return string.IsNullOrWhiteSpace(name) ? null : name;
+ });
if (controller is null)
{
Console.WriteLine("[D.2b] chat: required role elements missing in 0x2100006F.");
diff --git a/src/AcDream.App/UI/UiMenu.cs b/src/AcDream.App/UI/UiMenu.cs
index 9de157ef..7b087934 100644
--- a/src/AcDream.App/UI/UiMenu.cs
+++ b/src/AcDream.App/UI/UiMenu.cs
@@ -28,6 +28,17 @@ public sealed class UiMenu : UiElement
/// Fired with the picked item's payload when a row is chosen.
public Action