feat(D.2b): UiChatInput — editable field, caret, 100-entry history (UIElement_Text port)
Ports retail UIElement_Text editable one-line mode (caret = glyph index;
caret pixel-X = sum of glyph advances via UiDatFont) and ChatInterface's
100-entry command history (up/down arrow; sentinel -1 = live line).
Submit (Enter/KeypadEnter) fires OnSubmit callback, clears, pushes history.
Draws via DrawStringDat (dat font) or DrawString (BitmapFont) fallback.
AcceptsFocus=true + IsEditControl=true so UiRoot routes Char/KeyDown to it
and suppresses global hotkeys while typing. 6 new tests, all green.
Decomp refs: UIElement_Text::MoveCursor @0x468d00,
UIElement_Text::FindPixelsFromPos @0x472b40,
ChatInterface::ProcessCommand @0x4f5100
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2940b4e3b2
commit
bcc45d668e
2 changed files with 230 additions and 0 deletions
158
src/AcDream.App/UI/UiChatInput.cs
Normal file
158
src/AcDream.App/UI/UiChatInput.cs
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
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.
|
||||||
|
/// 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);
|
||||||
|
public float Padding { get; set; } = 4f;
|
||||||
|
public int MaxCharacters { get; set; } = 0xFFFF;
|
||||||
|
|
||||||
|
public Action<string>? OnSubmit { get; set; }
|
||||||
|
|
||||||
|
private string _text = "";
|
||||||
|
private int _caret;
|
||||||
|
public string Text => _text;
|
||||||
|
public int CaretPos => _caret;
|
||||||
|
|
||||||
|
private readonly List<string> _history = new();
|
||||||
|
private int _historyIndex = -1;
|
||||||
|
public int HistoryCount => _history.Count;
|
||||||
|
|
||||||
|
public UiChatInput()
|
||||||
|
{
|
||||||
|
AcceptsFocus = true;
|
||||||
|
IsEditControl = true;
|
||||||
|
CapturesPointerDrag = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void InsertChar(char c)
|
||||||
|
{
|
||||||
|
if (c < 0x20 || c == 0x7F) return;
|
||||||
|
if (_text.Length >= MaxCharacters) return;
|
||||||
|
_text = _text.Insert(_caret, c.ToString());
|
||||||
|
_caret++;
|
||||||
|
_historyIndex = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Backspace()
|
||||||
|
{
|
||||||
|
if (_caret == 0) return;
|
||||||
|
_text = _text.Remove(_caret - 1, 1);
|
||||||
|
_caret--;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void DeleteForward()
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
|
||||||
|
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; _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;
|
||||||
|
}
|
||||||
|
|
||||||
|
public float CaretPixelX()
|
||||||
|
=> DatFont is { } df ? df.MeasureWidth(_text.Substring(0, _caret))
|
||||||
|
: Font is { } bf ? bf.MeasureWidth(_text.Substring(0, _caret)) : 0f;
|
||||||
|
|
||||||
|
private bool _focused;
|
||||||
|
|
||||||
|
protected override void OnDraw(UiRenderContext ctx)
|
||||||
|
{
|
||||||
|
ctx.DrawRect(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);
|
||||||
|
|
||||||
|
if (_focused)
|
||||||
|
{
|
||||||
|
float cx = Padding + CaretPixelX();
|
||||||
|
ctx.DrawRect(cx, ty, 1f, lh, TextColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.Char:
|
||||||
|
InsertChar((char)e.Data0);
|
||||||
|
return true;
|
||||||
|
case UiEventType.KeyDown:
|
||||||
|
{
|
||||||
|
var key = (Silk.NET.Input.Key)e.Data0;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
72
tests/AcDream.App.Tests/UI/UiChatInputTests.cs
Normal file
72
tests/AcDream.App.Tests/UI/UiChatInputTests.cs
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
using AcDream.App.UI;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.UI;
|
||||||
|
|
||||||
|
public class UiChatInputTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void InsertChar_AdvancesCaret()
|
||||||
|
{
|
||||||
|
var input = new UiChatInput();
|
||||||
|
input.InsertChar('h'); input.InsertChar('i');
|
||||||
|
Assert.Equal("hi", input.Text);
|
||||||
|
Assert.Equal(2, input.CaretPos);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Backspace_DeletesBeforeCaret()
|
||||||
|
{
|
||||||
|
var input = new UiChatInput();
|
||||||
|
foreach (var c in "abc") input.InsertChar(c);
|
||||||
|
input.MoveCaret(-1);
|
||||||
|
input.Backspace();
|
||||||
|
Assert.Equal("ac", input.Text);
|
||||||
|
Assert.Equal(1, input.CaretPos);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Submit_FiresCallback_ClearsText_PushesHistory()
|
||||||
|
{
|
||||||
|
string? sent = null;
|
||||||
|
var input = new UiChatInput { OnSubmit = t => sent = t };
|
||||||
|
foreach (var c in "hello") input.InsertChar(c);
|
||||||
|
input.Submit();
|
||||||
|
Assert.Equal("hello", sent);
|
||||||
|
Assert.Equal("", input.Text);
|
||||||
|
Assert.Equal(0, input.CaretPos);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EmptySubmit_DoesNotFire()
|
||||||
|
{
|
||||||
|
int n = 0;
|
||||||
|
var input = new UiChatInput { OnSubmit = _ => n++ };
|
||||||
|
input.Submit();
|
||||||
|
Assert.Equal(0, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void History_UpDownBrowsesPreviousSubmissions()
|
||||||
|
{
|
||||||
|
var input = new UiChatInput { OnSubmit = _ => {} };
|
||||||
|
foreach (var c in "first") input.InsertChar(c); input.Submit();
|
||||||
|
foreach (var c in "second") input.InsertChar(c); input.Submit();
|
||||||
|
input.HistoryPrev();
|
||||||
|
Assert.Equal("second", input.Text);
|
||||||
|
input.HistoryPrev();
|
||||||
|
Assert.Equal("first", input.Text);
|
||||||
|
input.HistoryNext();
|
||||||
|
Assert.Equal("second", input.Text);
|
||||||
|
input.HistoryNext();
|
||||||
|
Assert.Equal("", input.Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void History_CapsAt100()
|
||||||
|
{
|
||||||
|
var input = new UiChatInput { OnSubmit = _ => {} };
|
||||||
|
for (int i = 0; i < 150; i++) { input.InsertChar('x'); input.Submit(); }
|
||||||
|
Assert.True(input.HistoryCount <= 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue