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:
parent
260507e33c
commit
367a752078
1 changed files with 273 additions and 25 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue