diff --git a/src/AcDream.App/UI/UiChatInput.cs b/src/AcDream.App/UI/UiChatInput.cs index 8bed6af0..58c6e4a0 100644 --- a/src/AcDream.App/UI/UiChatInput.cs +++ b/src/AcDream.App/UI/UiChatInput.cs @@ -8,7 +8,9 @@ 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. -/// Submit (Enter / Send) fires , clears, and pushes history. +/// 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). /// @@ -18,13 +20,27 @@ public sealed class UiChatInput : UiElement 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; @@ -32,16 +48,29 @@ public sealed class UiChatInput : UiElement 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; + 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++; @@ -50,6 +79,7 @@ public sealed class UiChatInput : UiElement public void Backspace() { + if (DeleteSelection()) return; if (_caret == 0) return; _text = _text.Remove(_caret - 1, 1); _caret--; @@ -57,13 +87,92 @@ public sealed class UiChatInput : UiElement public void DeleteForward() { + if (DeleteSelection()) return; if (_caret >= _text.Length) return; _text = _text.Remove(_caret, 1); } - public void MoveCaret(int delta) => _caret = Math.Clamp(_caret + delta, 0, _text.Length); - public void CaretHome() => _caret = 0; - public void CaretEnd() => _caret = _text.Length; + 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; + } + + 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() { @@ -74,7 +183,7 @@ public sealed class UiChatInput : UiElement Clear(); } - private void Clear() { _text = ""; _caret = 0; _historyIndex = -1; } + private void Clear() { _text = ""; _caret = 0; _selAnchor = null; _historyIndex = -1; } private void PushHistory(string t) { @@ -102,53 +211,192 @@ public sealed class UiChatInput : UiElement { _text = _history[_historyIndex]; _caret = _text.Length; + _selAnchor = null; } - public float CaretPixelX() - => DatFont is { } df ? df.MeasureWidth(_text.Substring(0, _caret)) - : Font is { } bf ? bf.MeasureWidth(_text.Substring(0, _caret)) : 0f; + // ── Geometry ───────────────────────────────────────────────────────── - private bool _focused; + /// 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) { - ctx.DrawRect(0, 0, Width, Height, BackgroundColor); + // 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; - if (DatFont is { } df) ctx.DrawStringDat(df, _text, Padding, ty, TextColor); - else ctx.DrawString(_text, Padding, ty, TextColor, Font); + 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) { - float cx = Padding + CaretPixelX(); - ctx.DrawRect(cx, ty, 1f, lh, TextColor); + // 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; 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(); return true; - case Silk.NET.Input.Key.Backspace: Backspace(); return true; - case Silk.NET.Input.Key.Delete: DeleteForward(); return true; - case Silk.NET.Input.Key.Left: MoveCaret(-1); return true; - case Silk.NET.Input.Key.Right: MoveCaret(1); return true; - case Silk.NET.Input.Key.Home: CaretHome(); return true; - case Silk.NET.Input.Key.End: CaretEnd(); return true; - case Silk.NET.Input.Key.Up: HistoryPrev(); return true; - case Silk.NET.Input.Key.Down: HistoryNext(); return true; + 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; }