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

@ -31,6 +31,7 @@ public sealed class ChatWindowController
// Element ids from chat LayoutDesc 0x21000006 (confirmed in Task D/G1).
private const uint RootId = 0x1000000Eu;
private const uint ResizeBarId = 0x1000000Fu; // dat top resize bar (800px — dropped; nine-slice grips replace it)
private const uint TranscriptPanelId = 0x10000010u;
private const uint TranscriptId = 0x10000011u; // Type-12 prototype — skipped by factory
private const uint TrackId = 0x10000012u;
@ -41,10 +42,12 @@ public sealed class ChatWindowController
private const uint MaxMinId = 0x1000046Fu;
// Scrollbar sprite ids from base layout 0x2100003E (confirmed in Task D).
private const uint TrackSprite = 0x06004C5Fu;
private const uint ThumbSprite = 0x06004C63u;
private const uint UpSprite = 0x06004C69u;
private const uint DownSprite = 0x06004C6Cu;
private const uint TrackSprite = 0x06004C5Fu;
private const uint ThumbSprite = 0x06004C63u; // 3-slice middle tile
private const uint ThumbTopSprite = 0x06004C60u; // 3-slice top cap
private const uint ThumbBotSprite = 0x06004C66u; // 3-slice bottom cap
private const uint UpSprite = 0x06004C69u;
private const uint DownSprite = 0x06004C6Cu;
// Channel menu sprite ids (confirmed in chat element dump).
private const uint MenuNormal = 0x06004D65u;
@ -130,7 +133,29 @@ public sealed class ChatWindowController
return null;
}
var c = new ChatWindowController { Root = layout.Root };
// LayoutDesc 0x21000006 has SEVERAL top-level elements: the gmMainChatUI window
// (RootId 0x1000000E) PLUS stray auxiliary elements that are NOT part of the docked
// window — a separate Field+ListBox (0x1000001C/1D, the floaty scrollback), the
// talk-focus highlight strip (0x1000001E), and a scroll-button prototype (0x10000526).
// LayoutImporter.ImportInfos wraps all top-level elements in a synthetic Type-3 root,
// so using layout.Root would render the strays overlapping the real window (the
// red-striped garbage in the first live render). Use the gmMainChatUI window itself:
// GameWindow adds this to the host, which re-parents it out of the synthetic wrapper,
// orphaning the strays so they never draw.
var window = layout.FindElement(RootId) ?? layout.Root;
var c = new ChatWindowController { Root = window };
// Drop the dat top resize bar (0x1000000F): it is authored 800px wide and
// juts out of the content-width window. The host wraps this content in the
// universal nine-slice chrome, whose grips provide the resize affordance.
if (layout.FindElement(ResizeBarId) is { Parent: { } rbParent } resizeBar)
rbParent.RemoveChild(resizeBar);
// Reclaim the 9px strip the dropped resize bar occupied (rows 0-8 of the root):
// grow the transcript panel up to the window top so its dark bg fills the strip.
// Otherwise the root element's brown bg shows through as a sliver along the top.
transcriptPanel.Top = 0f;
transcriptPanel.Height += 9f; // dat resize-bar height (0x1000000F H=9)
// ── Transcript ───────────────────────────────────────────────────
// Place the behavioral transcript widget inside the transcript panel at the
@ -144,7 +169,7 @@ public sealed class ChatWindowController
Anchors = ElementReader.ToAnchors(tInfo.Left, tInfo.Top, tInfo.Right, tInfo.Bottom),
DatFont = datFont,
Font = debugFont,
LinesProvider = () => BuildLines(vm),
LinesProvider = () => BuildLines(vm, c.Transcript, datFont, debugFont),
};
transcriptPanel.AddChild(c.Transcript);
@ -178,10 +203,12 @@ public sealed class ChatWindowController
Anchors = track.Anchors,
Model = c.Transcript.Scroll,
SpriteResolve = resolve,
TrackSprite = TrackSprite,
ThumbSprite = ThumbSprite,
UpSprite = UpSprite,
DownSprite = DownSprite,
TrackSprite = TrackSprite,
ThumbSprite = ThumbSprite,
ThumbTopSprite = ThumbTopSprite,
ThumbBotSprite = ThumbBotSprite,
UpSprite = UpSprite,
DownSprite = DownSprite,
};
trackParent.RemoveChild(track);
trackParent.AddChild(c.Scrollbar);
@ -220,6 +247,11 @@ public sealed class ChatWindowController
// ── Max/min toggle — simplified gmMainChatUI::HandleMaximizeButton ──
if (layout.FindElement(MaxMinId) is UiDatElement maxMinEl)
{
// The dat puts max/min and the scrollbar up-button at the SAME X (both
// right-anchored), so at content width they overlap. Retail shows max/min
// just LEFT of the scrollbar column — shift it one button-width left.
if (track is not null)
maxMinEl.Left = track.Left - maxMinEl.Width;
maxMinEl.ClickThrough = false;
maxMinEl.OnClick = c.ToggleMaximize;
}
@ -276,17 +308,66 @@ public sealed class ChatWindowController
/// <see cref="UiChatView.Line"/> record format, applying retail-faithful
/// per-<see cref="ChatKind"/> colors.
/// </summary>
private static IReadOnlyList<UiChatView.Line> BuildLines(ChatVM vm)
private static IReadOnlyList<UiChatView.Line> BuildLines(
ChatVM vm, UiChatView view, UiDatFont? datFont, BitmapFont? debugFont)
{
var detailed = vm.RecentLinesDetailed();
if (detailed.Count == 0) return Array.Empty<UiChatView.Line>();
var result = new UiChatView.Line[detailed.Count];
for (int i = 0; i < detailed.Count; i++)
result[i] = new UiChatView.Line(detailed[i].Text, RetailChatColor(detailed[i].Kind));
// Word-wrap each message to the transcript's current pixel width (ports retail
// GlyphList::Recalculate @0x473800 — break at word boundaries when the line would
// exceed wrapWidth). Re-evaluated each frame so wrapping follows window resize.
float maxW = view.Width - 2f * view.Padding;
Func<string, float> measure =
datFont is { } df ? s => df.MeasureWidth(s)
: debugFont is { } bf ? s => bf.MeasureWidth(s)
: s => s.Length * 7f;
var result = new List<UiChatView.Line>(detailed.Count);
foreach (var d in detailed)
{
var color = RetailChatColor(d.Kind);
foreach (var frag in WrapText(d.Text, maxW, measure))
result.Add(new UiChatView.Line(frag, color));
}
return result;
}
/// <summary>
/// Greedy word-wrap: split <paramref name="text"/> into fragments that each fit in
/// <paramref name="maxW"/> pixels (per <paramref name="measure"/>), breaking at spaces.
/// A single word longer than the width overflows its own line (retail does not
/// hyphenate chat). Mirrors retail GlyphList::Recalculate's per-GlyphLine emission.
/// </summary>
public static IEnumerable<string> WrapText(string text, float maxW, Func<string, float> measure)
{
if (string.IsNullOrEmpty(text) || maxW <= 0f || measure(text) <= maxW)
{
yield return text;
yield break;
}
var line = new System.Text.StringBuilder();
foreach (var word in text.Split(' '))
{
if (line.Length == 0)
{
line.Append(word);
}
else if (measure(line.ToString() + " " + word) > maxW)
{
yield return line.ToString();
line.Clear();
line.Append(word);
}
else
{
line.Append(' ').Append(word);
}
}
if (line.Length > 0) yield return line.ToString();
}
/// <summary>
/// Per-<see cref="ChatKind"/> text color matching retail AC's channel coloring
/// (observed from retail client screenshots and holtburger's chat.rs coloring).
@ -297,7 +378,7 @@ public sealed class ChatWindowController
ChatKind.RangedSpeech => new(1f, 0.95f, 0.8f, 1f), // warm white — shout
ChatKind.Channel => new(0.6f, 0.8f, 1f, 1f), // light blue — channel text
ChatKind.Tell => new(1f, 0.5f, 1f, 1f), // magenta — whisper
ChatKind.System => new(1f, 1f, 0.45f, 1f), // yellow — system messages
ChatKind.System => new(0f, 1f, 0f, 1f), // green — system messages (retail ChatMessageType 5; AC2D eGreen {0,255,0})
ChatKind.Popup => new(1f, 0.85f, 0.4f, 1f), // amber — popup/broadcast
ChatKind.Emote => new(0.8f, 0.8f, 0.7f, 1f), // off-white — emote
ChatKind.SoulEmote => new(0.8f, 0.8f, 0.7f, 1f), // off-white — soul emote