feat(ui): ChatPanel — second devtools panel proves the abstraction

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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-25 00:48:07 +02:00
parent 4d1b8b8aee
commit 9faf9d7e3a
5 changed files with 273 additions and 2 deletions

View file

@ -0,0 +1,82 @@
using AcDream.Core.Chat;
namespace AcDream.UI.Abstractions.Panels.Chat;
/// <summary>
/// ViewModel for the chat panel. Reads the tail of <see cref="ChatLog"/>
/// and formats each <see cref="ChatEntry"/> into a single display line.
///
/// <para>
/// 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-<see cref="ChatKind"/> styling, but
/// the plain-text form is the fallback and the starting point.
/// </para>
///
/// <para>
/// D.2a snapshots the log every frame. Cheap: the default 500-entry cap
/// keeps it &lt; 1 ms. A future iteration can subscribe to
/// <see cref="ChatLog.EntryAppended"/> for incremental updates once we
/// add virtualized scrolling in <see cref="IPanelRenderer"/>.
/// </para>
/// </summary>
public sealed class ChatVM
{
/// <summary>Default number of tail entries rendered.</summary>
public const int DefaultDisplayLimit = 20;
private readonly ChatLog _log;
private readonly int _displayLimit;
/// <summary>
/// Build a ChatVM bound to a <see cref="ChatLog"/> instance.
/// </summary>
/// <param name="log">Live chat log. Never null.</param>
/// <param name="displayLimit">
/// Maximum number of tail entries to surface per
/// <see cref="RecentLines"/> call. Must be &gt;= 1. Defaults to
/// <see cref="DefaultDisplayLimit"/>.
/// </param>
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;
}
/// <summary>
/// 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.
/// </summary>
public IReadOnlyList<string> RecentLines()
{
var snap = _log.Snapshot();
int start = Math.Max(0, snap.Length - _displayLimit);
int count = snap.Length - start;
if (count <= 0) return Array.Empty<string>();
var lines = new string[count];
for (int i = 0; i < count; i++)
{
lines[i] = FormatEntry(snap[start + i]);
}
return lines;
}
/// <summary>
/// Format a single <see cref="ChatEntry"/> for display. Public so tests
/// can assert the per-kind formatting without touching a full log.
/// </summary>
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,
};
}