feat(D.2b): chat input — write mode, selection, clipboard, key-repeat, scroll-clip

Port the retail UIElement_Text editable single-line field:

- Focused = "write mode": draws the gold lit field sprite (0x060011AB, the
  Normal_focussed state) instead of the flat translucent rect; Enter submits
  AND blurs (exits write mode).
- Single-line SELECTION: click-drag, Shift+Arrows, Shift+Home/End, Ctrl+A;
  translucent-blue highlight behind the span; typing/Backspace/Delete/Paste
  replace the selection first.
- CLIPBOARD: Ctrl+C copy, Ctrl+X cut, Ctrl+V paste at the caret (control chars
  stripped — single-line). Wired to the keyboard device for clipboard + Ctrl/
  Shift state.
- Held-key AUTO-REPEAT (Silk delivers one KeyDown per press): Backspace /
  Delete / Left / Right repeat via a 0.4s-delay, ~25/s OnTick timer.
- Horizontal SCROLL + clip: keeps the caret in the field and draws only the
  glyph window that fits inside it, so long input scrolls within the box
  instead of spilling past Send into the 3D world.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-16 15:24:09 +02:00
parent 260507e33c
commit 367a752078

View file

@ -8,7 +8,9 @@ namespace AcDream.App.UI;
/// Editable one-line chat input. Port of retail <c>UIElement_Text</c> editable
/// one-line mode + <c>ChatInterface</c>'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 <see cref="OnSubmit"/>, 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
/// <see cref="OnSubmit"/>, clears, and pushes history.
/// Decomp: UIElement_Text MoveCursor @0x468d00, FindPixelsFromPos @0x472b40;
/// ChatInterface ProcessCommand @0x4f5100 (history cap 100, sentinel 0xFFFFFFFF).
/// </summary>
@ -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);
/// <summary>Selected-span highlight (translucent blue, behind the text).</summary>
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;
/// <summary>Keyboard device for clipboard (Ctrl+C/X/V) + modifier state (Ctrl/Shift).
/// Wired by the host from <see cref="UiHost.Keyboard"/>.</summary>
public Silk.NET.Input.IKeyboard? Keyboard { get; set; }
/// <summary>Dat sprite resolver (id → GL texture + size) for the focused-field
/// background. Null = fall back to the flat <see cref="BackgroundColor"/> rect.</summary>
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
/// <summary>Gold "lit" field background drawn when focused (retail Normal_focussed
/// state, RenderSurface 0x060011AB). 0 = no focus sprite.</summary>
public uint FocusFieldSprite { get; set; }
public Action<string>? 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) : "";
}
/// <summary>Remove the selected span (if any). Returns true if it removed anything.</summary>
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;
/// <summary>Pixel-X of the caret (Σ glyph advances to <paramref name="i"/>).</summary>
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);
/// <summary>Map a local X (click) to the nearest caret index — retail
/// FindPixelsFromPos inverse. Accounts for the horizontal scroll offset.</summary>
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;
}