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:
parent
07c0c2c7b9
commit
355c86a6f6
6 changed files with 388 additions and 10 deletions
|
|
@ -56,8 +56,9 @@ internal sealed class SilkRuntimeDisplayWindowTarget : IRuntimeDisplayWindowTarg
|
|||
public void Apply(DisplaySettings display)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(display);
|
||||
if (TryParseResolution(display.Resolution, out int width, out int height)
|
||||
&& (_window.Size.X != width || _window.Size.Y != height))
|
||||
bool haveResolution =
|
||||
TryParseResolution(display.Resolution, out int width, out int height);
|
||||
if (haveResolution && (_window.Size.X != width || _window.Size.Y != height))
|
||||
{
|
||||
_window.Size = new Vector2D<int>(width, height);
|
||||
}
|
||||
|
|
@ -65,6 +66,16 @@ internal sealed class SilkRuntimeDisplayWindowTarget : IRuntimeDisplayWindowTarg
|
|||
WindowState desired = display.Fullscreen
|
||||
? WindowState.Fullscreen
|
||||
: WindowState.Normal;
|
||||
|
||||
// #374 investigation note: while FULLSCREEN, the visible resolution
|
||||
// is the display's video mode, and Silk's abstract windowing API
|
||||
// cannot change it (IViewProperties.VideoMode is read-only; Silk
|
||||
// fullscreen is desktop-mode borderless). The Size write above is
|
||||
// therefore only visible in windowed mode — a resolution pick while
|
||||
// fullscreen changes what a later return to windowed restores, not
|
||||
// the fullscreen mode itself. Retail's own fullscreen resolution
|
||||
// switch (Device::ForceDisplayResolution) needs a native
|
||||
// glfwSetWindowMonitor port — issue #376.
|
||||
if (_window.WindowState != desired)
|
||||
_window.WindowState = desired;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -463,6 +463,67 @@ public sealed class UiRoot : UiElement
|
|||
WorldMouseMoveFallThrough?.Invoke(x, y);
|
||||
}
|
||||
|
||||
// ── Popup routing (#374) ────────────────────────────────────────────
|
||||
//
|
||||
// An OPEN transient popup (a UiMenu dropdown) extends its owner's
|
||||
// hit-test area beyond the owner's own rect (UiMenu.OnHitTest's
|
||||
// button+popup union). But HitTestTopDown walks SIBLINGS front-to-back
|
||||
// by z-order, and any sibling added after the owner whose rect overlaps
|
||||
// the popup area wins the walk before the owner's extended OnHitTest is
|
||||
// ever consulted — on the Options panel's Config tab every dropdown has
|
||||
// rows BELOW it, so item clicks landed on those rows instead (toggling
|
||||
// Full Screen / VSync underneath the open Resolution popup). Vendor's
|
||||
// and chat's menus only ever worked because no overlapping sibling sat
|
||||
// in front of them. While a popup is registered it gets FIRST claim on
|
||||
// pointer events; a press outside it dismisses it and is swallowed (the
|
||||
// standard dropdown-dismiss gesture — the dismissing click must not
|
||||
// fall through and act on whatever sat under the popup).
|
||||
private UiElement? _activePopup;
|
||||
private Action? _activePopupDismiss;
|
||||
|
||||
/// <summary>Registers <paramref name="popup"/> as the transient popup
|
||||
/// with first claim on pointer routing. Replaces any prior registration
|
||||
/// (its owner keeps its own open state; the previous dismiss is invoked
|
||||
/// so owner state cannot go stale).</summary>
|
||||
internal void SetActivePopup(UiElement popup, Action dismiss)
|
||||
{
|
||||
if (!ReferenceEquals(_activePopup, popup))
|
||||
_activePopupDismiss?.Invoke();
|
||||
_activePopup = popup;
|
||||
_activePopupDismiss = dismiss;
|
||||
}
|
||||
|
||||
/// <summary>Clears the registration if <paramref name="popup"/> holds it
|
||||
/// (the owner closed itself — item picked, bevel click, forced close).</summary>
|
||||
internal void ClearActivePopup(UiElement popup)
|
||||
{
|
||||
if (!ReferenceEquals(_activePopup, popup)) return;
|
||||
_activePopup = null;
|
||||
_activePopupDismiss = null;
|
||||
}
|
||||
|
||||
/// <summary>The registered popup's hit-test claim on (x,y), with stale
|
||||
/// registrations (owner hidden/detached, e.g. its window closed while
|
||||
/// open) self-healing to a dismissed, unregistered state.</summary>
|
||||
private UiElement? PopupHit(int x, int y)
|
||||
{
|
||||
if (_activePopup is not { } popup) return null;
|
||||
for (UiElement? e = popup; e is not null; e = e.Parent)
|
||||
{
|
||||
if (ReferenceEquals(e, this)) break;
|
||||
if (!e.Visible || !e.Enabled || e.Parent is null)
|
||||
{
|
||||
var stale = _activePopupDismiss;
|
||||
_activePopup = null;
|
||||
_activePopupDismiss = null;
|
||||
stale?.Invoke();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
var pp = popup.ScreenPosition;
|
||||
return popup.HitTest(x - pp.X, y - pp.Y);
|
||||
}
|
||||
|
||||
public void OnMouseDown(UiMouseButton btn, int x, int y, uint flags = 0)
|
||||
{
|
||||
MouseX = x; MouseY = y;
|
||||
|
|
@ -473,7 +534,30 @@ public sealed class UiRoot : UiElement
|
|||
if (Modal is not null && !ContainsAbsolute(Modal, x, y))
|
||||
return;
|
||||
|
||||
var (target, _, _) = HitTestTopDown(x, y);
|
||||
UiElement? target;
|
||||
if (_activePopup is not null)
|
||||
{
|
||||
target = PopupHit(x, y);
|
||||
if (target is null)
|
||||
{
|
||||
if (_activePopup is not null)
|
||||
{
|
||||
// Press outside a live popup: dismiss it, swallow the press.
|
||||
var dismiss = _activePopupDismiss;
|
||||
_activePopup = null;
|
||||
_activePopupDismiss = null;
|
||||
dismiss?.Invoke();
|
||||
return;
|
||||
}
|
||||
// Stale registration self-healed inside PopupHit — fall
|
||||
// through to the ordinary walk for this press.
|
||||
(target, _, _) = HitTestTopDown(x, y);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
(target, _, _) = HitTestTopDown(x, y);
|
||||
}
|
||||
if (target is null)
|
||||
{
|
||||
// Clicking the 3D world exits write mode (no submit) and returns control to
|
||||
|
|
@ -696,6 +780,18 @@ public sealed class UiRoot : UiElement
|
|||
|
||||
public void OnScroll(int dy)
|
||||
{
|
||||
// An open popup (dropdown) claims the wheel first — its scrollable
|
||||
// list must scroll even where a front sibling overlaps it (#374).
|
||||
if (PopupHit(MouseX, MouseY) is { } popupTarget)
|
||||
{
|
||||
var pp = popupTarget.ScreenPosition;
|
||||
var pe = new UiEvent(popupTarget.EventId, popupTarget, UiEventType.Scroll,
|
||||
Data0: dy,
|
||||
Data1: (int)(MouseX - pp.X), Data2: (int)(MouseY - pp.Y));
|
||||
BubbleEvent(popupTarget, in pe);
|
||||
return;
|
||||
}
|
||||
|
||||
// Scroll goes to the widget under the cursor (not the focused one).
|
||||
var (target, lx, ly) = HitTestTopDown(MouseX, MouseY);
|
||||
if (target is null)
|
||||
|
|
@ -956,7 +1052,11 @@ public sealed class UiRoot : UiElement
|
|||
|
||||
private void UpdateHover(int x, int y)
|
||||
{
|
||||
var (w, _, _) = HitTestTopDown(x, y);
|
||||
// An open popup claims hover first (#374) — its item highlight must
|
||||
// track the cursor even where a front sibling overlaps the popup.
|
||||
UiElement? w = PopupHit(x, y);
|
||||
if (w is null)
|
||||
(w, _, _) = HitTestTopDown(x, y);
|
||||
if (ReferenceEquals(w, _hoverWidget))
|
||||
{
|
||||
if (w?.ReceivesHoverMouseMove == true)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue