acdream/src/AcDream.App/UI/UiRoot.cs
Erik 9d9280a069 fix(ui): morning gate — world tooltips ride retail's mouse-idle dwell, not the found edge
User finding 1 (side-by-side vs retail): our world-object tooltips popped
the instant the found object changed; retail's "lag". The night round's
derivation from RecvNotice_SmartBoxObjectFound @0x004E5AD0 misread the
notice as edge-MOUNTING: its immediate StartTooltipAtMouse @0x004E5DFB is
inside `if (s_pInstance->m_dragElement != 0)` (@0x004E5D8E) — and
m_dragElement is a real, distinct PDB field in acclient.h's
UIElementManager (separate from the m_pTooltipElement family), so the
immediate mount is DRAG-AND-DROP ONLY. The ordinary hover path merely
STAGES the name (SetTooltip @0x004E5D74 + the |=0x20 TooltipOn bit) and
the display rides the SAME UIElementManager::CheckTooltip @0x0045B6E0
mouse-idle dwell as UI tooltips: 250 ms (m_tooltipDelay @0x0045f75d)
since m_lastMouseMoveTime (stamped on EVERY move, MouseMoveHandler
@0x0045e736). Found swaps under an IDLE mouse replace the popup the same
frame (SetTooltip's own text-change teardown @0x004617FF -> ResetTooltip
@0x0045C360 tail-calling CheckTooltip); the 10 s duration expiry
(@0x0045b78a) requires a fresh mouse move before re-arming
(SwitchMouseOver(null) @0x0045b7b2 clears m_pElementLastEntered).

Port: UiRoot gains the unconditional last-mouse-move stamp
(m_lastMouseMoveTime 1:1 — the existing _hoverStartedMs stamps are
deliberately conditional) exposed as MouseIdleMs/NowMs;
RetailTooltipPresenter.UpdateWorldHoverTooltip now stages text at the
notice edge (ShowTooltips gate + name resolve read there, @0x004E5D21/
@0x004E5D3B, empty-name SetTooltip skip @0x004E5D48 included) and mounts
via the CheckTooltip dwell block (no-capture gate @0x0045b715,
m_tooltipEnable via MouseHover @0x0046254C — which the drag-immediate
branch faithfully bypasses). Session reset also forgets the staged text.

Tests: the world-hover fixture section rewritten to the corrected model —
found edge stages but never mounts before the dwell; a continuously
moving mouse never mounts until it rests; idle found-swap replaces
same-frame without stacking; duration auto-hide needs a move + fresh
dwell to remount; drag-in-progress mounts immediately. 38/38 pass.

Register TS-85 and ISSUES item 2 corrected honestly: the "edge-fired
(no dwell)" conclusion is superseded by the user's retail evidence and
the m_dragElement branch read.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 08:49:11 +02:00

