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

@ -84,7 +84,27 @@ public sealed class UiField : UiElement
public Action? OnFocusGained { get; set; }
public Action<string>? OnFocusLost { get; set; }
private string _text = "";
private string _textValue = "";
/// <summary>
/// Every mutation bumps <see cref="_textVersion"/> 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 <see cref="HitChar"/> wrap lines describing the OLD, longer
/// text, and measuring that stale range crashed with
/// ArgumentOutOfRangeException (the 2026-07-29 inscription-field crash).
/// </summary>
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<WrappedLine> _wrappedLines = Array.Empty<WrappedLine>();
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<WrappedLine> 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;
}
/// <summary>
/// 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.
/// </summary>
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;