feat(D.2b): ChatWindowController — bind chat LayoutDesc, place widgets, route chat

Implements Task G2: binds the imported chat LayoutDesc (0x21000006) to live
behavior, the acdream analogue of retail ChatInterface + gmMainChatUI::PostInit.

- UiDatElement: add OnClick hook + OnEvent override so Send/max-min buttons
  can be wired by a controller without needing a dedicated widget type.
- ChatWindowController.Bind: reads transcript (0x10000011) and input
  (0x10000016) rects from the raw ElementInfo tree (factory skips them as
  Type-12/no-media), places UiChatView under the transcript panel and
  UiChatInput under the input bar; replaces the imported scrollbar track
  (0x10000012) with UiChatScrollbar driving UiChatView.Scroll; replaces
  the channel menu placeholder (0x10000014) with UiChannelMenu; wires
  Send button and max/min toggle via the new OnClick hook.
  ChatCommandRouter.Submit routes all input through the existing pipeline.
- 6 smoke tests: Bind returns non-null, Transcript is child of panel,
  Input is child of bar, Input.OnSubmit publishes SendChatCmd, channel
  change updates submit channel, returns null when panels missing.

Build: 0 errors. Test suite: 392 passed / 1 skipped / 0 failed.

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

View file

@ -0,0 +1,304 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.UI;
using AcDream.Core.Chat;
using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Panels.Chat;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Binds the imported chat LayoutDesc (0x21000006) to live behavior — the acdream
/// analogue of retail <c>ChatInterface</c> + <c>gmMainChatUI::PostInit @0x4ce130</c>.
///
/// <para>
/// The transcript (<c>0x10000011</c>) and input (<c>0x10000016</c>) are Type-0
/// elements whose base is a Type-12 prototype, so the importer factory skips them
/// (returns null). This controller reads their rects from the raw
/// <see cref="ElementInfo"/> tree (which contains everything) and adds the behavioral
/// widgets as children of their parent container widgets (transcript panel
/// <c>0x10000010</c> / input bar <c>0x10000013</c>) which ARE created as
/// <see cref="UiDatElement"/> nodes. The scrollbar track (<c>0x10000012</c>) and
/// channel menu (<c>0x10000014</c>) are created by the factory and are replaced
/// with their behavioral counterparts here.
/// </para>
/// </summary>
public sealed class ChatWindowController
{
public const uint LayoutId = 0x21000006u;
// Element ids from chat LayoutDesc 0x21000006 (confirmed in Task D/G1).
private const uint RootId = 0x1000000Eu;
private const uint TranscriptPanelId = 0x10000010u;
private const uint TranscriptId = 0x10000011u; // Type-12 prototype — skipped by factory
private const uint TrackId = 0x10000012u;
private const uint InputBarId = 0x10000013u;
private const uint MenuId = 0x10000014u;
private const uint InputId = 0x10000016u; // Type-12 prototype — skipped by factory
private const uint SendId = 0x10000019u;
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;
// Channel menu sprite ids (confirmed in chat element dump).
private const uint MenuNormal = 0x06004D65u;
private const uint MenuPressed = 0x06004D66u;
// ── Public surface ─────────────────────────────────────────────────────
/// <summary>Root element of the imported layout (the chat window chrome).</summary>
public UiElement Root { get; private set; } = null!;
/// <summary>Live chat transcript widget. Null until <see cref="Bind"/> succeeds.</summary>
public UiChatView Transcript { get; private set; } = null!;
/// <summary>Editable chat input widget. Null until <see cref="Bind"/> succeeds.</summary>
public UiChatInput Input { get; private set; } = null!;
/// <summary>Scrollbar widget, driven by <see cref="Transcript"/>'s scroll model.</summary>
public UiChatScrollbar Scrollbar { get; private set; } = null!;
/// <summary>Channel-selector menu widget.</summary>
public UiChannelMenu Menu { get; private set; } = null!;
// ── Private state ──────────────────────────────────────────────────────
private ChatChannelKind _activeChannel = ChatChannelKind.Say;
/// <summary>Window height before maximize (stored to restore on un-maximize).</summary>
private float _normalHeight;
/// <summary>Window top before maximize.</summary>
private float _normalTop;
private bool _maximized;
// ── Factory ────────────────────────────────────────────────────────────
/// <summary>
/// Bind an imported chat layout to live behavior.
///
/// <paramref name="rootInfo"/> and <paramref name="layout"/> must come from the
/// SAME <see cref="LayoutImporter"/> pass (<c>ImportInfos</c> then <c>Build</c>)
/// so rects in the info tree match the widget geometry in the layout tree.
///
/// Returns <c>null</c> if the essential transcript/input panels are missing from
/// the info tree or the widget tree (e.g. the layout dat is incomplete).
/// </summary>
/// <param name="rootInfo">Full <see cref="ElementInfo"/> tree from
/// <see cref="LayoutImporter.ImportInfos"/>.</param>
/// <param name="layout">Widget tree from <see cref="LayoutImporter.Build"/>.</param>
/// <param name="vm">Chat view-model (transcript data + command routing).</param>
/// <param name="bus">Command bus for <c>SendChatCmd</c> publishes.</param>
/// <param name="datFont">Retail dat font for transcript + input rendering.</param>
/// <param name="debugFont">Fallback debug bitmap font (used when
/// <paramref name="datFont"/> is null).</param>
/// <param name="resolve">Dat RenderSurface id → (GL tex handle, px width, px height).
/// Forwarded to <see cref="UiChatScrollbar"/> and <see cref="UiChannelMenu"/>.</param>
public static ChatWindowController? Bind(
ElementInfo rootInfo,
ImportedLayout layout,
ChatVM vm,
ICommandBus bus,
UiDatFont? datFont,
BitmapFont? debugFont,
Func<uint, (uint tex, int w, int h)> resolve)
{
// The transcript + input nodes are Type-12 based and were skipped by the factory.
// Find them in the raw ElementInfo tree to read their rects.
var tInfo = FindInfo(rootInfo, TranscriptId);
var iInfo = FindInfo(rootInfo, InputId);
// Their parent panels must exist as real widgets in the layout tree.
var transcriptPanel = layout.FindElement(TranscriptPanelId);
var inputBar = layout.FindElement(InputBarId);
if (tInfo is null || iInfo is null || transcriptPanel is null || inputBar is null)
{
Console.WriteLine(
$"[D.2b] ChatWindowController.Bind: missing required elements " +
$"(tInfo={tInfo is not null}, iInfo={iInfo is not null}, " +
$"panel={transcriptPanel is not null}, bar={inputBar is not null}) — " +
$"chat window will not be interactive.");
return null;
}
var c = new ChatWindowController { Root = layout.Root };
// ── Transcript ───────────────────────────────────────────────────
// Place the behavioral transcript widget inside the transcript panel at the
// dat-rect of the (skipped) Type-12 transcript element.
c.Transcript = new UiChatView
{
Left = tInfo.X,
Top = tInfo.Y,
Width = tInfo.Width,
Height = tInfo.Height,
Anchors = ElementReader.ToAnchors(tInfo.Left, tInfo.Top, tInfo.Right, tInfo.Bottom),
DatFont = datFont,
Font = debugFont,
LinesProvider = () => BuildLines(vm),
};
transcriptPanel.AddChild(c.Transcript);
// ── Input ────────────────────────────────────────────────────────
// Place the behavioral input widget inside the input bar.
c.Input = new UiChatInput
{
Left = iInfo.X,
Top = iInfo.Y,
Width = iInfo.Width,
Height = iInfo.Height,
Anchors = ElementReader.ToAnchors(iInfo.Left, iInfo.Top, iInfo.Right, iInfo.Bottom),
DatFont = datFont,
Font = debugFont,
};
inputBar.AddChild(c.Input);
c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, bus, c._activeChannel);
// ── Scrollbar — replace the imported track placeholder ────────────
// The factory created a UiDatElement for the track. Remove it and place a
// behavioral UiChatScrollbar at the same position, driving the transcript's scroll.
var track = layout.FindElement(TrackId);
if (track?.Parent is { } trackParent)
{
c.Scrollbar = new UiChatScrollbar
{
Left = track.Left,
Top = track.Top,
Width = track.Width,
Height = track.Height,
Anchors = track.Anchors,
Model = c.Transcript.Scroll,
SpriteResolve = resolve,
TrackSprite = TrackSprite,
ThumbSprite = ThumbSprite,
UpSprite = UpSprite,
DownSprite = DownSprite,
};
trackParent.RemoveChild(track);
trackParent.AddChild(c.Scrollbar);
}
// ── Channel menu — replace the imported menu placeholder ──────────
var menuEl = layout.FindElement(MenuId);
if (menuEl?.Parent is { } menuParent)
{
c.Menu = new UiChannelMenu
{
Left = menuEl.Left,
Top = menuEl.Top,
Width = menuEl.Width,
Height = menuEl.Height,
Anchors = menuEl.Anchors,
DatFont = datFont,
Font = debugFont,
SpriteResolve = resolve,
NormalSprite = MenuNormal,
PressedSprite = MenuPressed,
};
c.Menu.OnChannelChanged = k => c._activeChannel = k;
menuParent.RemoveChild(menuEl);
menuParent.AddChild(c.Menu);
}
// ── Send button — Enter-alternate submit trigger ──────────────────
// Retail's gmMainChatUI wires the Send button to the same ProcessCommand path.
if (layout.FindElement(SendId) is UiDatElement sendEl)
{
sendEl.ClickThrough = false;
sendEl.OnClick = () => c.Input.Submit();
}
// ── Max/min toggle — simplified gmMainChatUI::HandleMaximizeButton ──
if (layout.FindElement(MaxMinId) is UiDatElement maxMinEl)
{
maxMinEl.ClickThrough = false;
maxMinEl.OnClick = c.ToggleMaximize;
}
return c;
}
// ── Max/min implementation ─────────────────────────────────────────────
/// <summary>
/// Toggle between the normal chat window height and an expanded 320px height.
/// Simplified port of retail <c>gmMainChatUI::HandleMaximizeButton @0x4cddb0</c>:
/// retail stores the pre-maximize height and restores it on a second click.
/// The 320px expanded size is the approximate retail maximized chat height.
/// </summary>
private void ToggleMaximize()
{
if (!_maximized)
{
_normalHeight = Root.Height;
_normalTop = Root.Top;
// Expand upward: move the top edge up so the bottom stays anchored.
Root.Top = MathF.Max(0f, Root.Top + Root.Height - 320f);
Root.Height = 320f;
_maximized = true;
}
else
{
Root.Top = _normalTop;
Root.Height = _normalHeight;
_maximized = false;
}
}
// ── Helpers ────────────────────────────────────────────────────────────
/// <summary>
/// Depth-first search for an <see cref="ElementInfo"/> node by id in the
/// raw info tree (which contains ALL elements, including the Type-12 skipped ones).
/// </summary>
private static ElementInfo? FindInfo(ElementInfo node, uint id)
{
if (node.Id == id) return node;
foreach (var child in node.Children)
{
var found = FindInfo(child, id);
if (found is not null) return found;
}
return null;
}
/// <summary>
/// Convert the ChatVM's detailed lines to the transcript's
/// <see cref="UiChatView.Line"/> record format, applying retail-faithful
/// per-<see cref="ChatKind"/> colors.
/// </summary>
private static IReadOnlyList<UiChatView.Line> BuildLines(ChatVM vm)
{
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));
return result;
}
/// <summary>
/// Per-<see cref="ChatKind"/> text color matching retail AC's channel coloring
/// (observed from retail client screenshots and holtburger's chat.rs coloring).
/// </summary>
private static Vector4 RetailChatColor(ChatKind kind) => kind switch
{
ChatKind.LocalSpeech => new(1f, 1f, 1f, 1f), // white — spoken nearby
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.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
ChatKind.Combat => new(1f, 0.6f, 0.25f, 1f), // orange — combat feedback
_ => new(0.9f, 0.9f, 0.9f, 1f), // light grey — fallback
};
}

View file

@ -76,6 +76,17 @@ public sealed class UiDatElement : UiElement
: _info.StateMedia.TryGetValue("", out var d) ? d
: (0u, 0);
/// <summary>Optional click handler. Set by a controller for interactive dat
/// elements (e.g. the chat Send / max-min buttons). Requires
/// <see cref="UiElement.ClickThrough"/> = false to receive click events.</summary>
public Action? OnClick { get; set; }
public override bool OnEvent(in UiEvent e)
{
if (e.Type == UiEventType.Click && OnClick is not null) { OnClick(); return true; }
return false;
}
protected override void OnDraw(UiRenderContext ctx)
{
var (file, _) = ActiveMedia();