fix #374: open dropdown popups get first claim on pointer routing

Campaign OP gate 2 root cause: UiElement.HitTest walks siblings
front-to-back by z-order, so an OPEN UiMenu's extended button+popup
hit-test union was never consulted when a LATER sibling's rect overlapped
the popup area — on the Config tab every dropdown has rows below it, so
Resolution-item clicks toggled the Full Screen / VSync rows underneath
(the gate session's persisted fullscreen/vsync flips were exactly those
stolen clicks). Latent since UiMenu existed; vendor/chat menus only
worked by z-order luck.

Fix: UiMenu's open/close now registers with UiRoot (SetActivePopup /
ClearActivePopup); a registered popup gets FIRST claim on mouse-down,
scroll, and hover routing; a press outside a live popup dismisses it and
is SWALLOWED (the dismissing click must not act on what sat underneath);
hidden/detached owners self-heal the registration on the next pointer
event. UiMenu gains the IsOpen seam and a single SetOpen writer.

Also in this commit, from the same investigation:
- SilkRuntimeDisplayWindowTarget.Apply documents the fullscreen half
  honestly: IViewProperties.VideoMode is READ-ONLY, so a resolution pick
  while fullscreen cannot switch the display mode through Silk's
  abstract API — split out as #376 (native glfwSetWindowMonitor port)
  rather than half-shipping untested native interop at a gate tail.
- Gate script §OP6 step 8 re-scoped: test resolution in WINDOWED mode.

Regressed by tests/AcDream.App.Tests/UI/UiMenuPopupRoutingTests.cs —
4 tests driving the real UiRoot input path on a mounted overlapping
tree, with an in-test overlap CONTROL click so the popup assertions
cannot pass vacuously (the #372 lesson: only mount+drive-input tests
catch this class; every fixture-conformance test stayed green through
this bug).

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-11 15:17:17 +02:00
parent 07c0c2c7b9
commit 355c86a6f6
6 changed files with 388 additions and 10 deletions

View file

@ -201,6 +201,28 @@ public sealed class UiMenu : UiElement
public Vector4 TextColorGhosted { get; set; } = new(0.5f, 0.5f, 0.5f, 1f);
private bool _open;
/// <summary>Whether the popup is currently open (test/inspection seam,
/// same rationale as <see cref="PopupScroll"/>/<see cref="CurrentArrowCapSprite"/>).</summary>
public bool IsOpen => _open;
/// <summary>The ONLY writer of <see cref="_open"/>: keeps the root's
/// transient-popup registration (#374 — an open popup gets first claim
/// on pointer routing, because the sibling z-order walk would otherwise
/// hand popup-area clicks to whatever front sibling overlaps it) in
/// lockstep with the widget's own state. A detached menu (no root yet)
/// still toggles locally — registration happens against the root that
/// dispatches the events, which by construction exists whenever a real
/// pointer event reaches this widget.</summary>
private void SetOpen(bool value)
{
if (_open == value) return;
_open = value;
if (FindRoot() is not { } root) return;
if (value) root.SetActivePopup(this, () => SetOpen(false));
else root.ClearActivePopup(this);
}
// Interior = the row content; Outer = interior + the 8-piece bevel ring.
// Scrollable: always exactly one column (RowsPerColumn is the VISIBLE window,
// not a wrap threshold), widened by the docked scrollbar's own authored width.
@ -555,11 +577,11 @@ public sealed class UiMenu : UiElement
OnSelect?.Invoke(Items[idx].Payload);
}
}
_open = false;
SetOpen(false);
return true;
}
_open = !_open; // toggle on button click
SetOpen(!_open); // toggle on button click
return true;
}
@ -581,7 +603,7 @@ public sealed class UiMenu : UiElement
{
OnSelect?.Invoke(Items[idx].Payload);
}
_open = false;
SetOpen(false);
return true;
}
@ -611,7 +633,7 @@ public sealed class UiMenu : UiElement
}
// Clicked the bevel ring — close, matching the grid path.
_open = false;
SetOpen(false);
return true;
}