diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs new file mode 100644 index 00000000..edc4c3f3 --- /dev/null +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -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; + +/// +/// Binds the imported chat LayoutDesc (0x21000006) to live behavior — the acdream +/// analogue of retail ChatInterface + gmMainChatUI::PostInit @0x4ce130. +/// +/// +/// The transcript (0x10000011) and input (0x10000016) 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 +/// tree (which contains everything) and adds the behavioral +/// widgets as children of their parent container widgets (transcript panel +/// 0x10000010 / input bar 0x10000013) which ARE created as +/// nodes. The scrollbar track (0x10000012) and +/// channel menu (0x10000014) are created by the factory and are replaced +/// with their behavioral counterparts here. +/// +/// +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 ───────────────────────────────────────────────────── + + /// Root element of the imported layout (the chat window chrome). + public UiElement Root { get; private set; } = null!; + + /// Live chat transcript widget. Null until succeeds. + public UiChatView Transcript { get; private set; } = null!; + + /// Editable chat input widget. Null until succeeds. + public UiChatInput Input { get; private set; } = null!; + + /// Scrollbar widget, driven by 's scroll model. + public UiChatScrollbar Scrollbar { get; private set; } = null!; + + /// Channel-selector menu widget. + public UiChannelMenu Menu { get; private set; } = null!; + + // ── Private state ────────────────────────────────────────────────────── + + private ChatChannelKind _activeChannel = ChatChannelKind.Say; + + /// Window height before maximize (stored to restore on un-maximize). + private float _normalHeight; + /// Window top before maximize. + private float _normalTop; + private bool _maximized; + + // ── Factory ──────────────────────────────────────────────────────────── + + /// + /// Bind an imported chat layout to live behavior. + /// + /// and must come from the + /// SAME pass (ImportInfos then Build) + /// so rects in the info tree match the widget geometry in the layout tree. + /// + /// Returns null if the essential transcript/input panels are missing from + /// the info tree or the widget tree (e.g. the layout dat is incomplete). + /// + /// Full tree from + /// . + /// Widget tree from . + /// Chat view-model (transcript data + command routing). + /// Command bus for SendChatCmd publishes. + /// Retail dat font for transcript + input rendering. + /// Fallback debug bitmap font (used when + /// is null). + /// Dat RenderSurface id → (GL tex handle, px width, px height). + /// Forwarded to and . + public static ChatWindowController? Bind( + ElementInfo rootInfo, + ImportedLayout layout, + ChatVM vm, + ICommandBus bus, + UiDatFont? datFont, + BitmapFont? debugFont, + Func 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 ───────────────────────────────────────────── + + /// + /// Toggle between the normal chat window height and an expanded 320px height. + /// Simplified port of retail gmMainChatUI::HandleMaximizeButton @0x4cddb0: + /// retail stores the pre-maximize height and restores it on a second click. + /// The 320px expanded size is the approximate retail maximized chat height. + /// + 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 ──────────────────────────────────────────────────────────── + + /// + /// Depth-first search for an node by id in the + /// raw info tree (which contains ALL elements, including the Type-12 skipped ones). + /// + 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; + } + + /// + /// Convert the ChatVM's detailed lines to the transcript's + /// record format, applying retail-faithful + /// per- colors. + /// + private static IReadOnlyList BuildLines(ChatVM vm) + { + var detailed = vm.RecentLinesDetailed(); + if (detailed.Count == 0) return Array.Empty(); + + 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; + } + + /// + /// Per- text color matching retail AC's channel coloring + /// (observed from retail client screenshots and holtburger's chat.rs coloring). + /// + 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 + }; +} diff --git a/src/AcDream.App/UI/Layout/UiDatElement.cs b/src/AcDream.App/UI/Layout/UiDatElement.cs index 61f7c6b3..43cc4032 100644 --- a/src/AcDream.App/UI/Layout/UiDatElement.cs +++ b/src/AcDream.App/UI/Layout/UiDatElement.cs @@ -76,6 +76,17 @@ public sealed class UiDatElement : UiElement : _info.StateMedia.TryGetValue("", out var d) ? d : (0u, 0); + /// Optional click handler. Set by a controller for interactive dat + /// elements (e.g. the chat Send / max-min buttons). Requires + /// = false to receive click events. + 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(); diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs new file mode 100644 index 00000000..c4c6b9b1 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs @@ -0,0 +1,209 @@ +using System.Collections.Generic; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.Core.Chat; +using AcDream.UI.Abstractions; +using AcDream.UI.Abstractions.Panels.Chat; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Smoke tests for — no dats, no GL. +/// +/// Building the Type-12 "skipped" elements via the pure +/// path is the correct approach: we build a synthetic info tree that reflects the +/// real chat layout hierarchy (root → transcript panel + input bar as Type-3 +/// containers, with Type-12 children for transcript + input, plus a Type-3 track +/// and menu), call to get the widget tree +/// (Type-12 children skipped, Type-3 parents created), then call +/// which reads rects from the info tree +/// and places behavioral widgets under the parent containers. +/// +public class ChatWindowControllerTests +{ + // ── Null-resolve helper (no GL needed) ───────────────────────────────── + private static (uint, int, int) NoTex(uint _) => (0u, 0, 0); + + // ── Capture bus — records every Publish call ──────────────────────────── + private sealed class CaptureBus : ICommandBus + { + public readonly List Published = new(); + public void Publish(T cmd) where T : notnull => Published.Add(cmd!); + } + + // ── Synthetic element tree matching the real chat layout topology ──────── + + /// + /// Build a minimal synthetic ElementInfo tree that mirrors the real chat + /// layout (0x21000006) with enough fidelity for Bind to succeed: + /// root (Type-3) + /// transcriptPanel (Type-3) [0x10000010] + /// transcript (Type-12, no media) [0x10000011] ← skipped by factory + /// track (Type-3) [0x10000012] + /// inputBar (Type-3) [0x10000013] + /// menu (Type-3) [0x10000014] + /// input (Type-12, no media) [0x10000016] ← skipped by factory + /// send (Type-3) [0x10000019] + /// maxmin (Type-3) [0x1000046F] + /// + private static (ElementInfo rootInfo, ImportedLayout layout, ChatVM vm) BuildTestTree() + { + var transcriptNode = new ElementInfo + { + Id = 0x10000011u, Type = 12, // Type-12, no media → skipped by factory + X = 16, Y = 0, Width = 458, Height = 74, + }; + var trackNode = new ElementInfo + { + Id = 0x10000012u, Type = 3, + X = 474, Y = 6, Width = 16, Height = 68, + }; + var transcriptPanel = new ElementInfo + { + Id = 0x10000010u, Type = 3, X = 0, Y = 9, Width = 490, Height = 74, + }; + transcriptPanel.Children.Add(transcriptNode); + transcriptPanel.Children.Add(trackNode); + + var menuNode = new ElementInfo + { + Id = 0x10000014u, Type = 3, X = 0, Y = 0, Width = 46, Height = 17, + }; + var inputNode = new ElementInfo + { + Id = 0x10000016u, Type = 12, // Type-12, no media → skipped by factory + X = 46, Y = 0, Width = 398, Height = 17, + }; + var sendNode = new ElementInfo + { + Id = 0x10000019u, Type = 3, X = 444, Y = 0, Width = 46, Height = 17, + }; + var inputBar = new ElementInfo + { + Id = 0x10000013u, Type = 3, X = 0, Y = 83, Width = 490, Height = 17, + }; + inputBar.Children.Add(menuNode); + inputBar.Children.Add(inputNode); + inputBar.Children.Add(sendNode); + + var maxMinNode = new ElementInfo + { + Id = 0x1000046Fu, Type = 3, X = 474, Y = 0, Width = 16, Height = 16, + }; + + var root = new ElementInfo + { + Id = 0x1000000Eu, Type = 3, Width = 490, Height = 100, + }; + root.Children.Add(transcriptPanel); + root.Children.Add(inputBar); + root.Children.Add(maxMinNode); + + var layout = LayoutImporter.Build(root, NoTex, null); + var vm = new ChatVM(new ChatLog()); + return (root, layout, vm); + } + + // ── Test 1: Bind returns non-null with the minimal tree ────────────────── + + [Fact] + public void Bind_Returns_NonNull_OnValidTree() + { + var (rootInfo, layout, vm) = BuildTestTree(); + var bus = new CaptureBus(); + + var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, bus, null, null, NoTex); + + Assert.NotNull(ctrl); + } + + // ── Test 2: Transcript is placed as a child of the transcript panel ────── + + [Fact] + public void Bind_Transcript_IsChildOfTranscriptPanel() + { + var (rootInfo, layout, vm) = BuildTestTree(); + var bus = new CaptureBus(); + + var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, bus, null, null, NoTex); + + Assert.NotNull(ctrl); + var panel = layout.FindElement(0x10000010u); + Assert.NotNull(panel); + // The transcript widget must be a child of the transcript panel. + Assert.Contains(ctrl!.Transcript, panel!.Children); + } + + // ── Test 3: Input is placed as a child of the input bar ───────────────── + + [Fact] + public void Bind_Input_IsChildOfInputBar() + { + var (rootInfo, layout, vm) = BuildTestTree(); + var bus = new CaptureBus(); + + var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, bus, null, null, NoTex); + + Assert.NotNull(ctrl); + var bar = layout.FindElement(0x10000013u); + Assert.NotNull(bar); + Assert.Contains(ctrl!.Input, bar!.Children); + } + + // ── Test 4: Input.OnSubmit publishes SendChatCmd via the capture bus ───── + + [Fact] + public void Bind_InputSubmit_PublishesSendChatCmd() + { + var (rootInfo, layout, vm) = BuildTestTree(); + var bus = new CaptureBus(); + + var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, bus, null, null, NoTex); + + Assert.NotNull(ctrl); + ctrl!.Input.OnSubmit!.Invoke("hello world"); + + // ChatCommandRouter.Submit should have published a SendChatCmd. + Assert.Single(bus.Published); + var cmd = Assert.IsType(bus.Published[0]); + Assert.Equal("hello world", cmd.Text); + } + + // ── Test 5: Channel change updates the channel used by subsequent submits ─ + + [Fact] + public void Bind_ChannelChange_UpdatesSubmitChannel() + { + var (rootInfo, layout, vm) = BuildTestTree(); + var bus = new CaptureBus(); + + var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, bus, null, null, NoTex); + + Assert.NotNull(ctrl); + // Switch channel to General. + ctrl!.Menu.OnChannelChanged!.Invoke(ChatChannelKind.General); + ctrl.Input.OnSubmit!.Invoke("hey all"); + + Assert.Single(bus.Published); + var cmd = Assert.IsType(bus.Published[0]); + Assert.Equal(ChatChannelKind.General, cmd.Channel); + } + + // ── Test 6: Bind returns null when required elements are absent ────────── + + [Fact] + public void Bind_Returns_Null_WhenTranscriptPanelMissing() + { + // Build a layout that is missing the transcript panel entirely. + var root = new ElementInfo { Id = 0x1000000Eu, Type = 3, Width = 490, Height = 100 }; + // No children → TranscriptPanelId and InputBarId are absent from the widget tree. + + var layout = LayoutImporter.Build(root, NoTex, null); + var vm = new ChatVM(new ChatLog()); + var bus = new CaptureBus(); + + var ctrl = ChatWindowController.Bind(root, layout, vm, bus, null, null, NoTex); + + Assert.Null(ctrl); + } +}