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:
parent
4d1b8b8aee
commit
9faf9d7e3a
5 changed files with 273 additions and 2 deletions
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
64
src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs
Normal file
64
src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
namespace AcDream.UI.Abstractions.Panels.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Second real UI panel — shows the tail of the chat log.
|
||||
/// Exercises <see cref="IPanelRenderer.Text"/> + <see cref="IPanelRenderer.Separator"/>
|
||||
/// 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.
|
||||
///
|
||||
/// <para>
|
||||
/// D.2a scope: show the last <see cref="ChatVM.DefaultDisplayLimit"/>
|
||||
/// lines with a separator above the tail and each entry as a single
|
||||
/// <c>Text</c> call. No input field (outbound chat already has wire
|
||||
/// support via <c>SendChat</c> — a text-input widget on <see cref="IPanelRenderer"/>
|
||||
/// lands with the first panel that actually needs one, not here).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ChatPanel : IPanel
|
||||
{
|
||||
private readonly ChatVM _vm;
|
||||
|
||||
public ChatPanel(ChatVM vm)
|
||||
{
|
||||
_vm = vm ?? throw new ArgumentNullException(nameof(vm));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Id => "acdream.chat";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Title => "Chat";
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsVisible { get; set; } = true;
|
||||
|
||||
/// <inheritdoc />
|
||||
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();
|
||||
}
|
||||
}
|
||||
82
src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs
Normal file
82
src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs
Normal 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 < 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 >= 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ namespace AcDream.UI.Abstractions.Panels.Vitals;
|
|||
/// <summary>
|
||||
/// First real UI panel — shows the local player's vitals as progress bars.
|
||||
/// Backend-agnostic; renders exclusively through <see cref="IPanelRenderer"/>
|
||||
/// 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).
|
||||
///
|
||||
/// <para>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue