feat(D.2b): cut GameWindow over to the data-driven chat window

Replace the hand-authored chat block (UiNineSlicePanel + inline UiChatView
+ local BuildRetailChatLines/RetailChatColor statics) with
ChatWindowController.Bind(LayoutDesc 0x21000006) — the same LayoutImporter
path as the vitals window.  The controller places UiChatView (transcript) +
UiChatInput (text entry, on-submit) + UiChatScrollbar + UiChannelMenu inside
the dat-authored chrome.  The dead local statics are deleted.

Wired to _commandBus (same LiveCommandBus as the ImGui ChatPanel) so
type+Enter dispatches SendChatCmd server-ward.  Transcript keyboard set from
_uiHost.Keyboard (set by WireKeyboard above the chat block) for Ctrl+C/Ctrl+A.

Divergence register: added AD-28 (two-widget split vs UIElement_Text),
AP-38 (no in-element word-wrap), AP-39 (per-line colour vs per-glyph runs),
AP-40 (no opacity fade / shared vitalsDatFont), TS-30 (tab buttons no-op),
TS-31 (no squelch); updated IA-15 to cover both vitals + chat importer paths.

Build: 0 errors/warnings.  Tests: 392 passed, 1 skipped (expected).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-15 23:15:04 +02:00
parent 9d9e036e4c
commit 12ab9663d2
3 changed files with 48 additions and 52 deletions

View file

@ -1833,58 +1833,47 @@ public sealed class GameWindow : IDisposable
Console.WriteLine("[D.2b] vitals: LayoutDesc 0x2100006C not found — vitals unavailable.");
}
// Retail chat window — a draggable/resizable nine-slice frame hosting a
// scrollable transcript (UiChatView). Read-only + wheel-scroll for now;
// drag-select + Ctrl+C copy land in the next D.2b sub-step. A dedicated
// ChatVM with a deeper tail (200) feeds the scrollback; it shares the
// same live ChatLog (Chat) as the ImGui panel.
// Retail chat window — data-driven from LayoutDesc 0x21000006 (gmMainChatUI),
// the same importer path as vitals. ChatWindowController binds the transcript,
// input, scrollbar and channel menu and routes through ChatVM + ChatCommandRouter.
var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200);
var chatWindow = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome)
AcDream.App.UI.Layout.ElementInfo? chatRootInfo;
AcDream.App.UI.Layout.ImportedLayout? chatLayout;
lock (_datLock)
{
Left = 10, Top = 432, Width = 440, Height = 184,
MinWidth = 180, MinHeight = 80,
};
var chatView = new AcDream.App.UI.UiChatView
{
Left = 8, Top = 8, Width = 424, Height = 168,
Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top
| AcDream.App.UI.AnchorEdges.Right | AcDream.App.UI.AnchorEdges.Bottom,
Font = _debugFont,
LinesProvider = () => BuildRetailChatLines(retailChatVm),
// Drag-select + Ctrl+C copy need the keyboard for clipboard +
// modifier state. UiHost.Keyboard is set during WireKeyboard above.
Keyboard = _uiHost.Keyboard,
};
chatWindow.AddChild(chatView);
_uiHost.Root.AddChild(chatWindow);
// Map the VM's formatted tail into coloured view lines. Per-ChatKind
// palette (retail-ish): speech white, tells magenta, channels blue,
// system yellow, emotes grey, combat orange. Refined later if needed.
static System.Collections.Generic.IReadOnlyList<AcDream.App.UI.UiChatView.Line> BuildRetailChatLines(
AcDream.UI.Abstractions.Panels.Chat.ChatVM vm)
{
var detailed = vm.RecentLinesDetailed();
var result = new AcDream.App.UI.UiChatView.Line[detailed.Count];
for (int i = 0; i < detailed.Count; i++)
result[i] = new AcDream.App.UI.UiChatView.Line(
detailed[i].Text, RetailChatColor(detailed[i].Kind));
return result;
chatRootInfo = AcDream.App.UI.Layout.LayoutImporter.ImportInfos(
_dats!, AcDream.App.UI.Layout.ChatWindowController.LayoutId);
chatLayout = chatRootInfo is null ? null
: AcDream.App.UI.Layout.LayoutImporter.Build(chatRootInfo, ResolveChrome, vitalsDatFont);
}
static System.Numerics.Vector4 RetailChatColor(AcDream.Core.Chat.ChatKind kind) => kind switch
if (chatRootInfo is not null && chatLayout is not null)
{
AcDream.Core.Chat.ChatKind.LocalSpeech => new(1f, 1f, 1f, 1f),
AcDream.Core.Chat.ChatKind.RangedSpeech => new(1f, 0.95f, 0.8f, 1f),
AcDream.Core.Chat.ChatKind.Channel => new(0.6f, 0.8f, 1f, 1f),
AcDream.Core.Chat.ChatKind.Tell => new(1f, 0.5f, 1f, 1f),
AcDream.Core.Chat.ChatKind.System => new(1f, 1f, 0.45f, 1f),
AcDream.Core.Chat.ChatKind.Popup => new(1f, 0.85f, 0.4f, 1f),
AcDream.Core.Chat.ChatKind.Emote => new(0.8f, 0.8f, 0.7f, 1f),
AcDream.Core.Chat.ChatKind.SoulEmote => new(0.8f, 0.8f, 0.7f, 1f),
AcDream.Core.Chat.ChatKind.Combat => new(1f, 0.6f, 0.25f, 1f),
_ => new(0.9f, 0.9f, 0.9f, 1f),
};
var chatController = AcDream.App.UI.Layout.ChatWindowController.Bind(
chatRootInfo, chatLayout, retailChatVm, _commandBus ?? (AcDream.UI.Abstractions.ICommandBus)AcDream.UI.Abstractions.NullCommandBus.Instance,
vitalsDatFont, _debugFont, ResolveChrome);
if (chatController is not null)
{
// Ctrl+C / Ctrl+A on the transcript need the keyboard for clipboard + modifiers.
// _uiHost.Keyboard is set by WireKeyboard above — it is non-null here.
chatController.Transcript.Keyboard = _uiHost.Keyboard;
// Top-level retail window: user-positioned at the bottom-left, movable + resizable.
// KEEP the dat-authored size (do NOT override Width/Height) so the child anchors
// capture their dat margins on the first layout — the same reason the vitals root
// keeps its dat size. The user resizes/moves from there.
var chatRoot = chatController.Root;
chatRoot.Left = 10;
chatRoot.Top = 460; // bottom-left default; pending the user's visual review
chatRoot.Anchors = AcDream.App.UI.AnchorEdges.None;
chatRoot.Draggable = true;
chatRoot.Resizable = true;
chatRoot.MinWidth = 200f;
chatRoot.MinHeight = 80f;
_uiHost.Root.AddChild(chatRoot);
Console.WriteLine("[D.2b] retail chat window from LayoutDesc importer (0x21000006).");
}
else Console.WriteLine("[D.2b] chat: required role elements missing in 0x21000006.");
}
else Console.WriteLine("[D.2b] chat: LayoutDesc 0x21000006 not found.");
// Drain plugin-registered markup panels (buffered before the GL
// window opened) into the same UiRoot tree. A faulty plugin markup