fix #210: stabilize mouse-up capture ownership

Pin each mouse-up to its original captured target before callbacks can hide a window or transfer capture. Classify double-clicks before dispatch, preserve replacement capture owners, and cover both re-entrant paths so jump/combat visibility changes cannot crash the UI root.

Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-13 10:27:27 +02:00
parent db03b4bda8
commit e74efc5c06
3 changed files with 106 additions and 19 deletions

View file

@ -528,7 +528,7 @@ public sealed class UiRoot : UiElement
return;
}
if (Captured is not null)
if (Captured is { } target)
{
int rawType = btn switch
{
@ -538,46 +538,53 @@ public sealed class UiRoot : UiElement
_ => UiEventType.MouseUp,
};
var sp = Captured.ScreenPosition;
var raw = new UiEvent(Captured.EventId, Captured, rawType,
// Event callbacks may synchronously hide/remove a window or transfer
// pointer capture. Keep the mouse-down target stable for this complete
// mouse-up transaction instead of rereading the mutable global owner.
var sp = target.ScreenPosition;
var raw = new UiEvent(target.EventId, target, rawType,
Data0: (int)flags,
Data1: (int)(x - sp.X), Data2: (int)(y - sp.Y));
BubbleEvent(Captured, in raw);
BubbleEvent(target, in raw);
// If left-up over the same element that received the down, emit Click.
if (btn == UiMouseButton.Left && ContainsAbsolute(Captured, x, y))
if (btn == UiMouseButton.Left && ContainsAbsolute(target, x, y))
{
var click = new UiEvent(Captured.EventId, Captured, UiEventType.Click,
Data0: (int)flags,
Data1: (int)(x - sp.X), Data2: (int)(y - sp.Y));
BubbleEvent(Captured, in click);
long now = _nowMs != 0 ? _nowMs : Environment.TickCount64;
bool isDoubleClick =
ReferenceEquals(Captured, _lastClickTarget)
ReferenceEquals(target, _lastClickTarget)
&& now - _lastClickMs <= DoubleClickDelayMs
&& Math.Abs(x - _lastClickX) <= DragDistanceThreshold
&& Math.Abs(y - _lastClickY) <= DragDistanceThreshold;
var click = new UiEvent(target.EventId, target, UiEventType.Click,
Data0: (int)flags,
Data1: (int)(x - sp.X), Data2: (int)(y - sp.Y));
BubbleEvent(target, in click);
if (isDoubleClick)
{
var dbl = new UiEvent(Captured.EventId, Captured, UiEventType.DoubleClick,
var dbl = new UiEvent(target.EventId, target, UiEventType.DoubleClick,
Data0: (int)flags,
Data1: (int)(x - sp.X), Data2: (int)(y - sp.Y));
BubbleEvent(Captured, in dbl);
BubbleEvent(target, in dbl);
}
_lastClickTarget = Captured;
_lastClickTarget = target;
_lastClickMs = now;
_lastClickX = x;
_lastClickY = y;
}
else if (btn == UiMouseButton.Right && ContainsAbsolute(Captured, x, y))
else if (btn == UiMouseButton.Right && ContainsAbsolute(target, x, y))
{
var click = new UiEvent(Captured.EventId, Captured, UiEventType.RightClick,
var click = new UiEvent(target.EventId, target, UiEventType.RightClick,
Data0: (int)flags);
BubbleEvent(Captured, in click);
BubbleEvent(target, in click);
}
ReleaseCapture();
// A callback may have moved capture to a newly opened modal/widget.
// Release only the capture that this mouse-up is completing.
if (ReferenceEquals(Captured, target))
ReleaseCapture();
_dragCandidate = false;
return;
}