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