fix(vt): list column fix round 4/11 — try/finally around per-cell clip

Matches UiButton.cs:896-906 / UiElement.cs:683-720's own clip
discipline: every per-cell PushClip in DrawColumns now has its matching
PopClip in a finally, so a cell draw that throws (e.g. a plugin's icon
resolver misbehaving) doesn't leak that PushClip onto the context's
clip stack. Without this, the leaked entry combines badly with
UiElement.DrawSelfAndChildren's own outer clip pop on the way out
(it pops the wrong stack entry), permanently corrupting the shared
UiRenderContext's clip state for every draw that follows in the frame.

Added UiRenderContext.ClipStackDepth (internal, InternalsVisibleTo
AcDream.App.Tests) purely to make this provable from a test — the
number of PushClip calls not yet matched by PopClip.

New test: a column whose icon resolver throws mid-draw still leaves
the clip stack at its pre-draw depth after the exception propagates —
shown to fail first (leaked to depth 1 instead of 0, reproducing
exactly the "outer pop consumes the wrong stack entry" mechanism
described above) before the try/finally was added.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-06 21:00:41 +02:00
parent ed94acb862
commit b58668c6cd
3 changed files with 65 additions and 11 deletions

View file

@ -306,20 +306,31 @@ public sealed class UiMarkupList : UiElement
// Per-cell horizontal clipping: no column's content (an
// over-long text row above all) can bleed into its neighbor.
// Fix round item 4: try/finally around the whole per-cell
// draw (matching UiButton.cs/UiElement.cs's own clip
// discipline) — a cell draw that throws (a hostile/buggy
// plugin icon resolver, say) must still balance the clip
// stack rather than leaking this PushClip forever.
context.PushClip(cellX, y, cellW, RowHeight);
switch (columns[c].Kind)
try
{
case UiMarkupListColumnKind.Text:
DrawTextCell(context, textRows[c], colorRows[c], index, cellX, y);
break;
case UiMarkupListColumnKind.Check:
DrawCheckCell(context, checkRows[c], index, cellX, y);
break;
case UiMarkupListColumnKind.Icon:
DrawIconCell(context, columns[c], iconRows[c], index, cellX, cellW, y);
break;
switch (columns[c].Kind)
{
case UiMarkupListColumnKind.Text:
DrawTextCell(context, textRows[c], colorRows[c], index, cellX, y);
break;
case UiMarkupListColumnKind.Check:
DrawCheckCell(context, checkRows[c], index, cellX, y);
break;
case UiMarkupListColumnKind.Icon:
DrawIconCell(context, columns[c], iconRows[c], index, cellX, cellW, y);
break;
}
}
finally
{
context.PopClip();
}
context.PopClip();
}
}
}

View file

@ -115,6 +115,16 @@ public sealed class UiRenderContext
_clipStack.RemoveAt(_clipStack.Count - 1);
}
/// <summary>
/// Test-only: the number of <see cref="PushClip"/> calls not yet matched
/// by a <see cref="PopClip"/>. Used to prove a per-cell draw that throws
/// mid-draw (e.g. a plugin's icon resolver) still leaves the clip stack
/// balanced — see <see cref="UiMarkupList"/>'s per-cell
/// <c>try</c>/<c>finally</c> around <c>PushClip</c>/<c>PopClip</c> (fix
/// round item 4). <c>InternalsVisibleTo</c> to <c>AcDream.App.Tests</c>.
/// </summary>
internal int ClipStackDepth => _clipStack.Count;
/// <summary>
/// True when the current accumulated clip is non-null and has zero (or negative)
/// area — CT-GF1 fix-round subtree cull, porting retail's

View file

@ -556,6 +556,39 @@ public sealed class MarkupListColumnsTests
public IGpuFrame? CurrentFrame => null;
}
// ── Fix round: try/finally around every per-cell clip (fix item 4) ──────
[Fact]
public void PerCellDrawException_StillBalancesTheClipStack()
{
// col0's icon resolver throws mid-draw (a hostile/buggy plugin
// resolver) — the per-cell PushClip for that cell must still be
// popped before the exception propagates, matching UiButton.cs
// /UiElement.cs's own try/finally clip pattern. Without it, the one
// leaked PushClip (plus UiElement's own outer ambient-clip pop
// consuming the wrong stack entry on the way out) leaves the
// context's clip stack permanently off by one.
var list = new UiMarkupList
{
Width = 60f, Height = 40f, RowHeight = 18f,
SelectedIndexSource = () => -1,
Columns = new[]
{
UiMarkupListColumn.Icon(
60f,
() => new uint[] { 99u },
_ => throw new InvalidOperationException("boom"),
_ => { }),
},
};
var (_, ctx) = MakeContext(200f, 200f);
int depthBefore = ctx.ClipStackDepth;
Assert.Throws<InvalidOperationException>(() => list.DrawSelfAndChildren(ctx));
Assert.Equal(depthBefore, ctx.ClipStackDepth);
}
[Fact]
public void Columns_CheckThenIcon_EachCellDrawsInsideItsOwnColumnBounds()
{