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
}
}