diff --git a/src/AcDream.App/UI/UiField.cs b/src/AcDream.App/UI/UiField.cs index 8cb0af79..7c760973 100644 --- a/src/AcDream.App/UI/UiField.cs +++ b/src/AcDream.App/UI/UiField.cs @@ -84,7 +84,27 @@ public sealed class UiField : UiElement public Action? OnFocusGained { get; set; } public Action? OnFocusLost { get; set; } - private string _text = ""; + private string _textValue = ""; + + /// + /// Every mutation bumps so the wrapped-line + /// cache can prove coherence. The cache is rebuilt at draw time, but + /// mouse hits arrive through input events that can precede the next + /// draw — a backspace followed by a click in the same pumped frame used + /// to hand wrap lines describing the OLD, longer + /// text, and measuring that stale range crashed with + /// ArgumentOutOfRangeException (the 2026-07-29 inscription-field crash). + /// + private string _text + { + get => _textValue; + set + { + _textValue = value; + _textVersion++; + } + } + private int _caret; private int? _selAnchor; // selection fixed end (null = no selection); span = [min,max] with _caret public string Text => _text; @@ -101,6 +121,9 @@ public sealed class UiField : UiElement private float _scrollX; // horizontal pixel scroll so the caret stays in the field private IReadOnlyList _wrappedLines = Array.Empty(); private float _wrappedLineHeight = 14f; + private int _textVersion; + private int _wrappedVersion = -1; + private float _wrappedWidth; private bool _suppressNextNewlineChar; // Held-key auto-repeat (Silk delivers one KeyDown per physical press). @@ -440,6 +463,8 @@ public sealed class UiField : UiElement float visibleHeight = MathF.Max(1f, Height - (2f * Padding)); IReadOnlyList lines = BuildWrappedLines(visibleWidth); _wrappedLines = lines; + _wrappedVersion = _textVersion; + _wrappedWidth = visibleWidth; _wrappedLineHeight = lineHeight; Scroll.LineHeight = Math.Max(1, (int)MathF.Round(lineHeight)); @@ -592,10 +617,32 @@ public sealed class UiField : UiElement ?? value.Length * 8f; } + /// + /// Rebuilds the wrapped-line cache when the text has changed since the + /// last draw. Mouse hits arrive through input events pumped BEFORE the + /// frame's draw, so a mutation (backspace, SetText, paste) followed by a + /// click in the same frame would otherwise measure ranges of the OLD + /// text against the new string — the 2026-07-29 inscription-field + /// crash. Rebuilding (rather than clamping) keeps the caret placement + /// correct, not merely non-throwing. + /// + internal void EnsureWrappedLinesCurrent() + { + if (_wrappedVersion == _textVersion && _wrappedLines.Count > 0) + return; + + float width = _wrappedWidth > 0f + ? _wrappedWidth + : MathF.Max(1f, Width - (2f * Padding)); + _wrappedLines = BuildWrappedLines(width); + _wrappedVersion = _textVersion; + } + private int HitChar(float localX, float localY) { if (OneLine) return HitCharX(localX); + EnsureWrappedLinesCurrent(); if (_wrappedLines.Count == 0) return _text.Length; diff --git a/tests/AcDream.App.Tests/UI/UiFieldTests.cs b/tests/AcDream.App.Tests/UI/UiFieldTests.cs index 8487e65b..21abf165 100644 --- a/tests/AcDream.App.Tests/UI/UiFieldTests.cs +++ b/tests/AcDream.App.Tests/UI/UiFieldTests.cs @@ -70,6 +70,64 @@ public class UiFieldTests Assert.True(input.HistoryCount <= 100); } + [Fact] + public void MultilineClick_AfterTextShrankSinceLastWrap_DoesNotThrowAndPlacesCaretInNewText() + { + // The 2026-07-29 inscription-field crash: the wrapped-line cache is + // rebuilt at DRAW time, but mouse events are pumped BEFORE the + // frame's draw — a backspace/SetText followed by a click in the same + // frame handed HitChar wrap lines describing the OLD, longer text, + // and MeasureRange threw ArgumentOutOfRangeException from + // String.Substring. HitChar must prove wrap coherence itself. + var input = new UiField + { + OneLine = false, + Selectable = true, + Width = 120, + Height = 80, + }; + input.SetText( + "a long inscription that wraps across multiple lines when it " + + "is measured with the fallback eight pixel glyph width"); + // Simulate the draw-time cache build for the CURRENT (long) text. + input.EnsureWrappedLinesCurrent(); + + // Text shrinks with no draw in between — the cached lines now + // describe ranges far beyond the live string. + input.SetText("hi"); + + // Click low and to the right, where a stale line would demand a + // substring past the end of "hi". Pre-fix: throws. Post-fix: the + // cache rebuilds and the caret lands inside the new text. + var exception = Record.Exception(() => input.OnEvent( + new UiEvent(0u, input, UiEventType.MouseDown, Data1: 90, Data2: 60))); + + Assert.Null(exception); + Assert.InRange(input.CaretPos, 0, input.Text.Length); + } + + [Fact] + public void MultilineClick_AfterBackspacesSinceLastWrap_DoesNotThrow() + { + var input = new UiField + { + OneLine = false, + Selectable = true, + Width = 96, + Height = 60, + }; + input.SetText("wrapped inscription text under edit right now"); + input.EnsureWrappedLinesCurrent(); + for (int i = 0; i < 30; i++) + input.Backspace(); + + var exception = Record.Exception(() => input.OnEvent( + new UiEvent(0u, input, UiEventType.MouseDown, Data1: 80, Data2: 40))); + + Assert.Null(exception); + Assert.InRange(input.CaretPos, 0, input.Text.Length); + } + [Fact] public void CharacterFilter_rejectsDisallowedInput() {