fix #371: straddling rows clip at the viewport edge instead of vanishing whole

Gate-3 screenshot review (user): 'the chat tab looks like it is missing
per window config' — Chat Window 1's header rendered over a void at the
DEFAULT scroll offset because its 260px self-sized filter block
straddled the viewport's bottom edge and UiScrollablePanel hid
straddling rows WHOLE (AP-201's predicted symptom, now user-observed at
scroll position zero, upgrading it from polish to blocking).

By fix time the UI renderer HAD everything needed: UiRenderContext's
clip stack (PushClip/PopClip with rect intersection + per-draw quad
clipping) and UiElement's ClipsChildren hook, already honored by both
the generic draw walk and hit-testing. The fix is therefore exactly the
shape the filing asked for, in the panel itself:
- ClipsChildren => true: children draw and hit-test clipped to the
  viewport rect.
- The layout cull keeps any INTERSECTING row Visible (was: fully-inside
  only), with a half-pixel margin excluding zero-overlap edge rows;
  fully-outside rows stay hidden as the cheap skip.

AP-201 retired in this commit (AP actives 142 -> 141); #371 closed; the
gate script's Chat-tab steps re-written to expect clean edge clipping
and to treat any whole-block vanish as a regression. Pinned by
StraddlingRow_StaysVisible_AndClipsInsteadOfVanishing (the exact gate-3
geometry: header + 260px straddler in a 430px viewport) and
ViewportClipsChildDrawingAndHitTesting (the clipped slice is not
clickable).

Full Release suite: 13,089 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-11 17:08:21 +02:00
parent a8ce010d02
commit a59e077a66
5 changed files with 118 additions and 20 deletions

View file

