using System; using System.Collections.Generic; using System.Numerics; namespace AcDream.App.UI; /// /// Editable one-line chat input. Port of retail UIElement_Text editable /// one-line mode + ChatInterface's 100-entry command history. Caret is a /// glyph index; the caret pixel-X is Σ glyph advances (UiDatFont) to the caret. /// Supports mouse + Shift-arrow SELECTION, clipboard cut/copy/paste, and held-key /// auto-repeat (hold Backspace deletes continuously). Submit (Enter / Send) fires /// , clears, and pushes history. /// Decomp: UIElement_Text MoveCursor @0x468d00, FindPixelsFromPos @0x472b40; /// ChatInterface ProcessCommand @0x4f5100 (history cap 100, sentinel 0xFFFFFFFF). /// public sealed class UiChatInput : UiElement { public UiDatFont? DatFont { get; set; } public AcDream.App.Rendering.BitmapFont? Font { get; set; } public Vector4 TextColor { get; set; } = new(1f, 1f, 1f, 1f); public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0.35f); /// Selected-span highlight (translucent blue, behind the text). public Vector4 SelectionColor { get; set; } = new(0.25f, 0.45f, 0.85f, 0.5f); public float Padding { get; set; } = 4f; public int MaxCharacters { get; set; } = 0xFFFF; /// Keyboard device for clipboard (Ctrl+C/X/V) + modifier state (Ctrl/Shift). /// Wired by the host from . public Silk.NET.Input.IKeyboard? Keyboard { get; set; } /// Dat sprite resolver (id → GL texture + size) for the focused-field /// background. Null = fall back to the flat rect. public Func? SpriteResolve { get; set; } /// Gold "lit" field background drawn when focused (retail Normal_focussed /// state, RenderSurface 0x060011AB). 0 = no focus sprite. public uint FocusFieldSprite { get; set; } public Action? OnSubmit { get; set; } private string _text = ""; private int _caret; private int? _selAnchor; // selection fixed end (null = no selection); span = [min,max] with _caret public string Text => _text; public int CaretPos => _caret; private readonly List _history = new(); private int _historyIndex = -1; public int HistoryCount => _history.Count; private bool _focused; private bool _selecting; // mouse drag in progress private float _scrollX; // horizontal pixel scroll so the caret stays in the field // Held-key auto-repeat (Silk delivers one KeyDown per physical press). private Silk.NET.Input.Key? _repeatKey; private double _repeatTimer; private const double RepeatDelay = 0.40; // s before the first repeat private const double RepeatRate = 0.04; // s between repeats (~25/s) public UiChatInput() { AcceptsFocus = true; IsEditControl = true; CapturesPointerDrag = true; // interior drag selects, doesn't move the window } // ── Editing primitives ────────────────────────────────────────────── public void InsertChar(char c) { if (c < 0x20 || c == 0x7F) return; DeleteSelection(); if (_text.Length >= MaxCharacters) return; _text = _text.Insert(_caret, c.ToString()); _caret++; _historyIndex = -1; } public void Backspace() { if (DeleteSelection()) return; if (_caret == 0) return; _text = _text.Remove(_caret - 1, 1); _caret--; } public void DeleteForward() { if (DeleteSelection()) return; if (_caret >= _text.Length) return; _text = _text.Remove(_caret, 1); } private void MoveCaretTo(int target, bool shift) { target = Math.Clamp(target, 0, _text.Length); if (shift) _selAnchor ??= _caret; // begin/extend selection from the old caret else _selAnchor = null; // plain move collapses any selection _caret = target; _historyIndex = -1; } /// Move the caret left (negative) or right (positive) by /// glyph positions without extending a selection. Public for test access. public void MoveCaret(int delta) => MoveCaretTo(_caret + delta, false); private void MoveCaret(int delta, bool shift) => MoveCaretTo(_caret + delta, shift); // ── Selection ──────────────────────────────────────────────────────── private (int lo, int hi) SelSpan() { if (_selAnchor is not { } a || a == _caret) return (_caret, _caret); return (Math.Min(a, _caret), Math.Max(a, _caret)); } private bool HasSelection => _selAnchor is { } a && a != _caret; private string SelectedText() { var (lo, hi) = SelSpan(); return hi > lo ? _text.Substring(lo, hi - lo) : ""; } /// Remove the selected span (if any). Returns true if it removed anything. private bool DeleteSelection() { if (!HasSelection) { _selAnchor = null; return false; } var (lo, hi) = SelSpan(); _text = _text.Remove(lo, hi - lo); _caret = lo; _selAnchor = null; return true; } private void SelectAll() { if (_text.Length == 0) { _selAnchor = null; return; } _selAnchor = 0; _caret = _text.Length; } private void CopySelection() { var s = SelectedText(); if (s.Length > 0 && Keyboard is not null) Keyboard.ClipboardText = s; } private void CutSelection() { if (!HasSelection) return; CopySelection(); DeleteSelection(); _historyIndex = -1; } private void Paste() { if (Keyboard is null) return; string clip = Keyboard.ClipboardText ?? ""; if (clip.Length == 0) return; // Single-line field: strip control chars (newlines/tabs) from pasted text. var sb = new System.Text.StringBuilder(clip.Length); foreach (char ch in clip) if (ch >= 0x20 && ch != 0x7F) sb.Append(ch); if (sb.Length == 0) return; DeleteSelection(); int room = MaxCharacters - _text.Length; if (room <= 0) return; string ins = sb.Length > room ? sb.ToString(0, room) : sb.ToString(); _text = _text.Insert(_caret, ins); _caret += ins.Length; _historyIndex = -1; } // ── Submit + history ───────────────────────────────────────────────── public void Submit() { var t = _text; if (t.Trim().Length == 0) { Clear(); return; } OnSubmit?.Invoke(t); PushHistory(t); Clear(); } private void Clear() { _text = ""; _caret = 0; _selAnchor = null; _historyIndex = -1; } private void PushHistory(string t) { _history.Add(t); if (_history.Count > 100) _history.RemoveAt(0); _historyIndex = -1; } public void HistoryPrev() { if (_history.Count == 0) return; _historyIndex = _historyIndex < 0 ? _history.Count - 1 : Math.Max(0, _historyIndex - 1); SetTextFromHistory(); } public void HistoryNext() { if (_historyIndex < 0) return; _historyIndex++; if (_historyIndex >= _history.Count) { _historyIndex = -1; Clear(); return; } SetTextFromHistory(); } private void SetTextFromHistory() { _text = _history[_historyIndex]; _caret = _text.Length; _selAnchor = null; } // ── Geometry ───────────────────────────────────────────────────────── /// Pixel-X of the caret (Σ glyph advances to ). private float MeasureTo(int i) { if (i <= 0) return 0f; string s = _text.Substring(0, Math.Min(i, _text.Length)); return DatFont is { } df ? df.MeasureWidth(s) : Font is { } bf ? bf.MeasureWidth(s) : 0f; } public float CaretPixelX() => MeasureTo(_caret); /// Map a local X (click) to the nearest caret index — retail /// FindPixelsFromPos inverse. Accounts for the horizontal scroll offset. private int HitCharX(float localX) { float target = localX - Padding + _scrollX; if (target <= 0f) return 0; int best = 0; float bestDist = float.MaxValue; for (int i = 0; i <= _text.Length; i++) { float d = MathF.Abs(MeasureTo(i) - target); if (d < bestDist) { bestDist = d; best = i; } } return best; } // ── Draw ───────────────────────────────────────────────────────────── protected override void OnDraw(UiRenderContext ctx) { // Focused = "write mode": draw the gold lit field sprite (retail Normal_focussed). // Unfocused: the flat translucent rect. Both go through the sprite bucket // (DrawFill / DrawSprite) so the text — also sprite-bucket — draws on top. bool lit = _focused && SpriteResolve is not null && FocusFieldSprite != 0; if (lit) { var (tex, tw, th) = SpriteResolve!(FocusFieldSprite); if (tex != 0 && tw > 0) ctx.DrawSprite(tex, 0, 0, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); else lit = false; } if (!lit) ctx.DrawFill(0, 0, Width, Height, BackgroundColor); float lh = DatFont?.LineHeight ?? Font?.LineHeight ?? 14f; float ty = (Height - lh) * 0.5f; float visibleW = MathF.Max(1f, Width - 2f * Padding); // Horizontal scroll: keep the caret inside the field; clamp so we never scroll past // the text. Then draw only the glyph window that lands inside the field — a single- // line text box clips + scrolls (retail UIElement_Text) rather than overflowing the // field (which previously spilled the text out into the 3D world). float caretX = MeasureTo(_caret); float fullW = MeasureTo(_text.Length); if (caretX - _scrollX > visibleW) _scrollX = caretX - visibleW; if (caretX < _scrollX) _scrollX = caretX; _scrollX = Math.Clamp(_scrollX, 0f, MathF.Max(0f, fullW - visibleW)); // Visible character window [start, end). int start = 0; while (start < _text.Length && MeasureTo(start + 1) <= _scrollX) start++; int end = start; while (end < _text.Length && MeasureTo(end + 1) - _scrollX <= visibleW) end++; // Selection highlight BEHIND the text, clipped to the field. if (HasSelection) { var (lo, hi) = SelSpan(); float h0 = MathF.Max(MeasureTo(lo) - _scrollX, 0f); float h1 = MathF.Min(MeasureTo(hi) - _scrollX, visibleW); if (h1 > h0) ctx.DrawFill(Padding + h0, ty, h1 - h0, lh, SelectionColor); } if (end > start) { string vis = _text.Substring(start, end - start); float vx = Padding + (MeasureTo(start) - _scrollX); if (DatFont is { } df2) ctx.DrawStringDat(df2, vis, vx, ty, TextColor); else ctx.DrawString(vis, vx, ty, TextColor, Font); } if (_focused) { // Caret on TOP of the text → submitted after the text in the same bucket. float cx = Padding + (caretX - _scrollX); if (cx >= Padding - 1f && cx <= Width - Padding + 1f) ctx.DrawFill(cx, ty, 1f, lh, TextColor); } } // ── Auto-repeat ────────────────────────────────────────────────────── protected override void OnTick(double deltaSeconds) { if (_repeatKey is not { } k) return; _repeatTimer -= deltaSeconds; if (_repeatTimer > 0) return; _repeatTimer = RepeatRate; bool shift = ShiftHeld(); switch (k) { case Silk.NET.Input.Key.Backspace: Backspace(); break; case Silk.NET.Input.Key.Delete: DeleteForward(); break; case Silk.NET.Input.Key.Left: MoveCaret(-1, shift); break; case Silk.NET.Input.Key.Right: MoveCaret(1, shift); break; default: _repeatKey = null; break; } } private void StartRepeat(Silk.NET.Input.Key k) { _repeatKey = k; _repeatTimer = RepeatDelay; } private bool CtrlHeld() => Keyboard is not null && (Keyboard.IsKeyPressed(Silk.NET.Input.Key.ControlLeft) || Keyboard.IsKeyPressed(Silk.NET.Input.Key.ControlRight)); private bool ShiftHeld() => Keyboard is not null && (Keyboard.IsKeyPressed(Silk.NET.Input.Key.ShiftLeft) || Keyboard.IsKeyPressed(Silk.NET.Input.Key.ShiftRight)); // ── Events ─────────────────────────────────────────────────────────── public override bool OnEvent(in UiEvent e) { switch (e.Type) { case UiEventType.FocusGained: _focused = true; return true; case UiEventType.FocusLost: _focused = false; _historyIndex = -1; _selAnchor = null; _selecting = false; _repeatKey = null; return true; case UiEventType.Char: InsertChar((char)e.Data0); return true; case UiEventType.MouseDown: _caret = HitCharX(e.Data1); _selAnchor = _caret; // anchor; a drag will extend, a plain click won't _selecting = true; return true; case UiEventType.MouseMove: if (_selecting) _caret = HitCharX(e.Data1); return true; case UiEventType.MouseUp: _selecting = false; return true; case UiEventType.KeyUp: if ((Silk.NET.Input.Key)e.Data0 == _repeatKey) _repeatKey = null; return true; case UiEventType.KeyDown: { var key = (Silk.NET.Input.Key)e.Data0; if (CtrlHeld()) { switch (key) { case Silk.NET.Input.Key.A: SelectAll(); return true; case Silk.NET.Input.Key.C: CopySelection(); return true; case Silk.NET.Input.Key.X: CutSelection(); return true; case Silk.NET.Input.Key.V: Paste(); return true; } return true; // swallow other Ctrl combos while typing } bool shift = ShiftHeld(); switch (key) { case Silk.NET.Input.Key.Enter: case Silk.NET.Input.Key.KeypadEnter: Submit(); FindRoot()?.SetKeyboardFocus(null); // exit write mode after sending return true; case Silk.NET.Input.Key.Backspace: Backspace(); StartRepeat(key); return true; case Silk.NET.Input.Key.Delete: DeleteForward(); StartRepeat(key); return true; case Silk.NET.Input.Key.Left: MoveCaret(-1, shift); StartRepeat(key); return true; case Silk.NET.Input.Key.Right: MoveCaret(1, shift); StartRepeat(key); return true; case Silk.NET.Input.Key.Home: MoveCaretTo(0, shift); return true; case Silk.NET.Input.Key.End: MoveCaretTo(_text.Length, shift); return true; case Silk.NET.Input.Key.Up: HistoryPrev(); return true; case Silk.NET.Input.Key.Down: HistoryNext(); return true; } return false; } } return false; } }