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:
parent
1453ff7da2
commit
ada863980c
3 changed files with 169 additions and 0 deletions
|
|
@ -1773,6 +1773,56 @@ public sealed class GameWindow : IDisposable
|
|||
_uiHost.Root.AddChild(panel);
|
||||
Console.WriteLine("[D.2b] retail UI active — vitals panel from vitals.xml markup.");
|
||||
|
||||
// Retail chat window — a draggable/resizable nine-slice frame hosting a
|
||||
// scrollable transcript (UiChatView). Read-only + wheel-scroll for now;
|
||||
// drag-select + Ctrl+C copy land in the next D.2b sub-step. A dedicated
|
||||
// ChatVM with a deeper tail (200) feeds the scrollback; it shares the
|
||||
// same live ChatLog (Chat) as the ImGui panel.
|
||||
var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200);
|
||||
var chatWindow = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome)
|
||||
{
|
||||
Left = 10, Top = 432, Width = 440, Height = 184,
|
||||
MinWidth = 180, MinHeight = 80,
|
||||
};
|
||||
var chatView = new AcDream.App.UI.UiChatView
|
||||
{
|
||||
Left = 8, Top = 8, Width = 424, Height = 168,
|
||||
Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top
|
||||
| AcDream.App.UI.AnchorEdges.Right | AcDream.App.UI.AnchorEdges.Bottom,
|
||||
Font = _debugFont,
|
||||
LinesProvider = () => BuildRetailChatLines(retailChatVm),
|
||||
};
|
||||
chatWindow.AddChild(chatView);
|
||||
_uiHost.Root.AddChild(chatWindow);
|
||||
|
||||
// Map the VM's formatted tail into coloured view lines. Per-ChatKind
|
||||
// palette (retail-ish): speech white, tells magenta, channels blue,
|
||||
// system yellow, emotes grey, combat orange. Refined later if needed.
|
||||
static System.Collections.Generic.IReadOnlyList<AcDream.App.UI.UiChatView.Line> BuildRetailChatLines(
|
||||
AcDream.UI.Abstractions.Panels.Chat.ChatVM vm)
|
||||
{
|
||||
var detailed = vm.RecentLinesDetailed();
|
||||
var result = new AcDream.App.UI.UiChatView.Line[detailed.Count];
|
||||
for (int i = 0; i < detailed.Count; i++)
|
||||
result[i] = new AcDream.App.UI.UiChatView.Line(
|
||||
detailed[i].Text, RetailChatColor(detailed[i].Kind));
|
||||
return result;
|
||||
}
|
||||
|
||||
static System.Numerics.Vector4 RetailChatColor(AcDream.Core.Chat.ChatKind kind) => kind switch
|
||||
{
|
||||
AcDream.Core.Chat.ChatKind.LocalSpeech => new(1f, 1f, 1f, 1f),
|
||||
AcDream.Core.Chat.ChatKind.RangedSpeech => new(1f, 0.95f, 0.8f, 1f),
|
||||
AcDream.Core.Chat.ChatKind.Channel => new(0.6f, 0.8f, 1f, 1f),
|
||||
AcDream.Core.Chat.ChatKind.Tell => new(1f, 0.5f, 1f, 1f),
|
||||
AcDream.Core.Chat.ChatKind.System => new(1f, 1f, 0.45f, 1f),
|
||||
AcDream.Core.Chat.ChatKind.Popup => new(1f, 0.85f, 0.4f, 1f),
|
||||
AcDream.Core.Chat.ChatKind.Emote => new(0.8f, 0.8f, 0.7f, 1f),
|
||||
AcDream.Core.Chat.ChatKind.SoulEmote => new(0.8f, 0.8f, 0.7f, 1f),
|
||||
AcDream.Core.Chat.ChatKind.Combat => new(1f, 0.6f, 0.25f, 1f),
|
||||
_ => new(0.9f, 0.9f, 0.9f, 1f),
|
||||
};
|
||||
|
||||
// Drain plugin-registered markup panels (buffered before the GL
|
||||
// window opened) into the same UiRoot tree. A faulty plugin markup
|
||||
// file is isolated — logged + skipped, never crashes the client.
|
||||
|
|
|
|||
91
src/AcDream.App/UI/UiChatView.cs
Normal file
91
src/AcDream.App/UI/UiChatView.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
28
tests/AcDream.App.Tests/UI/UiChatViewTests.cs
Normal file
28
tests/AcDream.App.Tests/UI/UiChatViewTests.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
using AcDream.App.UI;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public class UiChatViewTests
|
||||
{
|
||||
[Fact]
|
||||
public void ClampScroll_PinsToZero_WhenContentFitsView()
|
||||
{
|
||||
// 5 lines of content in a taller view → nothing to scroll, pinned at 0.
|
||||
Assert.Equal(0f, UiChatView.ClampScroll(50f, contentHeight: 80f, viewHeight: 200f));
|
||||
Assert.Equal(0f, UiChatView.ClampScroll(0f, contentHeight: 80f, viewHeight: 200f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClampScroll_CapsAtContentMinusView_WhenOverflowing()
|
||||
{
|
||||
// Content 500, view 200 → max scrollback is 300px (oldest line at top).
|
||||
Assert.Equal(300f, UiChatView.ClampScroll(1000f, contentHeight: 500f, viewHeight: 200f));
|
||||
Assert.Equal(120f, UiChatView.ClampScroll(120f, contentHeight: 500f, viewHeight: 200f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClampScroll_NeverNegative()
|
||||
{
|
||||
Assert.Equal(0f, UiChatView.ClampScroll(-50f, contentHeight: 500f, viewHeight: 200f));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue