fix(ui): UiField wrapped-line cache coherent with the text at mouse-hit time
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run

Fixes the crash the user hit twice today (captured in
artifacts/coldeve-acceptance-20260729/crash-hunt.log): clicking into a
multiline UiField - the examination window's inscription field - after
the text had changed since the last draw threw an unhandled
ArgumentOutOfRangeException from String.Substring and took the whole
client down (UiField.MeasureRange <- HitChar <- OnEvent MouseDown).

Root cause: _wrappedLines is a DRAW-side cache (rebuilt only in
DrawMultiLine) consumed by the INPUT side (HitChar on MouseDown and
drag-select MouseMove). Input events are pumped before the frame's
draw, so a mutation (backspace, SetText, paste) followed by a click in
the same pumped frame handed HitChar wrap lines describing the OLD,
longer text; measuring those stale ranges ran past the end of the live
string.

Fix: text mutations now bump a version (the _text field became a
private property so every existing mutation site participates without
churn), the draw records which version its wrap lines describe, and
HitChar proves coherence via EnsureWrappedLinesCurrent() - rebuilding
with the last draw width when stale. Rebuilding rather than clamping
keeps caret placement CORRECT against the live text, not merely
non-throwing. Two inversion-sensitive regression tests reproduce the
exact crash sequence (wrap long text, shrink without a draw, click);
they throw without the HitChar coherence call.

App tests 3,962 passed / 3 skipped (3,960 + 2 new).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-29 19:38:52 +02:00
parent 0ccbb4e52c
commit 67379d1f9a
2 changed files with 106 additions and 1 deletions

View file

@ -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()
{