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>
1531 lines
67 KiB
C#
1531 lines
67 KiB
C#
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.
|
||
}
|
||
}
|