feat(D.2b): scrollable retail chat window (read-only foundation)

Add UiChatView, a transcript widget for the retail-look UI: renders the
ChatVM tail bottom-pinned (newest at the bottom, like retail) with
mouse-wheel scrollback and whole-line vertical clipping so text stays
inside the frame. Hosted in a draggable/resizable UiNineSlicePanel and
wired into the UiHost next to the vitals window, fed by a dedicated
ChatVM (200-line tail) over the same live ChatLog. Per-ChatKind colour
palette (speech white, tells magenta, channels blue, system yellow,
emotes grey, combat orange).

This is the read-only foundation. The next sub-step adds glScissor
clipping + word-wrap, drag-to-select, and Ctrl+C copy -- the last needs
a CapturesPointerDrag opt-out on UiElement so an interior drag selects
text instead of moving the window (today an interior drag still moves
the window, same as the vitals panel).

Tests: UiChatView.ClampScroll (pin-to-bottom, cap-at-overflow,
never-negative).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-14 22:12:12 +02:00
parent 1453ff7da2
commit ada863980c
3 changed files with 169 additions and 0 deletions

View file

@ -0,0 +1,91 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering;
namespace AcDream.App.UI;
/// <summary>
/// Scrollable chat transcript for the retail-look chat window. Renders the
/// lines from <see cref="LinesProvider"/> bottom-pinned (newest at the bottom,
/// like retail) with mouse-wheel scrollback. Whole-line vertical clipping keeps
/// text inside the window.
///
/// <para>
/// This is the read-only foundation. A follow-up sub-step adds glScissor-based
/// clipping + word-wrap, drag-to-select, and Ctrl+C copy (which needs the
/// <see cref="UiElement.CapturesPointerDrag"/> opt-out so an interior drag
/// selects text instead of moving the window).
/// </para>
/// </summary>
public sealed class UiChatView : UiElement
{
/// <summary>One display line: pre-formatted text + its colour.</summary>
public readonly record struct Line(string Text, Vector4 Color);
/// <summary>Provider of the lines to show, oldest-first. Polled each frame.</summary>
public Func<IReadOnlyList<Line>> LinesProvider { get; set; } = static () => Array.Empty<Line>();
/// <summary>Font for the transcript; falls back to the context default.</summary>
public BitmapFont? Font { get; set; }
/// <summary>Backing fill behind the text (retail chat is a dark translucent box).</summary>
public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0.35f);
/// <summary>Inner text inset from the view edges, px.</summary>
public float Padding { get; set; } = 4f;
// Pixels the transcript is scrolled UP from the newest line (0 = pinned to bottom).
private float _scroll;
private const float WheelLines = 3f; // lines advanced per wheel notch
/// <summary>
/// Clamp a scroll offset to [0, max] where max = content-height - view-height
/// (never negative — when everything fits, scroll is pinned to 0). Exposed for tests.
/// </summary>
public static float ClampScroll(float scroll, float contentHeight, float viewHeight)
{
float max = Math.Max(0f, contentHeight - viewHeight);
if (scroll < 0f) return 0f;
return scroll > max ? max : scroll;
}
protected override void OnDraw(UiRenderContext ctx)
{
ctx.DrawRect(0, 0, Width, Height, BackgroundColor);
var font = Font ?? ctx.DefaultFont;
if (font is null) return;
var lines = LinesProvider();
if (lines.Count == 0) return;
float lh = font.LineHeight;
float top = Padding, bottom = Height - Padding;
float innerH = bottom - top;
float contentH = lines.Count * lh;
_scroll = ClampScroll(_scroll, contentH, innerH);
// Bottom-pin: with _scroll==0 the LAST line ends at `bottom`; scrolling up
// shifts the whole block down so older lines are revealed at the top.
float baseY = bottom - contentH + _scroll;
for (int i = 0; i < lines.Count; i++)
{
float y = baseY + i * lh;
if (y < top || y + lh > bottom) continue; // whole-line vertical clip (no scissor yet)
ctx.DrawString(lines[i].Text, Padding, y, lines[i].Color, font);
}
}
public override bool OnEvent(in UiEvent e)
{
if (e.Type == UiEventType.Scroll)
{
float lh = Font?.LineHeight ?? 16f;
// Silk wheel +Y = scroll up = reveal older = shift content down = larger _scroll.
_scroll += e.Data0 * WheelLines * lh; // re-clamped next OnDraw against live content
return true;
}
return false;
}
}