From 9faf9d7e3ac1014f8bf79ec93f3b039e088eee1b Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 25 Apr 2026 00:48:07 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20ChatPanel=20=E2=80=94=20second=20de?= =?UTF-8?q?vtools=20panel=20proves=20the=20abstraction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a second real panel behind ACDREAM_DEVTOOLS=1. Shows the tail of ChatLog (last 20 entries by default) formatted per ChatKind: "Caith: hello" — LocalSpeech "Regal says distantly: hi" — RangedSpeech "[ch 7] Caith: g'day" — Channel "[Tell] Regal: psst" — Tell "[System] Your spell fizzled!" — System "[Popup] A door stands..." — Popup Why now: proves the D.2a IPanelRenderer contract survives beyond a single progress-bar panel. ChatPanel exercises Text() + Separator() on a variable-length list where VitalsPanel was a fixed three-widget layout. No renderer primitives needed to grow — the contract held, which is the whole point of the abstraction layer. Files: - src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs (new) Snapshots ChatLog tail every frame. Cheap at default 500-entry cap. Per-kind formatting lives here (not in the panel) so the D.2b retail-look swap inherits plain-text fallbacks. - src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs (new) IPanel implementation. Separator + N Text lines. "(no messages yet)" fallback when the log is empty. - src/AcDream.App/Rendering/GameWindow.cs Registers the ChatPanel alongside VitalsPanel in the devtools init block. Uses the existing GameWindow.Chat field already fed by H.1's wire layer + GameEventWiring.WireAll. - tests/AcDream.UI.Abstractions.Tests/ChatVMTests.cs (new) 12 tests covering tail selection, display-limit bounds, every ChatKind's formatting, null-log + zero-limit guards, no stale caching across appends. Also fixes one stale "Hexa.NET.ImGui" mention in VitalsPanel's xmldoc (pivoted to ImGui.NET in 55aaca7; doc needed a trailing update). Build: 0 warnings, 0 errors. Tests: 23 UI.Abstractions (up from 11, all Core + Core.Net still green), 0 failures. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 9 +- .../Panels/Chat/ChatPanel.cs | 64 ++++++++++ .../Panels/Chat/ChatVM.cs | 82 ++++++++++++ .../Panels/Vitals/VitalsPanel.cs | 2 +- .../ChatVMTests.cs | 118 ++++++++++++++++++ 5 files changed, 273 insertions(+), 2 deletions(-) create mode 100644 src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs create mode 100644 src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs create mode 100644 tests/AcDream.UI.Abstractions.Tests/ChatVMTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index ab4479f..f1e6444 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -895,7 +895,14 @@ public sealed class GameWindow : IDisposable _panelHost.Register( new AcDream.UI.Abstractions.Panels.Vitals.VitalsPanel(_vitalsVm)); - Console.WriteLine("devtools: ImGui panel host ready (VitalsPanel registered)"); + // ChatPanel: reads the tail of the shared ChatLog. No GUID + // dependency — works pre-login (empty) and post-login (live + // tail of received speech/tells/channels/system msgs). + var chatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat); + _panelHost.Register( + new AcDream.UI.Abstractions.Panels.Chat.ChatPanel(chatVm)); + + Console.WriteLine("devtools: ImGui panel host ready (VitalsPanel + ChatPanel registered)"); } catch (Exception ex) { diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs new file mode 100644 index 0000000..3ca9c6a --- /dev/null +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs @@ -0,0 +1,64 @@ +namespace AcDream.UI.Abstractions.Panels.Chat; + +/// +/// Second real UI panel — shows the tail of the chat log. +/// Exercises + +/// on a non-trivial render pattern (N lines, not a single widget) — +/// proving the D.2a abstraction contract holds for more than the vitals +/// HUD before we grow the panel catalog further. +/// +/// +/// D.2a scope: show the last +/// lines with a separator above the tail and each entry as a single +/// Text call. No input field (outbound chat already has wire +/// support via SendChat — a text-input widget on +/// lands with the first panel that actually needs one, not here). +/// +/// +public sealed class ChatPanel : IPanel +{ + private readonly ChatVM _vm; + + public ChatPanel(ChatVM vm) + { + _vm = vm ?? throw new ArgumentNullException(nameof(vm)); + } + + /// + public string Id => "acdream.chat"; + + /// + public string Title => "Chat"; + + /// + public bool IsVisible { get; set; } = true; + + /// + public void Render(PanelContext ctx, IPanelRenderer renderer) + { + if (!renderer.Begin(Title)) + { + renderer.End(); + return; + } + + var lines = _vm.RecentLines(); + if (lines.Count == 0) + { + renderer.Text("(no messages yet)"); + } + else + { + // Header separator so the reader always sees the tail start; + // the IPanel contract promises pressing Begin opens the window + // at a stable anchor. + renderer.Separator(); + for (int i = 0; i < lines.Count; i++) + { + renderer.Text(lines[i]); + } + } + + renderer.End(); + } +} diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs new file mode 100644 index 0000000..fa05d27 --- /dev/null +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs @@ -0,0 +1,82 @@ +using AcDream.Core.Chat; + +namespace AcDream.UI.Abstractions.Panels.Chat; + +/// +/// ViewModel for the chat panel. Reads the tail of +/// and formats each into a single display line. +/// +/// +/// Formatting lives here (not in the panel) so the same rendering logic +/// survives the Phase D.2b backend swap — under the custom retail-look +/// toolkit we'll want different per- styling, but +/// the plain-text form is the fallback and the starting point. +/// +/// +/// +/// D.2a snapshots the log every frame. Cheap: the default 500-entry cap +/// keeps it < 1 ms. A future iteration can subscribe to +/// for incremental updates once we +/// add virtualized scrolling in . +/// +/// +public sealed class ChatVM +{ + /// Default number of tail entries rendered. + public const int DefaultDisplayLimit = 20; + + private readonly ChatLog _log; + private readonly int _displayLimit; + + /// + /// Build a ChatVM bound to a instance. + /// + /// Live chat log. Never null. + /// + /// Maximum number of tail entries to surface per + /// call. Must be >= 1. Defaults to + /// . + /// + public ChatVM(ChatLog log, int displayLimit = DefaultDisplayLimit) + { + _log = log ?? throw new ArgumentNullException(nameof(log)); + if (displayLimit < 1) + throw new ArgumentOutOfRangeException(nameof(displayLimit), displayLimit, "must be >= 1"); + _displayLimit = displayLimit; + } + + /// + /// Snapshot the tail of the chat log, formatted as display strings, + /// oldest-first. Never returns null; returns an empty array if the + /// log is empty. + /// + public IReadOnlyList RecentLines() + { + var snap = _log.Snapshot(); + int start = Math.Max(0, snap.Length - _displayLimit); + int count = snap.Length - start; + if (count <= 0) return Array.Empty(); + + var lines = new string[count]; + for (int i = 0; i < count; i++) + { + lines[i] = FormatEntry(snap[start + i]); + } + return lines; + } + + /// + /// Format a single for display. Public so tests + /// can assert the per-kind formatting without touching a full log. + /// + public static string FormatEntry(ChatEntry entry) => entry.Kind switch + { + ChatKind.LocalSpeech => $"{entry.Sender}: {entry.Text}", + ChatKind.RangedSpeech => $"{entry.Sender} says distantly: {entry.Text}", + ChatKind.Channel => $"[ch {entry.ChannelId}] {entry.Sender}: {entry.Text}", + ChatKind.Tell => $"[Tell] {entry.Sender}: {entry.Text}", + ChatKind.System => $"[System] {entry.Text}", + ChatKind.Popup => $"[Popup] {entry.Text}", + _ => entry.Text, + }; +} diff --git a/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsPanel.cs b/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsPanel.cs index 5040c7e..0b0b58a 100644 --- a/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsPanel.cs +++ b/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsPanel.cs @@ -3,7 +3,7 @@ namespace AcDream.UI.Abstractions.Panels.Vitals; /// /// First real UI panel — shows the local player's vitals as progress bars. /// Backend-agnostic; renders exclusively through -/// so the same file works under Hexa.NET.ImGui (D.2a) and the future custom +/// so the same file works under ImGui.NET (D.2a) and the future custom /// retail-look toolkit (D.2b). /// /// diff --git a/tests/AcDream.UI.Abstractions.Tests/ChatVMTests.cs b/tests/AcDream.UI.Abstractions.Tests/ChatVMTests.cs new file mode 100644 index 0000000..199ab94 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/ChatVMTests.cs @@ -0,0 +1,118 @@ +using AcDream.Core.Chat; +using AcDream.UI.Abstractions.Panels.Chat; + +namespace AcDream.UI.Abstractions.Tests; + +public sealed class ChatVMTests +{ + [Fact] + public void RecentLines_ReturnsEmpty_ForEmptyLog() + { + var log = new ChatLog(); + var vm = new ChatVM(log); + + Assert.Empty(vm.RecentLines()); + } + + [Fact] + public void RecentLines_ReturnsAllEntries_WhenBelowLimit() + { + var log = new ChatLog(); + log.OnLocalSpeech(sender: "Caith", text: "hello", senderGuid: 0x5000_0001u, isRanged: false); + log.OnLocalSpeech(sender: "Regal", text: "world", senderGuid: 0x5000_0002u, isRanged: false); + + var vm = new ChatVM(log, displayLimit: 20); + var lines = vm.RecentLines(); + + Assert.Equal(2, lines.Count); + Assert.Equal("Caith: hello", lines[0]); + Assert.Equal("Regal: world", lines[1]); + } + + [Fact] + public void RecentLines_ReturnsTail_WhenAboveLimit_InOldestFirstOrder() + { + var log = new ChatLog(); + for (int i = 0; i < 30; i++) + log.OnLocalSpeech(sender: "A", text: $"msg{i}", senderGuid: 0x5000_0001u, isRanged: false); + + var vm = new ChatVM(log, displayLimit: 5); + var lines = vm.RecentLines(); + + // Tail = msg25..msg29 (5 entries, oldest first). + Assert.Equal(5, lines.Count); + Assert.Equal("A: msg25", lines[0]); + Assert.Equal("A: msg29", lines[4]); + } + + [Fact] + public void FormatEntry_LocalSpeech_SenderColonText() + { + var entry = new ChatEntry(ChatKind.LocalSpeech, "Caith", "hello", 0x5000_0001u, 0); + Assert.Equal("Caith: hello", ChatVM.FormatEntry(entry)); + } + + [Fact] + public void FormatEntry_RangedSpeech_IncludesDistanceHint() + { + var entry = new ChatEntry(ChatKind.RangedSpeech, "Caith", "hello", 0x5000_0001u, 0); + Assert.Equal("Caith says distantly: hello", ChatVM.FormatEntry(entry)); + } + + [Fact] + public void FormatEntry_Channel_IncludesChannelId() + { + var entry = new ChatEntry(ChatKind.Channel, "Caith", "g'day", 0x5000_0001u, 7u); + Assert.Equal("[ch 7] Caith: g'day", ChatVM.FormatEntry(entry)); + } + + [Fact] + public void FormatEntry_Tell_PrefixedWithTellTag() + { + var entry = new ChatEntry(ChatKind.Tell, "Regal", "psst", 0x5000_0002u, 0); + Assert.Equal("[Tell] Regal: psst", ChatVM.FormatEntry(entry)); + } + + [Fact] + public void FormatEntry_System_NoSenderShown() + { + var entry = new ChatEntry(ChatKind.System, Sender: "", "Your spell fizzled!", 0, 0); + Assert.Equal("[System] Your spell fizzled!", ChatVM.FormatEntry(entry)); + } + + [Fact] + public void FormatEntry_Popup_Prefixed() + { + var entry = new ChatEntry(ChatKind.Popup, Sender: "", "A door stands before you.", 0, 0); + Assert.Equal("[Popup] A door stands before you.", ChatVM.FormatEntry(entry)); + } + + [Fact] + public void Constructor_ThrowsOnNullLog() + { + Assert.Throws(() => new ChatVM(null!)); + } + + [Fact] + public void Constructor_ThrowsOnZeroOrNegativeLimit() + { + var log = new ChatLog(); + Assert.Throws(() => new ChatVM(log, displayLimit: 0)); + Assert.Throws(() => new ChatVM(log, displayLimit: -1)); + } + + [Fact] + public void RecentLines_ReturnsNewLineData_AfterSubsequentAppend() + { + // Confirm the VM isn't caching — each call re-snapshots the log. + var log = new ChatLog(); + var vm = new ChatVM(log); + + Assert.Empty(vm.RecentLines()); + + log.OnLocalSpeech("Caith", "hello", 0x5000_0001u, false); + var after = vm.RecentLines(); + Assert.Single(after); + Assert.Equal("Caith: hello", after[0]); + } +}