From ada863980c742c1ec0e4066bcb45eb214444c5ee Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 22:12:12 +0200 Subject: [PATCH] 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) --- src/AcDream.App/Rendering/GameWindow.cs | 50 ++++++++++ src/AcDream.App/UI/UiChatView.cs | 91 +++++++++++++++++++ tests/AcDream.App.Tests/UI/UiChatViewTests.cs | 28 ++++++ 3 files changed, 169 insertions(+) create mode 100644 src/AcDream.App/UI/UiChatView.cs create mode 100644 tests/AcDream.App.Tests/UI/UiChatViewTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index ca649ec2..64289724 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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 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. diff --git a/src/AcDream.App/UI/UiChatView.cs b/src/AcDream.App/UI/UiChatView.cs new file mode 100644 index 00000000..5cf9a96b --- /dev/null +++ b/src/AcDream.App/UI/UiChatView.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.Rendering; + +namespace AcDream.App.UI; + +/// +/// Scrollable chat transcript for the retail-look chat window. Renders the +/// lines from bottom-pinned (newest at the bottom, +/// like retail) with mouse-wheel scrollback. Whole-line vertical clipping keeps +/// text inside the window. +/// +/// +/// 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 +/// opt-out so an interior drag +/// selects text instead of moving the window). +/// +/// +public sealed class UiChatView : UiElement +{ + /// One display line: pre-formatted text + its colour. + public readonly record struct Line(string Text, Vector4 Color); + + /// Provider of the lines to show, oldest-first. Polled each frame. + public Func> LinesProvider { get; set; } = static () => Array.Empty(); + + /// Font for the transcript; falls back to the context default. + public BitmapFont? Font { get; set; } + + /// Backing fill behind the text (retail chat is a dark translucent box). + public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0.35f); + + /// Inner text inset from the view edges, px. + 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 + + /// + /// 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. + /// + 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; + } +} diff --git a/tests/AcDream.App.Tests/UI/UiChatViewTests.cs b/tests/AcDream.App.Tests/UI/UiChatViewTests.cs new file mode 100644 index 00000000..6dc9f22a --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiChatViewTests.cs @@ -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)); + } +}