@ -24,6 +24,26 @@ What does NOT go here:
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
## #377 — Startup CRASH (0xC0000005 in Glfw.GetVideoMode) when settings.json has `fullscreen: true`
**Status:** OPEN — filed 2026-08-11. Reproduced deterministically on this
machine: with the persisted display settings carrying `fullscreen: true`
(left behind by #374's stolen dropdown click during gate 2), the client
dies during `GameWindow.OnLoad``GameWindowCompositionPipeline.Run`
`Silk.NET.GLFW.Glfw.GetVideoMode(Monitor*)` with an access violation —
a native AV, not a managed exception, so no graceful error path runs.
Windowed startup (`fullscreen: false`) is unaffected. Likely site:
`DisplayFramePacingController` reading `_window.Monitor?.VideoMode`
(`src/AcDream.App/Rendering/DisplayFramePacingController.cs:35`) while
the window is mid-fullscreen-transition (or `Monitor` returning a
non-null but invalid handle in that state) — to CONFIRM, not assume.
Root-cause before fixing; the fix must make fullscreen startup safe, not
suppress the read (no workarounds rule). Until then: a user whose
settings carry `fullscreen: true` cannot launch — workaround is editing
settings.json back to `false` by hand. Related: #376 (fullscreen video-
mode switching), #374 (how the value got corrupted — that entry path is
fixed).
## #376 — Fullscreen resolution picks cannot switch the display mode (Silk API limit; needs native glfwSetWindowMonitor)
**Status:** OPEN — filed 2026-08-11, split from #374's investigation.
@ -240,7 +260,23 @@ can never regress green again.
## #371 — Options-panel row viewport culls whole rows instead of clipping; tall filter blocks can vanish entirely at some scroll offsets
**Status:** OPEN — filed 2026-08-11 at the OP5 review-fix round (S2).
**Status:** DONE — fixed 2026-08-11 at the Campaign OP gate-3 fix round.
The user's gate-3 screenshot review caught the predicted symptom at the
DEFAULT scroll offset ("the chat tab looks like it is missing per window
config" — Chat Window 1's header rendered over a void, its 260px filter
block whole-row-culled, windows 2-4 below the fold). By fix time the UI
renderer HAD grown a clip stack (`UiRenderContext.PushClip`, already
honored by the generic draw walk and hit-test via `ClipsChildren`), so
the fix is exactly the shape this filing asked for: `UiScrollablePanel`
now sets `ClipsChildren => true` and culls by INTERSECTION instead of
full containment — straddling rows render their visible slice, clipped
at the viewport edge for both drawing and clicks. Register row AP-201
retired in the same commit. Pinned by
`UiScrollablePanelTests.StraddlingRow_StaysVisible_AndClipsInsteadOfVanishing`
and `ViewportClipsChildDrawingAndHitTesting`.
<!-- original filing -->
**(filed OPEN)** — 2026-08-11 at the OP5 review-fix round (S2).
`UiScrollablePanel.LayoutScrollableChildren` (`src/AcDream.App/UI/UiScrollablePanel.cs:69`)
has no scissor stack, so a row that straddles the viewport's visible edge is
hidden WHOLE (`child.Visible = top >= -0.5f && top + child.Height <=

File diff suppressed because one or more lines are too long

View file

@ -403,16 +403,15 @@ chat windows already read when deciding which lines to show.
block has all 13.
2. **Scroll the list.** All six sections are reachable; the block heights
visibly differ from a flat 100px box — each block is exactly tall enough
to show all of its own rows with no clipping and no dead space below the
last row (AP-195's self-sizing). **Known, registered behavior (AP-201,
OP5 review fix S2): the row viewport culls whole rows rather than
clipping them (no scissor stack yet — `UiScrollablePanel.cs`), so a
240-260px filter block that straddles the visible edge at a given scroll
position can vanish ENTIRELY at that exact offset rather than showing a
partially-clipped view. Keep scrolling a little further and the block
reappears whole. This is the registered cull-vs-clip divergence, not a
self-sizing regression — do not report a block's disappear-then-
reappear-whole behavior as a bug.**
to show all of its own rows with no dead space below the last row
(AP-195's self-sizing). **Gate-3 fix (#371, AP-201 retired): blocks now
CLIP at the viewport edges instead of vanishing whole.** At the default
scroll position you should see the Main Chat Window block in full, then
"Chat Window 1"'s header and as many of its checkboxes as fit, cut off
cleanly at the panel's bottom edge — scrolling reveals the rest
smoothly. A block that disappears ENTIRELY while its header stays (the
pre-fix void your gate-3 screenshot review caught) is a regression —
report it.
3. **Look closely at a row whose mask covers MULTIPLE underlying message
types** (Gameplay, Combat, Allegiance, or Fellowship — the composite-mask
rows per the research doc) versus a single-bit row (e.g. "Error" or
@ -518,10 +517,9 @@ chat windows already read when deciding which lines to show.
- Reset/Defaults not behaving as described in 11-12.
- Any block that is STILL missing rows, or leaves a large empty gap below
its last row, once fully scrolled into view (a genuine self-sizing
regression). **Not a regression:** a block that disappears entirely at
some intermediate scroll position and reappears whole a little further —
that is the registered whole-row-cull behavior (AP-201, see step 2), not
a clipping bug.
regression). A block that disappears ENTIRELY at any scroll position is
ALSO a regression now — the gate-3 fix (#371) made straddling blocks
clip at the viewport edge instead of vanishing whole (see step 2).
- Any setting that reverts to default after a full relaunch (a persistence
regression) — remember this is local-only, so a SERVER-side relog is not
the right test here (see item 15's note).

View file

@ -7,11 +7,19 @@ namespace AcDream.App.UI;
/// <summary>
/// Simple vertical viewport for controller-built row lists. It shares the same
/// pixel scroll model as chat text and item grids, and clips whole rows because
/// the UI renderer does not have a scissor stack yet.
/// pixel scroll model as chat text and item grids. A row that straddles the
/// viewport edge stays visible and is CLIPPED to the viewport (#371 —
/// <see cref="ClipsChildren"/> routes the draw walk and hit-testing through
/// <see cref="UiRenderContext.PushClip"/>); before that fix this class hid a
/// straddling row WHOLE, which made the Chat tab's 260px per-window filter
/// blocks vanish into a void at the default scroll offset (AP-201's predicted
/// symptom, user-observed at Campaign OP gate 3).
/// </summary>
public sealed class UiScrollablePanel : UiPanel
{
/// <inheritdoc/>
protected override bool ClipsChildren => true;
private readonly Dictionary<UiElement, float> _baseTops = new(ReferenceEqualityComparer.Instance);
public UiScrollable Scroll { get; } = new();
@ -66,7 +74,12 @@ public sealed class UiScrollablePanel : UiPanel
float top = baseTop - Scroll.ScrollY;
child.Top = top;
child.Visible = top >= -0.5f && top + child.Height <= Height + 0.5f;
// #371: INTERSECTION, not full containment — a straddling row stays
// visible and ClipsChildren trims it to the viewport at draw and
// hit-test time. Rows entirely outside stay hidden (cheap skip);
// the half-pixel margin excludes zero-overlap rows sitting exactly
// on either edge.
child.Visible = top + child.Height > 0.5f && top < Height - 0.5f;
}
}

View file

@ -23,6 +23,57 @@ public sealed class UiScrollablePanelTests
Assert.True(panel.Children[2].Visible);
}
[Fact]
public void StraddlingRow_StaysVisible_AndClipsInsteadOfVanishing()
{
// #371 (Campaign OP gate 3, the Chat tab's "missing per-window
// config"): a tall self-sized block (AP-195's 260px filter blocks)
// straddling the viewport's bottom edge used to be hidden WHOLE,
// leaving a void under its section header at the default scroll
// offset. It must stay Visible (the draw/hit walk clips it via
// ClipsChildren) — only rows with zero overlap are hidden.
var panel = new UiScrollablePanel { Width = 300, Height = 430, LineHeight = 20 };
var header = new UiPanel { Top = 0, Width = 300, Height = 18 };
var mainBlock = new UiPanel { Top = 18, Width = 300, Height = 260 };
var header2 = new UiPanel { Top = 278, Width = 300, Height = 18 };
var straddler = new UiPanel { Top = 296, Width = 300, Height = 260 }; // 296..556 in a 430 viewport
var below = new UiPanel { Top = 556, Width = 300, Height = 260 }; // fully outside
foreach (var c in new[] { header, mainBlock, header2, straddler, below })
panel.AddChild(c);
panel.LayoutScrollableChildren();
Assert.True(header.Visible);
Assert.True(mainBlock.Visible);
Assert.True(header2.Visible);
Assert.True(straddler.Visible); // the pre-fix bug: this was false
Assert.False(below.Visible);
// Scrolling far enough hides the first block entirely and keeps the
// straddler fully in view — intersection semantics on both edges.
panel.Scroll.SetScrollY(296);
panel.LayoutScrollableChildren();
Assert.False(mainBlock.Visible); // now fully above the viewport
Assert.True(straddler.Visible);
}
[Fact]
public void ViewportClipsChildDrawingAndHitTesting()
{
// The clip half of #371: ClipsChildren must gate the panel's hit-test
// so a straddling row's off-viewport portion cannot be clicked.
var root = new UiRoot { Width = 800, Height = 600 };
var panel = new UiScrollablePanel { Left = 0, Top = 0, Width = 300, Height = 100, LineHeight = 20 };
var row = new UiPanel { Top = 60, Width = 300, Height = 120 }; // 60..180, viewport ends at 100
panel.AddChild(row);
root.AddChild(panel);
panel.LayoutScrollableChildren();
Assert.True(row.Visible);
Assert.Same(row, root.Pick(150, 80)); // visible slice: clickable
Assert.NotSame(row, root.Pick(150, 140) ?? panel); // clipped slice: not the row
}
[Fact]
public void ScrollEvent_MovesByLineHeight()
{