using System; using System.Collections.Generic; using System.Numerics; using System.Text; 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. /// /// /// Supports Windows-like text selection: a left-click-drag inside the transcript /// selects characters (the opt-out /// stops that interior drag from moving the host window), and Ctrl+C copies the /// selected span to the clipboard. Ctrl+A selects everything. /// /// public sealed class UiChatView : UiElement { /// One display line: pre-formatted text + its colour. public readonly record struct Line(string Text, Vector4 Color); /// A caret position: a line index into the cached line list plus a /// character index (0..line.Text.Length, i.e. a caret slot between glyphs). public readonly record struct Pos(int Line, int Col); /// 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; } /// Keyboard device for clipboard (Ctrl+C) + modifier state. Wired by /// the host from . public Silk.NET.Input.IKeyboard? Keyboard { 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); /// Highlight colour painted behind a selected character span. public Vector4 SelectionColor { get; set; } = new(0.25f, 0.45f, 0.85f, 0.5f); /// 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 // ── Cached layout from the last OnDraw, so OnEvent hit-tests the SAME geometry ── private IReadOnlyList _lastLines = Array.Empty(); private BitmapFont? _lastFont; private float _lastLineHeight = 16f; private float _lastBaseY; // top Y of line 0 in local space private float _lastPadding = 4f; // ── Selection state ────────────────────────────────────────────────── private Pos? _selAnchor; // where the drag started private Pos? _selCaret; // where the drag currently is private bool _selecting; public UiChatView() { AcceptsFocus = true; IsEditControl = true; // absorb keys (Ctrl+C) while focused CapturesPointerDrag = true; // interior drag selects, doesn't move the window } /// /// 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(); // Cache the geometry OnEvent will hit-test against. Even when there are no // lines we record the font/padding so a stray hit-test is harmless. _lastLines = lines; _lastFont = font; _lastLineHeight = font.LineHeight; _lastPadding = Padding; 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; _lastBaseY = baseY; // Normalised selection span (start <= end), if any. bool hasSel = TryGetOrderedSelection(out Pos selStart, out Pos selEnd); 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) string text = lines[i].Text; // Selection highlight behind this line's selected character span. if (hasSel && i >= selStart.Line && i <= selEnd.Line) { int c0 = i == selStart.Line ? selStart.Col : 0; int c1 = i == selEnd.Line ? selEnd.Col : text.Length; c0 = Math.Clamp(c0, 0, text.Length); c1 = Math.Clamp(c1, 0, text.Length); if (c1 > c0) { float hx = Padding + font.MeasureWidth(text.Substring(0, c0)); float hw = font.MeasureWidth(text.Substring(c0, c1 - c0)); ctx.DrawRect(hx, y, hw, lh, SelectionColor); } } ctx.DrawString(text, Padding, y, lines[i].Color, font); } } public override bool OnEvent(in UiEvent e) { switch (e.Type) { case UiEventType.Scroll: { float lh = (Font ?? _lastFont)?.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; } case UiEventType.MouseDown: { // Data1/Data2 = local-to-target coords (UiRoot.OnMouseDown). var p = HitChar(e.Data1, e.Data2); _selAnchor = p; _selCaret = p; _selecting = true; return true; } case UiEventType.MouseMove: { if (_selecting) { // Data1/Data2 = local-to-target coords (DispatchMouseMove). _selCaret = HitChar(e.Data1, e.Data2); return true; } return false; } case UiEventType.MouseUp: { _selecting = false; return true; } case UiEventType.KeyDown: { var key = (Silk.NET.Input.Key)e.Data0; bool ctrl = Keyboard is not null && (Keyboard.IsKeyPressed(Silk.NET.Input.Key.ControlLeft) || Keyboard.IsKeyPressed(Silk.NET.Input.Key.ControlRight)); if (ctrl && key == Silk.NET.Input.Key.C) { // Only touch the clipboard when there's a selection — an empty // copy must NOT clobber what the user previously copied. if (Keyboard is not null) { string sel = SelectedText(); if (sel.Length > 0) Keyboard.ClipboardText = sel; } return true; } if (ctrl && key == Silk.NET.Input.Key.A) { SelectAll(); return true; } return false; } } return false; } // ── Selection helpers ──────────────────────────────────────────────── /// Select the entire cached transcript (Ctrl+A). private void SelectAll() { var lines = _lastLines; if (lines.Count == 0) { _selAnchor = _selCaret = null; return; } int last = lines.Count - 1; _selAnchor = new Pos(0, 0); _selCaret = new Pos(last, lines[last].Text.Length); } /// Normalise (anchor, caret) into ordered (start, end). False if no /// selection or it is empty (anchor == caret). private bool TryGetOrderedSelection(out Pos start, out Pos end) { start = default; end = default; if (_selAnchor is not { } a || _selCaret is not { } c) return false; (start, end) = Order(a, c); return !(start.Line == end.Line && start.Col == end.Col); } /// The currently-selected text against the cached lines. Empty when /// nothing is selected. public string SelectedText() { if (!TryGetOrderedSelection(out var start, out var end)) return string.Empty; return SelectedText(_lastLines, start, end); } // ── Pure, testable logic (no GL / no font texture) ─────────────────── /// Order two caret positions so the first is <= the second (by line, /// then column). public static (Pos start, Pos end) Order(Pos a, Pos b) { if (a.Line < b.Line || (a.Line == b.Line && a.Col <= b.Col)) return (a, b); return (b, a); } /// /// Assemble the selected substring spanning .. /// (inclusive of start.Col, exclusive of end.Col) from /// . Multi-line selections are joined with "\n": /// the first line from start.Col to its end, whole middle lines, and the last /// line up to end.Col. Pure — unit-testable without GL. /// public static string SelectedText(IReadOnlyList lines, Pos start, Pos end) { if (lines.Count == 0) return string.Empty; (start, end) = Order(start, end); int sl = Math.Clamp(start.Line, 0, lines.Count - 1); int el = Math.Clamp(end.Line, 0, lines.Count - 1); if (sl == el) { string t = lines[sl].Text; int c0 = Math.Clamp(start.Col, 0, t.Length); int c1 = Math.Clamp(end.Col, 0, t.Length); if (c1 <= c0) return string.Empty; return t.Substring(c0, c1 - c0); } var sb = new StringBuilder(); // First line: from start.Col to its end. { string t = lines[sl].Text; int c0 = Math.Clamp(start.Col, 0, t.Length); sb.Append(t.AsSpan(c0)); } // Whole middle lines. for (int i = sl + 1; i < el; i++) { sb.Append('\n'); sb.Append(lines[i].Text); } // Last line: up to end.Col. { sb.Append('\n'); string t = lines[el].Text; int c1 = Math.Clamp(end.Col, 0, t.Length); sb.Append(t.AsSpan(0, c1)); } return sb.ToString(); } /// /// Convert a local-space point to a caret against the cached /// layout from the last draw. line = floor((localY - baseY)/lineHeight) clamped /// to the line range; col via . /// private Pos HitChar(float localX, float localY) { var lines = _lastLines; if (lines.Count == 0) return new Pos(0, 0); float lh = _lastLineHeight <= 0f ? 16f : _lastLineHeight; int line = (int)MathF.Floor((localY - _lastBaseY) / lh); line = Math.Clamp(line, 0, lines.Count - 1); string text = lines[line].Text; var font = _lastFont; int col = font is null ? 0 : CharIndexAt(text, ch => font.TryGetGlyph(ch, out var g) ? g.Advance : 0f, localX - _lastPadding); return new Pos(line, col); } /// /// The caret column for a horizontal position (already /// adjusted for the left padding, so x=0 is the start of the text). Walks the /// string accumulating each glyph's advance and snaps the caret to whichever /// side of the glyph midpoint falls on — natural /// Windows-like caret placement. Pure — unit-testable with a synthetic advance. /// /// The line text. /// Per-character advance (pixels) lookup. /// Horizontal position relative to the text's left edge. public static int CharIndexAt(string text, Func advanceOf, float x) { if (string.IsNullOrEmpty(text) || x <= 0f) return 0; float cursor = 0f; for (int i = 0; i < text.Length; i++) { float adv = advanceOf(text[i]); float mid = cursor + adv * 0.5f; if (x < mid) return i; // caret sits before this glyph cursor += adv; } return text.Length; // past the last glyph → end caret } }