feat(D.2b): chat polish — typing fix, opacity, scrollbar 3-slice, retail channel menu

Visual-iteration batch (decomp-grounded), each fix verified against the retail screenshots:
- typing: UiElement.HitTest aborted on ClickThrough BEFORE walking children, so the
  ClickThrough UiDatElement panels blocked hit-testing to the input/transcript inside
  them. Check ClickThrough AFTER the child walk (it only gates whether THIS element
  claims the hit). Restores input focus + typing.
- opacity: UiElement.Opacity + a UiRenderContext alpha stack applied to sprite/rect
  draws (text bypasses it, stays sharp); chat frame Opacity=0.75 → translucent chat.
- brown sliver: grow the transcript panel up 9px to cover the dropped resize-bar strip.
- scrollbar: real 3-slice thumb (caps 0x06004C60/66 + tiled mid) + tiled track.
- max/min: shifted one button-width left of the scrollbar (dat right-anchors collide).
- system text now green (retail ChatMessageType 5; was yellow).
- word-wrap: transcript lines wrap to the panel width (greedy, ports GlyphList::Recalculate).
- channel menu reworked to retail gmMainChatUI::InitTalkFocusMenu: "Chat" button + a
  TWO-COLUMN popup of the 14 talk-focus items (Squelch, Tell to Selected, Chat to All,
  Tell to Fellows, ...) on a tan panel; channel items set the active outbound channel.

Build + 392 App tests green. Visual confirmation in progress.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
This commit is contained in:
Erik 2026-06-16 09:37:40 +02:00
parent 0ec36f6197
commit 1da697ec2a
7 changed files with 329 additions and 126 deletions

View file

@ -1,3 +1,4 @@
using System.Linq;
using AcDream.App.UI;
using AcDream.UI.Abstractions;
@ -6,42 +7,40 @@ namespace AcDream.App.Tests.UI;
public class UiChannelMenuTests
{
[Fact]
public void Channels_HasExpected12Entries()
public void Items_HasExpected14Entries()
{
Assert.Equal(12, UiChannelMenu.Channels.Length);
// Retail gmMainChatUI::InitTalkFocusMenu: squelch + tell-selected + 12 channels.
Assert.Equal(14, UiChannelMenu.Items.Length);
}
[Fact]
public void Channels_FirstEntry_IsSay()
public void Items_FirstEntry_IsSquelch_Special()
{
Assert.Equal(ChatChannelKind.Say, UiChannelMenu.Channels[0].Channel);
Assert.Equal("Say", UiChannelMenu.Channels[0].Label);
Assert.Equal("Squelch (ignore)", UiChannelMenu.Items[0].Label);
Assert.Null(UiChannelMenu.Items[0].Channel); // special item, no channel
}
[Fact]
public void Channels_LastEntry_IsOlthoi()
public void Items_LastEntry_IsOlthoi()
{
var last = UiChannelMenu.Channels[^1];
var last = UiChannelMenu.Items[^1];
Assert.Equal("Tell to Olthoi Chat", last.Label);
Assert.Equal(ChatChannelKind.Olthoi, last.Channel);
Assert.Equal("Olthoi", last.Label);
}
[Fact]
public void Channels_ContainsAllExpectedKinds()
public void Items_ContainAll12ChannelKinds()
{
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);
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]
@ -52,25 +51,50 @@ public class UiChannelMenuTests
}
[Fact]
public void OnChannelChanged_FiredWhenSelectionMadeViaEvent()
public void Select_LeftColumnItem_FiresChannel()
{
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);
var openEvt = new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5); // open
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));
// PopupH = 7*16 = 112, top = -112. "Chat to All" (Say) is index 2 = left col, row 2:
// y in [-112+32, -112+48) = [-80,-64). Click (lx=10 < ColW, ly=-72) → idx 2 → Say.
var selEvt = new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -72);
Assert.True(menu.OnEvent(selEvt));
Assert.Equal(ChatChannelKind.Say, fired);
Assert.Equal(ChatChannelKind.Say, menu.Selected);
}
Assert.Equal(ChatChannelKind.General, fired);
Assert.Equal(ChatChannelKind.General, menu.Selected);
[Fact]
public void Select_RightColumnItem_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 Monarch" is index 7 = right col (lx >= ColW 150), row 0:
// y in [-112, -96). Click (lx=160, ly=-104) → col 1, row 0 → idx 7 → Monarch.
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 160, -104)));
Assert.Equal(ChatChannelKind.Monarch, fired);
Assert.Equal(ChatChannelKind.Monarch, 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: y in [-112, -96). No channel.
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -104)));
Assert.Equal(0, fired); // special item is a no-op
}
}