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>
976 lines
39 KiB
C#
976 lines
39 KiB
C#
using System;
|
|
using System.Numerics;
|
|
|
|
namespace AcDream.App.UI;
|
|
|
|
/// <summary>Which edges of a window a resize-drag is affecting (corners combine two).</summary>
|
|
[System.Flags]
|
|
public enum ResizeEdges { None = 0, Left = 1, Right = 2, Top = 4, Bottom = 8 }
|
|
|
|
/// <summary>
|
|
/// Top-level UI container. Implements the retail <c>UIElementManager</c> responsibilities
|
|
/// (mouse cursor tracking, keyboard focus, modal overlay, mouse capture,
|
|
/// drag-drop state machine, tooltip timer). Routes Silk.NET input events
|
|
/// into the widget tree with retail-faithful <see cref="UiEvent"/>
|
|
/// semantics.
|
|
///
|
|
/// Retail analog: <c>UIElementManager::UseTime @ 0x0045CFD0</c>. Tooltip
|
|
/// deadlines are polled before global time message 3; there is no generic Device
|
|
/// timer queue in the named client.
|
|
///
|
|
/// When no widget consumes an event, the <see cref="WorldMouseFallThrough"/>
|
|
/// or <see cref="WorldKeyFallThrough"/> event fires so the game world
|
|
/// (camera, player controller) still receives input.
|
|
/// </summary>
|
|
public sealed class UiRoot : UiElement
|
|
{
|
|
public UiRoot()
|
|
{
|
|
WindowManager = new RetailWindowManager(this);
|
|
}
|
|
|
|
/// <summary>Single owner for named retained-window lifecycle and raise policy.</summary>
|
|
public RetailWindowManager WindowManager { get; }
|
|
|
|
// ── Device-level state ───────────────────────────────────────────────
|
|
public int MouseX { get; private set; }
|
|
public int MouseY { get; private set; }
|
|
public bool LeftButtonDown { get; private set; }
|
|
public bool RightButtonDown { get; private set; }
|
|
public bool MiddleButtonDown { get; private set; }
|
|
|
|
/// <summary>Widget currently receiving keyboard events.</summary>
|
|
public UiElement? KeyboardFocus { get; private set; }
|
|
|
|
/// <summary>The edit control activated by Tab/Enter when nothing is focused — retail's
|
|
/// chat input "write mode" toggle. Set by the host once the chat window is built.</summary>
|
|
public UiElement? DefaultTextInput { get; set; }
|
|
|
|
/// <summary>
|
|
/// Single modal overlay; while set, mouse clicks outside its rect
|
|
/// are ignored. Retail sets this via Device vtable +0x48.
|
|
/// </summary>
|
|
public UiPanel? Modal { get; set; }
|
|
|
|
/// <summary>Widget with mouse capture (during click-drag).</summary>
|
|
public UiElement? Captured { get; private set; }
|
|
|
|
/// <summary>
|
|
/// True when the pointer is over a widget OR a widget holds mouse capture.
|
|
/// The host ORs this into the InputDispatcher's WantCaptureMouse gate so game
|
|
/// actions (movement, world-pick) are suppressed while the user interacts with
|
|
/// a retail window — mirrors ImGui's WantCaptureMouse.
|
|
/// </summary>
|
|
public bool WantsMouse => Captured is not null || HitTestTopDown(MouseX, MouseY).element is not null;
|
|
|
|
/// <summary>True when a widget holds keyboard focus (e.g. a focused chat input).</summary>
|
|
public bool WantsKeyboard => KeyboardFocus is not null;
|
|
|
|
/// <summary>Retail PlayerModule::LockUI gate. Blocks all retained-window
|
|
/// move/resize interactions without disabling their buttons or content.</summary>
|
|
private bool _uiLocked;
|
|
public bool UiLocked
|
|
{
|
|
get => _uiLocked;
|
|
set
|
|
{
|
|
if (_uiLocked == value) return;
|
|
_uiLocked = value;
|
|
UiLockChanged?.Invoke(value);
|
|
}
|
|
}
|
|
|
|
/// <summary>Current drag source (set between drag-begin and drop/cancel).</summary>
|
|
public UiElement? DragSource { get; private set; }
|
|
public object? DragPayload { get; private set; }
|
|
public bool IsWindowMoveActive => _windowDragTarget is not null;
|
|
public ResizeEdges ActiveResizeEdges => _resizeTarget is not null ? _resizeEdges : ResizeEdges.None;
|
|
public ResizeEdges HoverResizeEdges
|
|
{
|
|
get
|
|
{
|
|
var target = Pick(MouseX, MouseY);
|
|
var window = FindWindow(target);
|
|
return !UiLocked && window is { Resizable: true }
|
|
? HitEdges(window, MouseX, MouseY, ResizeGrip)
|
|
: ResizeEdges.None;
|
|
}
|
|
}
|
|
public bool HoverWindowMove
|
|
{
|
|
get
|
|
{
|
|
var target = Pick(MouseX, MouseY);
|
|
var window = FindWindow(target);
|
|
if (UiLocked || target is null || window is not { Draggable: true })
|
|
return false;
|
|
if (HoverResizeEdges != ResizeEdges.None)
|
|
return false;
|
|
return !target.IsDragSource
|
|
&& !target.CapturesPointerDrag
|
|
&& !target.HandlesClick;
|
|
}
|
|
}
|
|
private (uint tex, int w, int h)? _dragGhost;
|
|
/// <summary>Snapshotted drag-ghost (tex,w,h), exposed for tests. See BeginDrag.</summary>
|
|
internal (uint tex, int w, int h)? DragGhostForTest => _dragGhost;
|
|
private UiElement? _lastDragHoverTarget;
|
|
private int _pressX, _pressY;
|
|
private bool _dragCandidate;
|
|
private UiElement? _windowDragTarget;
|
|
private int _windowDragOffX, _windowDragOffY;
|
|
private UiElement? _lastClickTarget;
|
|
private long _lastClickMs;
|
|
private int _lastClickX, _lastClickY;
|
|
private UiElement? _resizeTarget;
|
|
private ResizeEdges _resizeEdges;
|
|
private float _resizeStartX, _resizeStartY, _resizeStartW, _resizeStartH;
|
|
private int _resizeMouseX, _resizeMouseY;
|
|
private const int ResizeGrip = 5; // px proximity to an edge to start a resize
|
|
private const int DragDistanceThreshold = 3; // pixels, retail-observed
|
|
private const int DoubleClickDelayMs = 500;
|
|
|
|
// Hover / tooltip tracking.
|
|
private UiElement? _hoverWidget;
|
|
private long _hoverStartedMs;
|
|
public int TooltipDelayMs { get; set; } = 250;
|
|
public int TooltipDurationMs { get; set; } = 10_000;
|
|
private bool _tooltipFired;
|
|
private long _tooltipShownMs;
|
|
|
|
private long _nowMs;
|
|
|
|
/// <summary>Raised when an event was not consumed by any widget.</summary>
|
|
public event Action<UiMouseButton, int, int, uint>? WorldMouseFallThrough;
|
|
|
|
/// <summary>Raised when a key was not consumed by any widget.</summary>
|
|
public event Action<int /*vk*/, uint /*lparam*/>? WorldKeyFallThrough;
|
|
|
|
/// <summary>Raised when mouse moved and no widget captured.</summary>
|
|
public event Action<int, int>? WorldMouseMoveFallThrough;
|
|
|
|
/// <summary>Raised on scroll fall-through (world zoom, etc.).</summary>
|
|
public event Action<int /*dy*/>? WorldScrollFallThrough;
|
|
|
|
/// <summary>Raised when a drag is released over no UI element.</summary>
|
|
public event Action<object /*payload*/, int /*x*/, int /*y*/>? DragReleasedOutsideUi;
|
|
|
|
/// <summary>Raised after a registered top-level window finishes moving.</summary>
|
|
public event Action<string, UiElement>? WindowMoved;
|
|
|
|
/// <summary>Raised after a registered top-level window finishes resizing.</summary>
|
|
public event Action<string, UiElement>? WindowResized;
|
|
|
|
/// <summary>Raised after any attached element changes visibility.</summary>
|
|
public event Action<UiElement, bool>? ElementVisibilityChanged;
|
|
|
|
/// <summary>Raised after keyboard focus changes; arguments are old/new.</summary>
|
|
public event Action<UiElement?, UiElement?>? KeyboardFocusChanged;
|
|
|
|
/// <summary>Raised after pointer capture changes; arguments are old/new.</summary>
|
|
public event Action<UiElement?, UiElement?>? PointerCaptureChanged;
|
|
|
|
/// <summary>Raised after the global retained-UI lock changes.</summary>
|
|
public event Action<bool>? UiLockChanged;
|
|
|
|
private uint _nextEventId = 0x10000001u;
|
|
|
|
public override void AddChild(UiElement child)
|
|
{
|
|
AssignEventIds(child);
|
|
base.AddChild(child);
|
|
}
|
|
|
|
private void AssignEventIds(UiElement element)
|
|
{
|
|
if (element.EventId == 0)
|
|
element.EventId = _nextEventId++;
|
|
foreach (var child in element.Children)
|
|
AssignEventIds(child);
|
|
}
|
|
|
|
private static void BroadcastGlobalUiTime(UiElement element, double nowSeconds)
|
|
{
|
|
if (element is IUiGlobalTimeListener listener)
|
|
listener.OnGlobalUiTime(nowSeconds);
|
|
|
|
// A listener may synchronously close/remove a window. Snapshot the walk,
|
|
// then skip children no longer owned by this parent so a deleted subtree
|
|
// cannot receive a stale pulse and collection mutation cannot invalidate it.
|
|
foreach (var child in element.Children.ToArray())
|
|
if (ReferenceEquals(child.Parent, element))
|
|
BroadcastGlobalUiTime(child, nowSeconds);
|
|
}
|
|
|
|
internal void OnSubtreeRemoving(UiElement subtree)
|
|
{
|
|
ClearSubtreeOwnership(subtree);
|
|
WindowManager.OnSubtreeRemoving(subtree);
|
|
}
|
|
|
|
internal void OnElementVisibilityChanging(UiElement element, bool visible)
|
|
{
|
|
if (visible) return;
|
|
WindowManager.PrepareToHide(element);
|
|
ClearSubtreeOwnership(element);
|
|
}
|
|
|
|
internal void OnElementVisibilityChanged(UiElement element, bool visible)
|
|
=> ElementVisibilityChanged?.Invoke(element, visible);
|
|
|
|
internal void ClearSubtreeOwnership(UiElement subtree)
|
|
{
|
|
if (IsWithinSubtree(KeyboardFocus, subtree))
|
|
SetKeyboardFocus(null);
|
|
if (IsWithinSubtree(Captured, subtree))
|
|
{
|
|
ReleaseCapture();
|
|
_dragCandidate = false;
|
|
}
|
|
if (IsWithinSubtree(DefaultTextInput, subtree))
|
|
DefaultTextInput = null;
|
|
if (IsWithinSubtree(Modal, subtree))
|
|
Modal = null;
|
|
if (IsWithinSubtree(DragSource, subtree))
|
|
{
|
|
DragSource?.SetDragSourceActive(false, DragPayload);
|
|
DragSource = null;
|
|
DragPayload = null;
|
|
_dragGhost = null;
|
|
_dragCandidate = false;
|
|
}
|
|
if (IsWithinSubtree(_hoverWidget, subtree))
|
|
{
|
|
var leave = new UiEvent(_hoverWidget!.EventId, _hoverWidget, UiEventType.HoverLeave);
|
|
_hoverWidget.OnEvent(in leave);
|
|
_hoverWidget = null;
|
|
_tooltipFired = false;
|
|
}
|
|
if (IsWithinSubtree(_lastDragHoverTarget, subtree))
|
|
_lastDragHoverTarget = null;
|
|
if (IsWithinSubtree(_lastClickTarget, subtree))
|
|
_lastClickTarget = null;
|
|
if (IsWithinSubtree(_windowDragTarget, subtree))
|
|
{
|
|
_windowDragTarget = null;
|
|
_dragCandidate = false;
|
|
}
|
|
if (IsWithinSubtree(_resizeTarget, subtree))
|
|
{
|
|
_resizeTarget = null;
|
|
_dragCandidate = false;
|
|
}
|
|
}
|
|
|
|
private static bool IsWithinSubtree(UiElement? element, UiElement subtree)
|
|
{
|
|
while (element is not null)
|
|
{
|
|
if (ReferenceEquals(element, subtree)) return true;
|
|
element = element.Parent;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// ── Per-frame pumping ────────────────────────────────────────────────
|
|
|
|
public void Tick(double dt, long nowMs)
|
|
{
|
|
_nowMs = nowMs;
|
|
|
|
// Tooltip timer: once mouse has hovered over the same widget for
|
|
// TooltipDelayMs, fire a Tooltip event on it exactly once.
|
|
if (_hoverWidget is not null && !_tooltipFired
|
|
&& _nowMs - _hoverStartedMs >= TooltipDelayMs)
|
|
{
|
|
var e = new UiEvent(_hoverWidget.EventId, _hoverWidget, UiEventType.Tooltip);
|
|
_hoverWidget.OnEvent(in e);
|
|
_tooltipFired = true;
|
|
_tooltipShownMs = _nowMs;
|
|
}
|
|
else if (_hoverWidget is not null && _tooltipFired
|
|
&& _nowMs - _tooltipShownMs >= TooltipDurationMs)
|
|
{
|
|
var leave = new UiEvent(_hoverWidget.EventId, _hoverWidget, UiEventType.HoverLeave);
|
|
_hoverWidget.OnEvent(in leave);
|
|
_hoverWidget = null;
|
|
_tooltipFired = false;
|
|
}
|
|
|
|
BroadcastGlobalUiTime(this, nowMs / 1000d);
|
|
TickSelfAndChildren(dt);
|
|
}
|
|
|
|
public void Draw(UiRenderContext ctx)
|
|
{
|
|
// Render children (panels) sorted by z-order — modal last so it
|
|
// sits on top.
|
|
DrawSelfAndChildren(ctx);
|
|
// Second pass: open popups/menus draw ON TOP of the whole tree (so e.g. the
|
|
// chat channel menu isn't greyed by the translucent chat panel that draws
|
|
// after it in the main pass). Routed to the renderer's overlay layer so it
|
|
// beats even rect backgrounds. Faithful to retail's root-level MakePopup.
|
|
ctx.BeginOverlayLayer();
|
|
DrawOverlays(ctx);
|
|
DrawDragGhost(ctx);
|
|
ctx.EndOverlayLayer();
|
|
}
|
|
|
|
private const float GhostAlpha = 1.0f; // retail m_dragIcon is the full icon, no fade
|
|
|
|
/// <summary>Paint the drag ghost at the cursor. The texture comes from the snapshotted
|
|
/// ghost captured in <see cref="BeginDrag"/> so UiRoot stays item-agnostic and the ghost
|
|
/// survives the source cell emptying on lift; the ghost is NOT a tree element, so it
|
|
/// never intercepts hit-tests.</summary>
|
|
private void DrawDragGhost(UiRenderContext ctx)
|
|
{
|
|
if (_dragGhost is not { } g || g.tex == 0) return;
|
|
ctx.DrawSprite(g.tex, MouseX - g.w / 2f, MouseY - g.h / 2f, g.w, g.h,
|
|
0f, 0f, 1f, 1f, new Vector4(1f, 1f, 1f, GhostAlpha));
|
|
}
|
|
|
|
// ── Input entry points (called from GameWindow's Silk.NET handlers) ──
|
|
|
|
public void OnMouseMove(int x, int y)
|
|
{
|
|
int dx = x - MouseX;
|
|
int dy = y - MouseY;
|
|
MouseX = x;
|
|
MouseY = y;
|
|
|
|
// Window resize takes precedence over move / drag-drop / hover.
|
|
if (_resizeTarget is not null)
|
|
{
|
|
var (nx, ny, nw, nh) = ResizeRect(
|
|
_resizeStartX, _resizeStartY, _resizeStartW, _resizeStartH,
|
|
_resizeEdges, x - _resizeMouseX, y - _resizeMouseY,
|
|
_resizeTarget.MinWidth, _resizeTarget.MinHeight,
|
|
_resizeTarget.MaxWidth, _resizeTarget.MaxHeight);
|
|
_resizeTarget.Left = nx; _resizeTarget.Top = ny;
|
|
_resizeTarget.Width = nw; _resizeTarget.Height = nh;
|
|
return;
|
|
}
|
|
|
|
// Window-move drag takes precedence over drag-drop / hover / fall-through.
|
|
if (_windowDragTarget is not null)
|
|
{
|
|
float left = x - _windowDragOffX;
|
|
float top = y - _windowDragOffY;
|
|
if (_windowDragTarget.ConstrainDragToParent
|
|
&& _windowDragTarget.Parent is { } parent)
|
|
{
|
|
left = Math.Clamp(left, 0f, Math.Max(0f, parent.Width - _windowDragTarget.Width));
|
|
top = Math.Clamp(top, 0f, Math.Max(0f, parent.Height - _windowDragTarget.Height));
|
|
}
|
|
_windowDragTarget.Left = left;
|
|
_windowDragTarget.Top = top;
|
|
return;
|
|
}
|
|
|
|
// If we have capture, deliver MouseMove to the captured widget
|
|
// AND drive drag state machine; do NOT fall through.
|
|
if (Captured is not null)
|
|
{
|
|
DispatchMouseMove(Captured, x, y);
|
|
|
|
// Promote to drag if candidate and moved far enough.
|
|
if (_dragCandidate && DragSource is null)
|
|
{
|
|
if (Math.Abs(x - _pressX) > DragDistanceThreshold
|
|
|| Math.Abs(y - _pressY) > DragDistanceThreshold)
|
|
{
|
|
BeginDrag(Captured);
|
|
}
|
|
}
|
|
if (DragSource is not null)
|
|
UpdateDragHover(x, y);
|
|
return;
|
|
}
|
|
|
|
// Not captured: track hover for tooltips + fall through.
|
|
UpdateHover(x, y);
|
|
WorldMouseMoveFallThrough?.Invoke(x, y);
|
|
}
|
|
|
|
public void OnMouseDown(UiMouseButton btn, int x, int y, uint flags = 0)
|
|
{
|
|
MouseX = x; MouseY = y;
|
|
UpdateButtonFlag(btn, down: true);
|
|
_pressX = x; _pressY = y;
|
|
|
|
// Modal blocks clicks outside its bounds.
|
|
if (Modal is not null && !ContainsAbsolute(Modal, x, y))
|
|
return;
|
|
|
|
var (target, _, _) = HitTestTopDown(x, y);
|
|
if (target is null)
|
|
{
|
|
// Clicking the 3D world exits write mode (no submit) and returns control to
|
|
// the character — retail blurs the chat input on an outside click.
|
|
if (btn == UiMouseButton.Left) SetKeyboardFocus(null);
|
|
WorldMouseFallThrough?.Invoke(btn, x, y, flags);
|
|
return;
|
|
}
|
|
|
|
// Keyboard focus follows a left click: the input bar (an edit control) takes
|
|
// focus = enters write mode; clicking anything else (chrome, Send, scrollbar,
|
|
// menu, another window) blurs the input = exits write mode WITHOUT submitting.
|
|
if (btn == UiMouseButton.Left)
|
|
SetKeyboardFocus(target.AcceptsFocus ? target : null);
|
|
|
|
SetCapture(target);
|
|
|
|
// Window resize / move: find the window (Draggable or Resizable ancestor).
|
|
// A left-drag starting near an edge resizes; interior drag repositions;
|
|
// otherwise it's a normal drag-drop candidate.
|
|
var window = FindWindow(target);
|
|
// Retail-faithful: pressing on a window raises it above its peers.
|
|
if (window is not null) BringToFront(window);
|
|
if (btn == UiMouseButton.Left && window is not null && !UiLocked)
|
|
{
|
|
var edges = window.Resizable ? HitEdges(window, x, y, ResizeGrip) : ResizeEdges.None;
|
|
if (edges != ResizeEdges.None)
|
|
{
|
|
// Edge resize still wins, even over a CapturesPointerDrag child:
|
|
// a resizable chat window can be resized from its frame.
|
|
_resizeTarget = window;
|
|
_resizeEdges = edges;
|
|
_resizeStartX = window.Left; _resizeStartY = window.Top;
|
|
_resizeStartW = window.Width; _resizeStartH = window.Height;
|
|
_resizeMouseX = x; _resizeMouseY = y;
|
|
_dragCandidate = false;
|
|
}
|
|
else if (target.IsDragSource)
|
|
{
|
|
// A drag SOURCE (e.g. an occupied item cell) inside a Draggable window
|
|
// starts an item drag-drop, NOT a window move. UiRoot stays item-agnostic:
|
|
// it only reads the IsDragSource flag (the cell decides occupancy). The
|
|
// BeginDrag promotion happens on the >3px move (and cancels if the source's
|
|
// GetDragPayload() returns null). Empty cells are NOT drag sources, so they
|
|
// fall through to window.Draggable below (IA-12 whole-window-drag), keeping
|
|
// the bar movable by its empty cells / chrome.
|
|
_dragCandidate = true;
|
|
}
|
|
else if (target.CapturesPointerDrag || target.HandlesClick)
|
|
{
|
|
// The pressed widget owns its pointer interaction — either an interior drag (e.g. text
|
|
// selection, CapturesPointerDrag) or a click it must receive (e.g. a UiButton,
|
|
// HandlesClick). Either way do NOT move the ancestor window. The already-dispatched
|
|
// MouseDown + SetCapture(target) let the target handle it; on release OnMouseUp emits
|
|
// the Click over the same element. (A HandlesClick widget is not a drag candidate.)
|
|
_dragCandidate = false;
|
|
}
|
|
else if (window.Draggable)
|
|
{
|
|
_windowDragTarget = window;
|
|
_windowDragOffX = x - (int)window.Left;
|
|
_windowDragOffY = y - (int)window.Top;
|
|
_dragCandidate = false;
|
|
}
|
|
else { _dragCandidate = true; }
|
|
}
|
|
else if (target.CapturesPointerDrag)
|
|
{
|
|
// No window ancestor, but the target still owns its interior drag.
|
|
_dragCandidate = false;
|
|
}
|
|
else
|
|
{
|
|
_dragCandidate = true;
|
|
}
|
|
|
|
// Dispatch raw MouseDown event (retail uses WM_LBUTTONDOWN = 0x201).
|
|
int rawType = btn switch
|
|
{
|
|
UiMouseButton.Left => UiEventType.MouseDown,
|
|
UiMouseButton.Right => UiEventType.RightDown,
|
|
UiMouseButton.Middle => UiEventType.MiddleDown,
|
|
_ => UiEventType.MouseDown,
|
|
};
|
|
// Deliver TARGET-LOCAL coords (consistent with MouseMove/MouseUp, which use
|
|
// target.ScreenPosition). HitTestTopDown's lx/ly are relative to the TOP-LEVEL
|
|
// child, so for a nested target (e.g. the chat view inset inside its window)
|
|
// they'd be offset by the child's position — which mis-anchored drag-select.
|
|
var sp = target.ScreenPosition;
|
|
var e = new UiEvent(target.EventId, target, rawType,
|
|
Data0: (int)flags, Data1: (int)(x - sp.X), Data2: (int)(y - sp.Y));
|
|
BubbleEvent(target, in e);
|
|
}
|
|
|
|
public void OnMouseUp(UiMouseButton btn, int x, int y, uint flags = 0)
|
|
{
|
|
MouseX = x; MouseY = y;
|
|
UpdateButtonFlag(btn, down: false);
|
|
|
|
if (_resizeTarget is not null)
|
|
{
|
|
var resizedWindow = _resizeTarget;
|
|
_resizeTarget = null;
|
|
ReleaseCapture();
|
|
NotifyWindowResized(resizedWindow);
|
|
return;
|
|
}
|
|
|
|
if (_windowDragTarget is not null)
|
|
{
|
|
var movedWindow = _windowDragTarget;
|
|
_windowDragTarget = null;
|
|
ReleaseCapture();
|
|
NotifyWindowMoved(movedWindow);
|
|
return;
|
|
}
|
|
|
|
if (DragSource is not null)
|
|
{
|
|
FinishDrag(x, y);
|
|
ReleaseCapture();
|
|
_dragCandidate = false;
|
|
return;
|
|
}
|
|
|
|
if (Captured is { } target)
|
|
{
|
|
int rawType = btn switch
|
|
{
|
|
UiMouseButton.Left => UiEventType.MouseUp,
|
|
UiMouseButton.Right => UiEventType.RightUp,
|
|
UiMouseButton.Middle => UiEventType.MiddleUp,
|
|
_ => UiEventType.MouseUp,
|
|
};
|
|
|
|
// 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(target, in raw);
|
|
|
|
// If left-up over the same element that received the down, emit Click.
|
|
if (btn == UiMouseButton.Left && ContainsAbsolute(target, x, y))
|
|
{
|
|
long now = _nowMs != 0 ? _nowMs : Environment.TickCount64;
|
|
bool isDoubleClick =
|
|
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(target.EventId, target, UiEventType.DoubleClick,
|
|
Data0: (int)flags,
|
|
Data1: (int)(x - sp.X), Data2: (int)(y - sp.Y));
|
|
BubbleEvent(target, in dbl);
|
|
}
|
|
_lastClickTarget = target;
|
|
_lastClickMs = now;
|
|
_lastClickX = x;
|
|
_lastClickY = y;
|
|
}
|
|
else if (btn == UiMouseButton.Right && ContainsAbsolute(target, x, y))
|
|
{
|
|
var click = new UiEvent(target.EventId, target, UiEventType.RightClick,
|
|
Data0: (int)flags);
|
|
BubbleEvent(target, in click);
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
// No capture — give the world a chance.
|
|
WorldMouseFallThrough?.Invoke(btn, x, y, flags);
|
|
}
|
|
|
|
public void OnScroll(int dy)
|
|
{
|
|
// Scroll goes to the widget under the cursor (not the focused one).
|
|
var (target, lx, ly) = HitTestTopDown(MouseX, MouseY);
|
|
if (target is null)
|
|
{
|
|
WorldScrollFallThrough?.Invoke(dy);
|
|
return;
|
|
}
|
|
var e = new UiEvent(target.EventId, target, UiEventType.Scroll, Data0: dy,
|
|
Data1: (int)lx, Data2: (int)ly);
|
|
BubbleEvent(target, in e);
|
|
}
|
|
|
|
public void OnKeyDown(int vk, uint lparam = 0)
|
|
{
|
|
// Nothing focused yet: Tab or Enter enters "write mode" by focusing the chat
|
|
// input (retail's chat-activation hotkeys). Consumed so the same press doesn't
|
|
// also fall through to a game hotkey.
|
|
if (KeyboardFocus is null && DefaultTextInput is not null
|
|
&& (vk == (int)Silk.NET.Input.Key.Tab
|
|
|| vk == (int)Silk.NET.Input.Key.Enter
|
|
|| vk == (int)Silk.NET.Input.Key.KeypadEnter))
|
|
{
|
|
SetKeyboardFocus(DefaultTextInput);
|
|
return;
|
|
}
|
|
|
|
// Focus widget first.
|
|
if (KeyboardFocus is not null)
|
|
{
|
|
var e = new UiEvent(KeyboardFocus.EventId, KeyboardFocus, UiEventType.KeyDown,
|
|
Data0: vk, Data1: (int)lparam);
|
|
if (BubbleEvent(KeyboardFocus, in e)) return;
|
|
}
|
|
|
|
// If the focused widget is NOT an edit control, also consult the modal /
|
|
// top panel. Edit controls absorb all keys (prevents hotkeys while typing).
|
|
if (KeyboardFocus is null || !KeyboardFocus.IsEditControl)
|
|
{
|
|
var root = Modal ?? (UiElement)this;
|
|
var e = new UiEvent(root.EventId, root, UiEventType.KeyDown,
|
|
Data0: vk, Data1: (int)lparam);
|
|
if (BubbleEvent(root, in e)) return;
|
|
}
|
|
|
|
WorldKeyFallThrough?.Invoke(vk, lparam);
|
|
}
|
|
|
|
public void OnKeyUp(int vk, uint lparam = 0)
|
|
{
|
|
if (KeyboardFocus is not null)
|
|
{
|
|
var e = new UiEvent(KeyboardFocus.EventId, KeyboardFocus, UiEventType.KeyUp,
|
|
Data0: vk, Data1: (int)lparam);
|
|
if (BubbleEvent(KeyboardFocus, in e)) return;
|
|
}
|
|
// Key up rarely falls through; game logic generally keys off KeyDown.
|
|
}
|
|
|
|
public void OnChar(int codepoint)
|
|
{
|
|
if (KeyboardFocus is null || !KeyboardFocus.IsEditControl) return;
|
|
var e = new UiEvent(KeyboardFocus.EventId, KeyboardFocus, UiEventType.Char,
|
|
Data0: codepoint);
|
|
BubbleEvent(KeyboardFocus, in e);
|
|
}
|
|
|
|
// ── Focus + capture ─────────────────────────────────────────────────
|
|
|
|
public void SetKeyboardFocus(UiElement? e)
|
|
{
|
|
if (KeyboardFocus == e) return;
|
|
UiElement? previous = KeyboardFocus;
|
|
if (previous is not null)
|
|
{
|
|
var lost = new UiEvent(previous.EventId, previous, UiEventType.FocusLost);
|
|
previous.OnEvent(in lost);
|
|
}
|
|
KeyboardFocus = e;
|
|
if (e is not null)
|
|
{
|
|
var gained = new UiEvent(e.EventId, e, UiEventType.FocusGained);
|
|
e.OnEvent(in gained);
|
|
}
|
|
KeyboardFocusChanged?.Invoke(previous, e);
|
|
}
|
|
|
|
public void SetCapture(UiElement e)
|
|
{
|
|
if (ReferenceEquals(Captured, e)) return;
|
|
UiElement? previous = Captured;
|
|
Captured = e;
|
|
PointerCaptureChanged?.Invoke(previous, e);
|
|
}
|
|
|
|
public void ReleaseCapture()
|
|
{
|
|
UiElement? previous = Captured;
|
|
Captured = null;
|
|
// Retail restarts the tooltip idle deadline when capture is released.
|
|
_hoverStartedMs = _nowMs;
|
|
_tooltipFired = false;
|
|
if (previous is not null)
|
|
PointerCaptureChanged?.Invoke(previous, null);
|
|
}
|
|
|
|
// ── Window manager (named top-level windows: Show / Hide / Toggle) ───
|
|
|
|
// Registry state lives in RetailWindowManager; methods below are compatibility forwarders.
|
|
|
|
/// <summary>Register a top-level window under a name for Show/Hide/Toggle.
|
|
/// Does NOT add it to the tree — the caller mounts via AddChild and controls
|
|
/// initial Visible. Idempotent registration returns the existing typed handle;
|
|
/// replacement performs full lifecycle teardown of the prior registration.</summary>
|
|
public RetailWindowHandle RegisterWindow(
|
|
string name,
|
|
UiElement window,
|
|
UiElement? contentRoot = null,
|
|
IRetainedPanelController? controller = null,
|
|
IRetainedWindowStateController? stateController = null)
|
|
=> WindowManager.Register(name, window, contentRoot, controller, stateController);
|
|
|
|
public bool UnregisterWindow(string name) => WindowManager.Unregister(name);
|
|
|
|
/// <summary>Make the named window visible. No-op (returns false) if unknown.</summary>
|
|
public bool ShowWindow(string name)
|
|
=> WindowManager.Show(name);
|
|
|
|
/// <summary>Hide the named window. No-op (returns false) if unknown.</summary>
|
|
public bool HideWindow(string name)
|
|
=> WindowManager.Hide(name);
|
|
|
|
public bool CloseWindow(string name) => WindowManager.Close(name);
|
|
|
|
/// <summary>Return the current visibility of a registered window.</summary>
|
|
public bool IsWindowVisible(string name)
|
|
=> WindowManager.IsVisible(name);
|
|
|
|
/// <summary>Flip the named window's visibility (Show if hidden, Hide if shown).
|
|
/// Returns the new IsVisible state (false for an unknown name).</summary>
|
|
public bool ToggleWindow(string name)
|
|
=> WindowManager.Toggle(name);
|
|
|
|
/// <summary>Raise a top-level window above its siblings by setting its ZOrder
|
|
/// one past the current max among the OTHER top-level children. Used on Show
|
|
/// and on click. Leaves ZOrder unchanged if it is the only / already-topmost child.</summary>
|
|
public void BringToFront(UiElement window)
|
|
=> WindowManager.BringToFront(window);
|
|
|
|
internal void NotifyWindowMoved(UiElement window)
|
|
{
|
|
if (WindowManager.TryGet(window, out var handle))
|
|
WindowMoved?.Invoke(handle.Name, window);
|
|
}
|
|
|
|
internal void NotifyWindowResized(UiElement window)
|
|
{
|
|
if (WindowManager.TryGet(window, out var handle))
|
|
WindowResized?.Invoke(handle.Name, window);
|
|
}
|
|
|
|
// ── Drag-drop (retail event chain 0x15 → 0x21 → 0x1C → 0x3E) ────────
|
|
|
|
private void BeginDrag(UiElement source)
|
|
{
|
|
var payload = source.GetDragPayload();
|
|
if (payload is null) { _dragCandidate = false; return; }
|
|
DragSource = source;
|
|
DragPayload = payload;
|
|
_dragGhost = source.GetDragGhost(); // snapshot NOW — the DragBegin handler may empty the source cell
|
|
var e = new UiEvent(source.EventId, source, UiEventType.DragBegin, Payload: payload);
|
|
source.OnEvent(in e);
|
|
// Retail UIElement_ItemList::ItemList_BeginDrag @ 0x004E32D0 selects an
|
|
// unselected item before enabling its waiting mesh. Keep that order so a
|
|
// press-drag shows the selection indicator on its very first frame.
|
|
source.SetDragSourceActive(true, payload);
|
|
}
|
|
|
|
private void UpdateDragHover(int x, int y)
|
|
{
|
|
var (t, lx, ly) = HitTestTopDown(x, y);
|
|
if (ReferenceEquals(t, _lastDragHoverTarget)) return;
|
|
|
|
// Leave old target.
|
|
if (_lastDragHoverTarget is not null)
|
|
{
|
|
var eLeave = new UiEvent(DragSource!.EventId, _lastDragHoverTarget,
|
|
UiEventType.DragOver, Data1: x, Data2: y,
|
|
Payload: DragPayload);
|
|
_lastDragHoverTarget.OnEvent(in eLeave);
|
|
}
|
|
|
|
// Enter new target.
|
|
if (t is not null)
|
|
{
|
|
var eEnter = new UiEvent(DragSource!.EventId, t, UiEventType.DragEnter,
|
|
Data1: (int)lx, Data2: (int)ly,
|
|
Payload: DragPayload);
|
|
t.OnEvent(in eEnter);
|
|
}
|
|
_lastDragHoverTarget = t;
|
|
}
|
|
|
|
private void FinishDrag(int x, int y)
|
|
{
|
|
UiElement? source = DragSource;
|
|
object? payload = DragPayload;
|
|
|
|
// Retail's source UIItem receives the release and hides m_elem_Icon_Ghosted
|
|
// before the target handles the move. Keep this at the root lifecycle boundary so
|
|
// releases over world space and non-item widgets clear the same state deterministically.
|
|
source?.SetDragSourceActive(false, payload);
|
|
|
|
var (t, lx, ly) = HitTestTopDown(x, y);
|
|
if (t is not null)
|
|
{
|
|
// Dropped on a real element — deliver DropReleased; the hit cell's handler places.
|
|
// A non-item target's OnEvent ignores it, so an off-bar drop leaves the lift's removal.
|
|
var e = new UiEvent(source!.EventId, t, UiEventType.DropReleased,
|
|
Data1: (int)lx, Data2: (int)ly, Payload: payload);
|
|
t.OnEvent(in e);
|
|
}
|
|
else if (payload is not null)
|
|
{
|
|
DragReleasedOutsideUi?.Invoke(payload, x, y);
|
|
}
|
|
DragSource = null;
|
|
DragPayload = null;
|
|
_dragGhost = null;
|
|
_lastDragHoverTarget = null;
|
|
}
|
|
|
|
// ── Hover / tooltip ─────────────────────────────────────────────────
|
|
|
|
private void UpdateHover(int x, int y)
|
|
{
|
|
var (w, _, _) = HitTestTopDown(x, y);
|
|
if (ReferenceEquals(w, _hoverWidget)) return;
|
|
|
|
if (_hoverWidget is not null)
|
|
{
|
|
var leave = new UiEvent(_hoverWidget.EventId, _hoverWidget, UiEventType.HoverLeave);
|
|
_hoverWidget.OnEvent(in leave);
|
|
}
|
|
_hoverWidget = w;
|
|
_hoverStartedMs = _nowMs;
|
|
_tooltipFired = false;
|
|
if (w is not null)
|
|
{
|
|
var enter = new UiEvent(w.EventId, w, UiEventType.HoverEnter);
|
|
w.OnEvent(in enter);
|
|
}
|
|
}
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────────
|
|
|
|
public void FireEvent(int type, UiElement target, object? payload = null)
|
|
{
|
|
var e = new UiEvent(target.EventId, target, type, Payload: payload);
|
|
target.OnEvent(in e);
|
|
}
|
|
|
|
private void UpdateButtonFlag(UiMouseButton b, bool down)
|
|
{
|
|
switch (b)
|
|
{
|
|
case UiMouseButton.Left: LeftButtonDown = down; break;
|
|
case UiMouseButton.Right: RightButtonDown = down; break;
|
|
case UiMouseButton.Middle: MiddleButtonDown = down; break;
|
|
}
|
|
}
|
|
|
|
private (UiElement? element, float localX, float localY) HitTestTopDown(int x, int y)
|
|
{
|
|
// Modal gets exclusive hit-test.
|
|
if (Modal is not null)
|
|
{
|
|
var mp = Modal.ScreenPosition;
|
|
var mh = Modal.HitTest(x - mp.X, y - mp.Y);
|
|
if (mh is not null) return (mh, x - mp.X, y - mp.Y);
|
|
return (null, 0, 0);
|
|
}
|
|
|
|
// Walk top-level children in reverse Z-order (topmost first).
|
|
var kids = new UiElement[Children.Count];
|
|
for (int i = 0; i < Children.Count; i++) kids[i] = Children[i];
|
|
Array.Sort(kids, static (a, b) => b.ZOrder.CompareTo(a.ZOrder));
|
|
foreach (var c in kids)
|
|
{
|
|
var cp = c.ScreenPosition;
|
|
var hit = c.HitTest(x - cp.X, y - cp.Y);
|
|
if (hit is not null)
|
|
return (hit, x - cp.X, y - cp.Y);
|
|
}
|
|
return (null, 0, 0);
|
|
}
|
|
|
|
/// <summary>Public hit-test for tooling (the UI Studio inspector): the topmost element under
|
|
/// (x,y) in root space, honoring modal exclusivity + Z-order. Wraps the private HitTestTopDown.</summary>
|
|
public UiElement? Pick(int x, int y) => HitTestTopDown(x, y).element;
|
|
|
|
private static UiElement? FindWindow(UiElement? e)
|
|
{
|
|
while (e is not null)
|
|
{
|
|
if (e.Draggable || e.Resizable) return e;
|
|
e = e.Parent;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// <summary>Which edges of <paramref name="w"/>'s screen rect the point
|
|
/// (<paramref name="x"/>,<paramref name="y"/>) is within <paramref name="grip"/> px of.
|
|
/// None if the point is outside the grip-expanded box entirely.</summary>
|
|
internal static ResizeEdges HitEdges(UiElement w, int x, int y, int grip)
|
|
{
|
|
float l = w.Left, t = w.Top, r = w.Left + w.Width, b = w.Top + w.Height;
|
|
if (x < l - grip || x > r + grip || y < t - grip || y > b + grip) return ResizeEdges.None;
|
|
var e = ResizeEdges.None;
|
|
if (System.Math.Abs(x - l) <= grip) e |= ResizeEdges.Left;
|
|
if (System.Math.Abs(x - r) <= grip) e |= ResizeEdges.Right;
|
|
if (System.Math.Abs(y - t) <= grip) e |= ResizeEdges.Top;
|
|
if (System.Math.Abs(y - b) <= grip) e |= ResizeEdges.Bottom;
|
|
if (!w.ResizeX) e &= ~(ResizeEdges.Left | ResizeEdges.Right);
|
|
if (!w.ResizeY) e &= ~(ResizeEdges.Top | ResizeEdges.Bottom);
|
|
e &= w.ResizableEdges;
|
|
return e;
|
|
}
|
|
|
|
/// <summary>Compute a resized rect from a start rect + drag delta + which edges,
|
|
/// clamping to (<paramref name="minW"/>,<paramref name="minH"/>) and
|
|
/// (<paramref name="maxW"/>,<paramref name="maxH"/>). Left/Top edges move the
|
|
/// origin so the opposite edge stays put.</summary>
|
|
public static (float x, float y, float w, float h) ResizeRect(
|
|
float startX, float startY, float startW, float startH,
|
|
ResizeEdges edges, float dx, float dy, float minW, float minH, float maxW, float maxH)
|
|
{
|
|
float x = startX, y = startY, w = startW, h = startH;
|
|
if ((edges & ResizeEdges.Right) != 0) w = System.Math.Clamp(startW + dx, minW, maxW);
|
|
if ((edges & ResizeEdges.Bottom) != 0) h = System.Math.Clamp(startH + dy, minH, maxH);
|
|
if ((edges & ResizeEdges.Left) != 0) { float nw = System.Math.Clamp(startW - dx, minW, maxW); x = startX + (startW - nw); w = nw; }
|
|
if ((edges & ResizeEdges.Top) != 0) { float nh = System.Math.Clamp(startH - dy, minH, maxH); y = startY + (startH - nh); h = nh; }
|
|
return (x, y, w, h);
|
|
}
|
|
|
|
private static bool ContainsAbsolute(UiElement e, int x, int y)
|
|
{
|
|
var sp = e.ScreenPosition;
|
|
return x >= sp.X && x < sp.X + e.Width
|
|
&& y >= sp.Y && y < sp.Y + e.Height;
|
|
}
|
|
|
|
private void DispatchMouseMove(UiElement target, int x, int y)
|
|
{
|
|
var sp = target.ScreenPosition;
|
|
var e = new UiEvent(target.EventId, target, UiEventType.MouseMove,
|
|
Data1: (int)(x - sp.X), Data2: (int)(y - sp.Y));
|
|
BubbleEvent(target, in e);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Call <see cref="UiElement.OnEvent"/> on <paramref name="start"/>;
|
|
/// if it returns false, walk the Parent chain.
|
|
/// </summary>
|
|
private bool BubbleEvent(UiElement start, in UiEvent e)
|
|
{
|
|
var w = start;
|
|
while (w is not null)
|
|
{
|
|
if (w.OnEvent(in e)) return true;
|
|
w = w.Parent;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
protected override void OnDraw(UiRenderContext ctx)
|
|
{
|
|
// Root itself draws nothing; children do.
|
|
}
|
|
}
|