1531 lines
67 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
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; }
/// <summary>
/// Campaign LA gate round 2 (register AD-98): when set, the retained tree
/// is laid out in this fixed authored canvas (the char-select screen's
/// 800×600) and the whole tree — widgets, glyphs, art — is stretched to
/// the window as one unit, matching retail's present-time frame stretch
/// for fixed-canvas pre-world screens. Draw applies the scale at the
/// renderer's quad chokepoint; the mouse entry points apply the inverse,
/// so <see cref="MouseX"/>/<see cref="MouseY"/> and every hit test live
/// in canvas space. Null (the in-world default) is native 1:1.
///
/// <para>
/// Campaign CC slice CC4 review-fix round R1 (2026-08-15): this raw
/// setter remains public for tests that exercise the scale/mouse-
/// mapping math in isolation (<c>UiRootFixedCanvasTests</c>), but
/// PRODUCTION code must go through <see cref="DeclareFixedCanvas"/>/
/// <see cref="RevokeFixedCanvas"/> instead of writing this property
/// directly. Two fixed-canvas screens can be active at once
/// (character-management underneath character-creation) and a raw
/// write from either one is a last-writer-wins race with no owner —
/// the F1 fix's own <c>Close()</c> null wiped the OTHER screen's still-
/// active canvas out from under it (see AD-98).
/// </para>
/// </summary>
public Vector2? FixedCanvasSize { get; set; }
/// <summary>Screens currently declaring a fixed canvas, keyed by owner
/// (see <see cref="DeclareFixedCanvas"/>).</summary>
private readonly Dictionary<object, Vector2> _fixedCanvasDeclarations = new();
/// <summary>
/// Declares that <paramref name="owner"/> wants the retained tree laid
/// out in <paramref name="size"/> while it is active. This is the single
/// arbiter for <see cref="FixedCanvasSize"/>: multiple owners may declare
/// concurrently (character-management stays declared while character-
/// creation is also open on top of it), and the effective
/// <see cref="FixedCanvasSize"/> is the shared declaration set's value.
/// Every current declarer must agree on the size — a mismatched second
/// declaration throws rather than silently overwriting the first
/// (Campaign CC CC4 review-fix round R1, 2026-08-15; see
/// <c>docs/architecture/retail-divergence-register.md</c> AD-98). Pair
/// every call with <see cref="RevokeFixedCanvas"/> on the SAME owner at
/// deactivate/close/dispose.
/// </summary>
public void DeclareFixedCanvas(object owner, Vector2 size)
{
ArgumentNullException.ThrowIfNull(owner);
if (_fixedCanvasDeclarations.TryGetValue(owner, out Vector2 existing))
{
if (existing == size)
return; // idempotent re-declare (e.g. a re-ticked activation edge)
throw new InvalidOperationException(
$"UiRoot.DeclareFixedCanvas: owner {owner} re-declared a different " +
$"canvas ({existing} -> {size}) without revoking first.");
}
foreach (Vector2 declared in _fixedCanvasDeclarations.Values)
{
if (declared != size)
{
throw new InvalidOperationException(
$"UiRoot.DeclareFixedCanvas: owner {owner} declared {size} but " +
$"another active owner already declared {declared} — every " +
"concurrently-active fixed-canvas screen must author the SAME " +
"canvas size (see AD-98).");
}
}
_fixedCanvasDeclarations[owner] = size;
FixedCanvasSize = size;
}
/// <summary>Revokes <paramref name="owner"/>'s declaration from
/// <see cref="DeclareFixedCanvas"/>. <see cref="FixedCanvasSize"/>
/// becomes null only once EVERY declarer has revoked; while another
/// owner is still declared, it stays set to that shared value. A
/// revoke from an owner that never declared (or already revoked) is a
/// no-op, matching the idempotent shutdown paths (<c>Deactivate</c>
/// AND <c>Dispose</c> can both revoke the same owner).</summary>
public void RevokeFixedCanvas(object owner)
{
ArgumentNullException.ThrowIfNull(owner);
if (!_fixedCanvasDeclarations.Remove(owner))
return;
if (_fixedCanvasDeclarations.Count == 0)
{
FixedCanvasSize = null;
return;
}
foreach (Vector2 declared in _fixedCanvasDeclarations.Values)
{
FixedCanvasSize = declared;
break;
}
}
/// <summary>
/// The coordinate space the retained tree currently lays out in: the fixed
/// authored canvas while one is active, else the window itself. Anything
/// that positions against "the screen" (dialog centering, full-screen
/// scrims) must use THIS — the gate-round-2 exit dialog centered against
/// the 1920px window while the tree lived in the 800px canvas, landing far
/// right of the visible screen center.
/// </summary>
public Vector2 EffectiveCanvasSize =>
FixedCanvasSize is { X: > 0f, Y: > 0f } canvas
? canvas
: new Vector2(Width, Height);
/// <summary>Window→canvas stretch factor; One when no fixed canvas is set.</summary>
public Vector2 CanvasScale =>
FixedCanvasSize is { X: > 0f, Y: > 0f } canvas && Width > 0f && Height > 0f
? new Vector2(Width / canvas.X, Height / canvas.Y)
: Vector2.One;
private (int x, int y) MapWindowToCanvas(int x, int y)
{
// Truncate, not round (batch review F6): rounding maps the window's
// last column/row one past the canvas's last valid coordinate
// (1919/2.4 → 800, past 799), creating a 1px dead band at the far
// right/bottom edge. Truncation maps 0..1919 onto 0..799 exactly.
Vector2 scale = CanvasScale;
return scale == Vector2.One
? (x, y)
: ((int)(x / scale.X), (int)(y / scale.Y));
}
// ── 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);
if (UiLocked || window is not { Resizable: true })
return ResizeEdges.None;
// A directly-hovered authored Resizebar grip (retail element class 9)
// is precise geometry, not a proximity heuristic — it wins outright.
var gripEdges = EffectiveGripEdges(target, window);
if (gripEdges != ResizeEdges.None)
return gripEdges;
// An authored move handle (retail UIElement_Dragbar, class 2) directly
// under the cursor takes precedence over ambient edge-proximity — e.g.
// the main chat window's top strip moves the window even though its
// own top-left/top-right CORNERS (separate Resizebar grips) resize it
// (docs/research/2026-08-09-chat-retail-window-shell.md §2.1/§2.3).
if (FindDragHandleWindow(target) is not null)
return ResizeEdges.None;
return HitEdges(window, MouseX, MouseY, ResizeGrip);
}
}
/// <summary>
/// Resolve the resize edges a literal, directly-hit <see cref="UiResizeGrip"/>
/// contributes, masked by the window's own <see cref="UiElement.ResizeX"/> /
/// <see cref="UiElement.ResizeY"/> axis gates and <see cref="UiElement.ResizableEdges"/>
/// mask (the same gates <see cref="HitEdges"/> applies to its proximity heuristic).
/// Returns <see cref="ResizeEdges.None"/> when <paramref name="target"/> is not
/// itself an authored grip.
/// </summary>
private static ResizeEdges EffectiveGripEdges(UiElement? target, UiElement? window)
{
if (window is null || target is not UiResizeGrip grip)
return ResizeEdges.None;
ResizeEdges edges = grip.Edges;
if (!window.ResizeX) edges &= ~(ResizeEdges.Left | ResizeEdges.Right);
if (!window.ResizeY) edges &= ~(ResizeEdges.Top | ResizeEdges.Bottom);
edges &= window.ResizableEdges;
return edges;
}
public bool HoverWindowMove
{
get
{
var target = Pick(MouseX, MouseY);
if (UiLocked || target is null)
return false;
if (HoverResizeEdges != ResizeEdges.None)
return false;
// An authored drag handle (retail UIElement_Dragbar, class 2) shows the
// move cursor even on a window that is not whole-surface Draggable.
if (FindDragHandleWindow(target) is not null)
return true;
var window = FindWindow(target);
if (window is not { Draggable: true })
return false;
// 2026-08-13 gate (user-directed, all windows): the move cursor
// advertises ONLY on the window's own border chrome. Round 3
// correction: "the frame element won the hit-test" is NOT a
// border test — a window whose interior is not fully covered by
// content children (the inventory panel's empty regions) resolves
// those interior pixels to the frame too. The border is a
// GEOMETRIC band along the window's outer edge; the frame must
// still be the hit-test winner so border-adjacent content keeps
// its own cursor. Whole-surface dragging still WORKS; it just no
// longer advertises over content or empty interior.
return ReferenceEquals(target, window)
&& WithinBorderBand(window, MouseX, MouseY, MoveBorderBand);
}
}
/// <summary>The point lies inside the window and within <paramref name="band"/>
/// pixels of one of its outer edges — the frame chrome the move cursor
/// advertises on. Unlike <see cref="HitEdges"/>, no resize-axis masking:
/// a non-resizable window's border still moves it.</summary>
private static bool WithinBorderBand(UiElement w, int x, int y, int band)
{
float l = w.Left, t = w.Top, r = w.Left + w.Width, b = w.Top + w.Height;
if (x < l || x >= r || y < t || y >= b)
return false;
return x - l < band || r - x <= band || y - t < band || b - y <= band;
}
/// <summary>Move-cursor border thickness in px — sized to the retail frame
/// chrome art so the ring stays visible inside the <see cref="ResizeGrip"/>
/// claim on resizable edges.</summary>
private const int MoveBorderBand = 8;
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;
/// <summary>Global hover-dwell delay in ms before a tooltip fires — retail
/// <c>UIElementManager::m_tooltipDelay</c>, default 0.25 s
/// (<c>UIElementManager::UIElementManager @0x0045F5D0</c>), the
/// <c>Misc.TooltipDelay</c> preference. A hovered widget's own
/// <see cref="UiElement.AuthoredTooltipDelaySeconds"/> (dat property
/// <c>0x50</c>) overrides this per <c>UIElementManager::CheckTooltip
/// @0x0045B6E0</c> — see <see cref="EffectiveTooltipDelayMs"/>.</summary>
public int TooltipDelayMs { get; set; } = 250;
/// <summary>How long a shown tooltip stays up before auto-hiding — retail
/// <c>m_tooltipDuration</c>, a fixed 10 s
/// (<c>UIElementManager::CheckTooltip @0x0045B6E0</c>'s own auto-hide
/// check); not a user preference.</summary>
public int TooltipDurationMs { get; set; } = 10_000;
private bool _tooltipFired;
private long _tooltipShownMs;
/// <summary>
/// #409: fired once, synchronously, when a hovered widget's dwell delay
/// elapses (retail's <c>UIElementManager::StartHover</c> ->
/// <c>UIElement::MouseHover</c> edge). <see cref="RetailTooltipPresenter"/>
/// is the production consumer — it decides whether the widget actually
/// authors a tooltip and, if so, builds/positions the popup.
/// </summary>
public event Action<UiElement>? TooltipShow;
/// <summary>
/// #409: fired when a previously-shown tooltip must go away — hover
/// left the widget (<see cref="UpdateHover"/>), the widget's subtree is
/// being removed (<see cref="ClearSubtreeOwnership"/>), or the shown
/// duration elapsed (<see cref="Tick"/>'s auto-hide branch). Only fires
/// if <see cref="TooltipShow"/> actually fired for this widget first
/// (mirrors retail's own <c>m_pTooltipElement != null</c> guard at every
/// one of those three call sites).
/// </summary>
public event Action<UiElement>? TooltipHide;
/// <summary>Retail <c>UIElementManager::CheckTooltip @0x0045B6E0</c>: a
/// per-element FLOAT delay override (dat property <c>0x50</c>) replaces
/// the global <see cref="TooltipDelayMs"/> when the hovered element
/// authors one.</summary>
private int EffectiveTooltipDelayMs(UiElement widget)
=> widget.AuthoredTooltipDelaySeconds is { } seconds
? (int)(seconds * 1000f)
: TooltipDelayMs;
private long _nowMs;
/// <summary>Retail <c>UIElementManager::m_lastMouseMoveTime</c>, ported
/// 1:1: stamped UNCONDITIONALLY at the top of every mouse move
/// (<c>MouseMoveHandler @0x0045E710</c>, <c>@0x0045e729</c>/<c>@0x0045e736</c>
/// — before hit-testing, capture handling, everything) and re-stamped on
/// capture release (<c>ReleaseMouseCapture @0x0045D2B0</c>,
/// <c>@0x0045d2da</c>). Distinct from <see cref="_hoverStartedMs"/>, whose
/// stamps are deliberately conditional (the <c>!_tooltipFired</c> guard in
/// <see cref="UpdateHover"/>, no stamp during captured moves) because that
/// field also carries <c>m_bHoverStarted</c> interplay. The world-hover
/// tooltip's idle-dwell gate (<see cref="Layout.RetailTooltipPresenter"/>)
/// needs retail's raw, unconditional timestamp.</summary>
private long _lastMouseMoveMs;
/// <summary>Milliseconds since the last mouse move — retail
/// <c>CheckTooltip @0x0045B6E0</c>'s dwell operand
/// (<c>@0x0045b747</c>: <c>m_lastMouseMoveTime + delay</c> vs now).</summary>
public long MouseIdleMs => _nowMs - _lastMouseMoveMs;
/// <summary>The clock <see cref="Tick"/> last ran at — retail
/// <c>Timer::local_time</c> as the UI tree sees it. Exposed for sibling
/// per-frame consumers (<see cref="Layout.RetailTooltipPresenter"/>'s
/// world-tooltip duration clock) so they share ONE frame timestamp.</summary>
public long NowMs => _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.ChildrenBackToFrontSnapshot())
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);
// #409: retail UIElementManager::DeletingElement @0x0045E520 tears
// down the active tooltip when its owner element is removed
// (m_pTooltipOwner == ebp). Only fire if a tooltip actually
// showed for this widget (mirrors that null-guarded check).
if (_tooltipFired)
TooltipHide?.Invoke(_hoverWidget);
_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 its
// effective dwell delay, fire a Tooltip event on it exactly once.
// #409 F6: retail's arm branch is gated `m_pElementWithMouseCapture
// == 0` (UIElementManager::CheckTooltip @0x0045B6E0, @0x0045b715) —
// a widget hovered before a drag/resize/scrollbar-thumb capture
// began must not pop a tooltip mid-gesture.
if (_hoverWidget is not null && !_tooltipFired && Captured is null
&& _nowMs - _hoverStartedMs >= EffectiveTooltipDelayMs(_hoverWidget))
{
var e = new UiEvent(_hoverWidget.EventId, _hoverWidget, UiEventType.Tooltip);
_hoverWidget.OnEvent(in e);
_tooltipFired = true;
_tooltipShownMs = _nowMs;
TooltipShow?.Invoke(_hoverWidget);
}
else if (_hoverWidget is not null && _tooltipFired
&& _nowMs - _tooltipShownMs >= TooltipDurationMs)
{
var leave = new UiEvent(_hoverWidget.EventId, _hoverWidget, UiEventType.HoverLeave);
_hoverWidget.OnEvent(in leave);
TooltipHide?.Invoke(_hoverWidget);
_hoverWidget = null;
_tooltipFired = false;
}
BroadcastGlobalUiTime(this, nowMs / 1000d);
TickSelfAndChildren(dt);
}
public void Draw(UiRenderContext ctx)
{
// AD-98 fixed-canvas stretch: scope the renderer's canvas scale to
// exactly this tree's draws (world-space HUD stays native).
ctx.TextRenderer.CanvasScale = CanvasScale;
try
{
DrawCore(ctx);
}
finally
{
ctx.TextRenderer.CanvasScale = Vector2.One;
}
}
private void DrawCore(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)
{
(x, y) = MapWindowToCanvas(x, y);
int dx = x - MouseX;
int dy = y - MouseY;
MouseX = x;
MouseY = y;
// MouseMoveHandler @0x0045e729/@0x0045e736: m_lastMouseMoveTime is
// stamped before ANY routing below (resize/window-drag/capture/hover).
_lastMouseMoveMs = _nowMs;
// Window resize takes precedence over move / drag-drop / hover.
if (_resizeTarget is not null)
{
float maxWidth = _resizeTarget.MaxWidth;
float maxHeight = _resizeTarget.MaxHeight;
if (_resizeTarget.ConstrainResizeToParent
&& _resizeTarget.Parent is { } resizeParent)
{
// The opposite edge remains fixed during a resize. Limit the
// dragged edge to its current parent, using the interaction's
// start rect so the clamp remains stable throughout the drag.
maxWidth = MathF.Min(
maxWidth,
(_resizeEdges & ResizeEdges.Left) != 0
? _resizeStartX + _resizeStartW
: resizeParent.Width - _resizeStartX);
maxHeight = MathF.Min(
maxHeight,
(_resizeEdges & ResizeEdges.Top) != 0
? _resizeStartY + _resizeStartH
: resizeParent.Height - _resizeStartY);
}
var (nx, ny, nw, nh) = ResizeRect(
_resizeStartX, _resizeStartY, _resizeStartW, _resizeStartH,
_resizeEdges, x - _resizeMouseX, y - _resizeMouseY,
_resizeTarget.MinWidth, _resizeTarget.MinHeight,
MathF.Max(_resizeTarget.MinWidth, maxWidth),
MathF.Max(_resizeTarget.MinHeight, maxHeight));
_resizeTarget.Left = nx; _resizeTarget.Top = ny;
_resizeTarget.Width = nw; _resizeTarget.Height = nh;
// Re-baseline the anchor layout: ApplyAnchor runs every frame before
// drawing children and would otherwise snap the window back to its
// captured margins, silently undoing the interactive resize on any
// anchored window.
_resizeTarget.ResetAnchorCapture();
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;
// Same re-baseline as the resize path: without it an anchored window
// (e.g. the combat/spell bar, mounted Left|Bottom) never visibly moves —
// the next frame's ApplyAnchor restores the captured margins.
_windowDragTarget.ResetAnchorCapture();
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);
}
// ── 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)
{
(x, y) = MapWindowToCanvas(x, y);
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;
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
// 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);
// Authored drag handle (retail UIElement_Dragbar, element class 2): a press
// inside the handle subtree moves its top-level window even when that window
// is not whole-surface Draggable — e.g. the combat/spell bar's authored top
// strip (StartMouseMoving @ 0x0046C760 → UIElement::StartMovement on parent).
var handleWindow = FindDragHandleWindow(target);
var raiseWindow = window ?? handleWindow;
// Retail-faithful: pressing on a window raises it above its peers.
if (raiseWindow is not null) BringToFront(raiseWindow);
if (btn == UiMouseButton.Left && raiseWindow is not null && !UiLocked)
{
// A directly-pressed authored Resizebar grip is precise geometry and
// wins outright, regardless of an overlapping move handle or the
// generic proximity heuristic. Failing that, a directly-pressed move
// handle (dragbar) wins over ambient edge-proximity — the chat
// window's top strip must move the window even though it sits
// within ResizeGrip px of the window's own top edge.
var gripEdges = EffectiveGripEdges(target, window);
var edges = gripEdges != ResizeEdges.None
? gripEdges
: (handleWindow is null && window is { Resizable: true }
? 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 (handleWindow is not null)
{
_windowDragTarget = handleWindow;
_windowDragOffX = x - (int)handleWindow.Left;
_windowDragOffY = y - (int)handleWindow.Top;
_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 is { Draggable: true })
{
_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
{
// Retail item drag begins from left-button movement. A right-button
// press remains a complete-click candidate for ItemList appraisal;
// it must never lift or move an inventory item.
_dragCandidate = btn == UiMouseButton.Left;
}
// 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)
{
(x, y) = MapWindowToCanvas(x, y);
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)
&& Math.Abs(x - _pressX) <= DragDistanceThreshold
&& Math.Abs(y - _pressY) <= DragDistanceThreshold)
{
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)
{
// 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)
{
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;
NotifyCaptureLost(previous);
PointerCaptureChanged?.Invoke(previous, e);
}
public void ReleaseCapture()
{
UiElement? previous = Captured;
Captured = null;
// #409 F7: retail restarts the tooltip idle deadline when capture is
// released (ReleaseMouseCapture @0x0045D2B0, @0x0045d2ce/@0x0045d2da
// — m_lastMouseMoveTime only). It does NOT touch m_bHoverStarted
// (our _tooltipFired): that field means "hover started" (a tooltip
// is showing), not "capture is active", and a mouse-up while a
// tooltip is already up must leave it up, not clear-then-re-fire it
// 250ms later without ever going through TooltipHide.
_hoverStartedMs = _nowMs;
_lastMouseMoveMs = _nowMs; // ReleaseMouseCapture @0x0045d2da — the same restart
NotifyCaptureLost(previous);
if (previous is not null)
PointerCaptureChanged?.Invoke(previous, null);
}
/// <summary>OP5 re-check R1 (2026-08-11): WM_CAPTURECHANGED to the element
/// losing capture — a capture drop WITHOUT a MouseUp (panel hidden by a
/// keybind mid-drag; a second button re-targeting capture) must let the
/// element terminate any capture-keyed gesture (the scrollbar's drag
/// latch, which otherwise reads IsDragging=true forever and silently
/// suppresses every later settings flush). A normal MouseUp path is
/// unaffected: the gesture state is already cleared by the time capture
/// releases, so the handler no-ops.</summary>
private static void NotifyCaptureLost(UiElement? previous)
{
if (previous is null) return;
var lost = new UiEvent(
previous.EventId, previous, UiEventType.CaptureChanged);
previous.OnEvent(in lost);
}
// ── 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,
int authoredGeometryRevision = 0)
=> WindowManager.Register(
name,
window,
contentRoot,
controller,
stateController,
authoredGeometryRevision);
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 ─────────────────────────────────────────────────
/// <summary>
/// #409 F11: forgets the in-progress hover-dwell/tooltip-shown latch
/// without touching <see cref="_hoverWidget"/> itself or firing any
/// hover-leave event. <see cref="RetailUiRuntime.ResetSessionDialogs"/>
/// calls this alongside <see cref="RetailTooltipPresenter.HideCurrent"/>
/// (which only tears down the presenter's own popup element) so a
/// reconnect that hides an in-flight tooltip also re-arms the dwell
/// timer — otherwise a mouse that never left the hovered widget across
/// the reset would leave <c>_tooltipFired</c> latched true and the
/// widget would not show a tooltip again until either the 10 s
/// auto-hide timeout elapses or the hover target changes. No literal
/// retail counterpart (retail's own session teardown is a full
/// <c>UIElementManager</c> re-construction, not a partial reset), but
/// the effect matches: a fresh dwell deadline, same as
/// <see cref="ReleaseCapture"/>'s own idle-timestamp restart.
/// </summary>
public void ResetTooltipTracking()
{
_hoverStartedMs = _nowMs;
_lastMouseMoveMs = _nowMs; // same fresh idle deadline for the world-hover dwell
_tooltipFired = false;
}
private void UpdateHover(int x, int 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)
DispatchMouseMove(w, x, y);
// #409 F2: retail's dwell timer anchors to mouse-IDLE, not
// hover-enter — UIElementManager::MouseMoveHandler @0x0045E710
// stamps m_lastMouseMoveTime on EVERY move (@0x0045e729/
// @0x0045e736), unconditionally, before any hit-testing; the
// arm check in CheckTooltip @0x0045B6E0 (@0x0045b747) compares
// against that timestamp. Jiggling the mouse within the SAME
// widget must keep re-arming the deadline, not just entering
// it once. Guarded by !_tooltipFired — retail's m_bHoverStarted
// (our _tooltipFired) keeps a SHOWN tooltip from being
// re-armed by further moves; ReleaseCapture already ports the
// same field's other writer (@0x0045D2B0).
if (!_tooltipFired)
_hoverStartedMs = _nowMs;
return;
}
if (_hoverWidget is not null)
{
var leave = new UiEvent(_hoverWidget.EventId, _hoverWidget, UiEventType.HoverLeave);
_hoverWidget.OnEvent(in leave);
// #409: retail UIElementManager::SwitchMouseOver @0x0045B560 calls
// StopHover (which tears down m_pTooltipElement) the instant the
// hovered element changes — the ONLY confirmed dismissal trigger
// besides duration-timeout and element-removal. Notably, retail's
// MouseDownEvent @0x0045DB60 calls SwitchMouseOver with the SAME
// hit-tested element, so clicking the tooltip's own owner does
// NOT dismiss it (SwitchMouseOver no-ops when the target hasn't
// changed) — no click-dismissal is ported here for that reason.
if (_tooltipFired)
TooltipHide?.Invoke(_hoverWidget);
}
_hoverWidget = w;
_hoverStartedMs = _nowMs;
_tooltipFired = false;
if (w is not null)
{
var screen = w.ScreenPosition;
var enter = new UiEvent(
w.EventId,
w,
UiEventType.HoverEnter,
Data1: (int)(x - screen.X),
Data2: (int)(y - screen.Y));
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).
foreach (var c in ChildrenFrontToBackSnapshot())
{
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>The top-level window moved by an authored drag handle at or above
/// <paramref name="e"/>, or null when the press is not inside a
/// <see cref="UiElement.WindowMoveHandle"/> subtree. Retail's UIElement_Dragbar
/// (element class 2) calls <c>UIElement::StartMovement</c> on its parent window
/// (<c>StartMouseMoving @ 0x0046C760</c>); in our mounted tree that parent's
/// analogue is the outer frame — the ancestor sitting directly under the root —
/// because <see cref="RetailWindowFrame"/> wraps the imported layout root.</summary>
private UiElement? FindDragHandleWindow(UiElement? e)
{
while (e is not null && !ReferenceEquals(e, this) && !e.WindowMoveHandle)
e = e.Parent;
if (e is null || ReferenceEquals(e, this)) return null;
while (e.Parent is not null && !ReferenceEquals(e.Parent, this))
e = e.Parent;
return e;
}
/// <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.
}
}