diff --git a/src/AcDream.App/UI/UiChatInput.cs b/src/AcDream.App/UI/UiChatInput.cs new file mode 100644 index 00000000..8bed6af0 --- /dev/null +++ b/src/AcDream.App/UI/UiChatInput.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.Numerics; + +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. +/// Decomp: UIElement_Text MoveCursor @0x468d00, FindPixelsFromPos @0x472b40; +/// ChatInterface ProcessCommand @0x4f5100 (history cap 100, sentinel 0xFFFFFFFF). +/// +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? OnSubmit { get; set; } + + private string _text = ""; + private int _caret; + public string Text => _text; + public int CaretPos => _caret; + + private readonly List _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; + } +} diff --git a/tests/AcDream.App.Tests/UI/UiChatInputTests.cs b/tests/AcDream.App.Tests/UI/UiChatInputTests.cs new file mode 100644 index 00000000..abbb751b --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiChatInputTests.cs @@ -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); + } +}