- Add ChatLayoutFixtureGenerator.cs (Skip-by-default) to regenerate
chat_21000006.json from the live portal.dat via LayoutImporter.ImportInfos
- Commit generated fixture chat_21000006.json (13 KB, 400 lines) — dat-free,
auto-copied to test output via existing *.json csproj glob
- Refactor FixtureLoader: extract shared LoadInfos(fileName) helper; add
LoadChat() + LoadChatInfos() mirroring the vitals pattern; LoadVitalsInfos()
now delegates to the shared loader (behavior unchanged, vitals tests green)
- Add ChatLayoutConformanceTests: ResolvesKnownElements + ResolvedTypes_MatchRetailRegistry
Confirmed resolved Types from live dat:
0x10000011 (transcript) → Type 12 (style-prototype, skipped by factory)
0x10000016 (input) → Type 12 (style-prototype, skipped by factory)
0x10000014 (menu) → Type 6
0x10000012 (scrollbar) → Type 11
0x10000019 (send) → Type 1
0x1000046F (max/min) → Type 1
Also fix pre-existing build break: UiChatInput.MoveCaret(int delta) was made
private in ce848c1 but UiChatInputTests.Backspace_DeletesBeforeCaret called it
as public. Expose a public MoveCaret(int) overload (no-shift) alongside the
private MoveCaret(int,bool) — restores the intended test surface.
Full suite: 398 passed, 2 skipped (generator + pre-existing), 0 failed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
410 lines
16 KiB
C#
410 lines
16 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Numerics;
|
|
|
|
namespace AcDream.App.UI;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// 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>
|
|
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);
|
|
/// <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;
|
|
|
|
private readonly List<string> _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;
|
|
}
|
|
|
|
/// <summary>Move the caret left (negative) or right (positive) by <paramref name="delta"/>
|
|
/// glyph positions without extending a selection. Public for test access.</summary>
|
|
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) : "";
|
|
}
|
|
|
|
/// <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()
|
|
{
|
|
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 ─────────────────────────────────────────────────────────
|
|
|
|
/// <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)
|
|
{
|
|
// 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;
|
|
}
|
|
}
|