acdream/src/AcDream.App/UI/UiRoot.cs
Erik 4943484eb9 fix: social gate round 3 - authored multiline word wrap + geometric
move-cursor border band

- Empty-state text (round 3): the literal-\n split was correct but each
  authored LINE rendered as one clipped run. Retail word-wraps each
  authored line within the element extent (its GlyphList draw - the
  same wrap RetailConfirmationDialogView already uses). Multiline
  authored text now wraps through UiText.WrapWords against the widget's
  LIVE width/font/color (cached per width+font+color, re-read per call).
  Single-line authored labels keep their one-run shape - re-wrapping
  every label is a client-wide change no gate asked for.
- Move cursor (round 3): "the frame won the hit-test" is not a border
  test - windows whose interior is not fully covered by children (the
  inventory panel's empty regions) resolve those pixels to the frame
  too. The border is now a geometric 8 px band along the window's outer
  edge, AND the frame must win the hit-test so border-adjacent content
  keeps its own cursor. Resize-edge claim still takes precedence;
  whole-surface dragging unchanged.

App suite 4,984/3 skips (new BuildText_MultilineAuthored wrap test).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 07:50:59 +02:00

1253 lines
53 KiB
C#

using System;
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>Which edges of a window a resize-drag is affecting (corners combine two).</summary>
[System.Flags]
public enum ResizeEdges { None = 0, Left = 1, Right = 2, Top = 4, Bottom = 8 }
/// <summary>
/// Top-level UI container. Implements the retail <c>UIElementManager</c> responsibilities
/// (mouse cursor tracking, keyboard focus, modal overlay, mouse capture,
/// drag-drop state machine, tooltip timer). Routes Silk.NET input events
/// into the widget tree with retail-faithful <see cref="UiEvent"/>
/// semantics.
///
/// Retail analog: <c>UIElementManager::UseTime @ 0x0045CFD0</c>. Tooltip
/// deadlines are polled before global time message 3; there is no generic Device
/// timer queue in the named client.
///
/// When no widget consumes an event, the <see cref="WorldMouseFallThrough"/>
/// or <see cref="WorldKeyFallThrough"/> event fires so the game world
/// (camera, player controller) still receives input.
/// </summary>
public sealed class UiRoot : UiElement
{
public UiRoot()
{
WindowManager = new RetailWindowManager(this);
}
/// <summary>Single owner for named retained-window lifecycle and raise policy.</summary>
public RetailWindowManager WindowManager { get; }
// ── Device-level state ───────────────────────────────────────────────
public int MouseX { get; private set; }
public int MouseY { get; private set; }
public bool LeftButtonDown { get; private set; }
public bool RightButtonDown { get; private set; }
public bool MiddleButtonDown { get; private set; }
/// <summary>Widget currently receiving keyboard events.</summary>
public UiElement? KeyboardFocus { get; private set; }
/// <summary>The edit control activated by Tab/Enter when nothing is focused — retail's
/// chat input "write mode" toggle. Set by the host once the chat window is built.</summary>
public UiElement? DefaultTextInput { get; set; }
/// <summary>
/// Single modal overlay; while set, mouse clicks outside its rect
/// are ignored. Retail sets this via Device vtable +0x48.
/// </summary>
public UiPanel? Modal { get; set; }
/// <summary>Widget with mouse capture (during click-drag).</summary>
public UiElement? Captured { get; private set; }
/// <summary>
/// True when the pointer is over a widget OR a widget holds mouse capture.
/// The host ORs this into the InputDispatcher's WantCaptureMouse gate so game
/// actions (movement, world-pick) are suppressed while the user interacts with
/// a retail window — mirrors ImGui's WantCaptureMouse.
/// </summary>
public bool WantsMouse => Captured is not null || HitTestTopDown(MouseX, MouseY).element is not null;
/// <summary>True when a widget holds keyboard focus (e.g. a focused chat input).</summary>
public bool WantsKeyboard => KeyboardFocus is not null;
/// <summary>Retail PlayerModule::LockUI gate. Blocks all retained-window
/// move/resize interactions without disabling their buttons or content.</summary>
private bool _uiLocked;
public bool UiLocked
{
get => _uiLocked;
set
{
if (_uiLocked == value) return;
_uiLocked = value;
UiLockChanged?.Invoke(value);
}
}
/// <summary>Current drag source (set between drag-begin and drop/cancel).</summary>
public UiElement? DragSource { get; private set; }
public object? DragPayload { get; private set; }
public bool IsWindowMoveActive => _windowDragTarget is not null;
public ResizeEdges ActiveResizeEdges => _resizeTarget is not null ? _resizeEdges : ResizeEdges.None;
public ResizeEdges HoverResizeEdges
{
get
{
var target = Pick(MouseX, MouseY);
var window = FindWindow(target);
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;
public int TooltipDelayMs { get; set; } = 250;
public int TooltipDurationMs { get; set; } = 10_000;
private bool _tooltipFired;
private long _tooltipShownMs;
private long _nowMs;
/// <summary>Raised when an event was not consumed by any widget.</summary>
public event Action<UiMouseButton, int, int, uint>? WorldMouseFallThrough;
/// <summary>Raised when a key was not consumed by any widget.</summary>
public event Action<int /*vk*/, uint /*lparam*/>? WorldKeyFallThrough;
/// <summary>Raised when mouse moved and no widget captured.</summary>
public event Action<int, int>? WorldMouseMoveFallThrough;
/// <summary>Raised on scroll fall-through (world zoom, etc.).</summary>
public event Action<int /*dy*/>? WorldScrollFallThrough;
/// <summary>Raised when a drag is released over no UI element.</summary>
public event Action<object /*payload*/, int /*x*/, int /*y*/>? DragReleasedOutsideUi;
/// <summary>Raised after a registered top-level window finishes moving.</summary>
public event Action<string, UiElement>? WindowMoved;
/// <summary>Raised after a registered top-level window finishes resizing.</summary>
public event Action<string, UiElement>? WindowResized;
/// <summary>Raised after any attached element changes visibility.</summary>
public event Action<UiElement, bool>? ElementVisibilityChanged;
/// <summary>Raised after keyboard focus changes; arguments are old/new.</summary>
public event Action<UiElement?, UiElement?>? KeyboardFocusChanged;
/// <summary>Raised after pointer capture changes; arguments are old/new.</summary>
public event Action<UiElement?, UiElement?>? PointerCaptureChanged;
/// <summary>Raised after the global retained-UI lock changes.</summary>
public event Action<bool>? UiLockChanged;
private uint _nextEventId = 0x10000001u;
public override void AddChild(UiElement child)
{
AssignEventIds(child);
base.AddChild(child);
}
private void AssignEventIds(UiElement element)
{
if (element.EventId == 0)
element.EventId = _nextEventId++;
foreach (var child in element.Children)
AssignEventIds(child);
}
private static void BroadcastGlobalUiTime(UiElement element, double nowSeconds)
{
if (element is IUiGlobalTimeListener listener)
listener.OnGlobalUiTime(nowSeconds);
// A listener may synchronously close/remove a window. Snapshot the walk,
// then skip children no longer owned by this parent so a deleted subtree
// cannot receive a stale pulse and collection mutation cannot invalidate it.
foreach (var child in element.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);
_hoverWidget = null;
_tooltipFired = false;
}
if (IsWithinSubtree(_lastDragHoverTarget, subtree))
_lastDragHoverTarget = null;
if (IsWithinSubtree(_lastClickTarget, subtree))
_lastClickTarget = null;
if (IsWithinSubtree(_windowDragTarget, subtree))
{
_windowDragTarget = null;
_dragCandidate = false;
}
if (IsWithinSubtree(_resizeTarget, subtree))
{
_resizeTarget = null;
_dragCandidate = false;
}
}
private static bool IsWithinSubtree(UiElement? element, UiElement subtree)
{
while (element is not null)
{
if (ReferenceEquals(element, subtree)) return true;
element = element.Parent;
}
return false;
}
// ── Per-frame pumping ────────────────────────────────────────────────
public void Tick(double dt, long nowMs)
{
_nowMs = nowMs;
// Tooltip timer: once mouse has hovered over the same widget for
// TooltipDelayMs, fire a Tooltip event on it exactly once.
if (_hoverWidget is not null && !_tooltipFired
&& _nowMs - _hoverStartedMs >= TooltipDelayMs)
{
var e = new UiEvent(_hoverWidget.EventId, _hoverWidget, UiEventType.Tooltip);
_hoverWidget.OnEvent(in e);
_tooltipFired = true;
_tooltipShownMs = _nowMs;
}
else if (_hoverWidget is not null && _tooltipFired
&& _nowMs - _tooltipShownMs >= TooltipDurationMs)
{
var leave = new UiEvent(_hoverWidget.EventId, _hoverWidget, UiEventType.HoverLeave);
_hoverWidget.OnEvent(in leave);
_hoverWidget = null;
_tooltipFired = false;
}
BroadcastGlobalUiTime(this, nowMs / 1000d);
TickSelfAndChildren(dt);
}
public void Draw(UiRenderContext ctx)
{
// Render children (panels) sorted by z-order — modal last so it
// sits on top.
DrawSelfAndChildren(ctx);
// Second pass: open popups/menus draw ON TOP of the whole tree (so e.g. the
// chat channel menu isn't greyed by the translucent chat panel that draws
// after it in the main pass). Routed to the renderer's overlay layer so it
// beats even rect backgrounds. Faithful to retail's root-level MakePopup.
ctx.BeginOverlayLayer();
DrawOverlays(ctx);
DrawDragGhost(ctx);
ctx.EndOverlayLayer();
}
private const float GhostAlpha = 1.0f; // retail m_dragIcon is the full icon, no fade
/// <summary>Paint the drag ghost at the cursor. The texture comes from the snapshotted
/// ghost captured in <see cref="BeginDrag"/> so UiRoot stays item-agnostic and the ghost
/// survives the source cell emptying on lift; the ghost is NOT a tree element, so it
/// never intercepts hit-tests.</summary>
private void DrawDragGhost(UiRenderContext ctx)
{
if (_dragGhost is not { } g || g.tex == 0) return;
ctx.DrawSprite(g.tex, MouseX - g.w / 2f, MouseY - g.h / 2f, g.w, g.h,
0f, 0f, 1f, 1f, new Vector4(1f, 1f, 1f, GhostAlpha));
}
// ── Input entry points (called from GameWindow's Silk.NET handlers) ──
public void OnMouseMove(int x, int y)
{
int dx = x - MouseX;
int dy = y - MouseY;
MouseX = x;
MouseY = y;
// Window resize takes precedence over move / drag-drop / hover.
if (_resizeTarget is not null)
{
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)
{
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)
{
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;
// Retail restarts the tooltip idle deadline when capture is released.
_hoverStartedMs = _nowMs;
_tooltipFired = false;
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 ─────────────────────────────────────────────────
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);
return;
}
if (_hoverWidget is not null)
{
var leave = new UiEvent(_hoverWidget.EventId, _hoverWidget, UiEventType.HoverLeave);
_hoverWidget.OnEvent(in leave);
}
_hoverWidget = w;
_hoverStartedMs = _nowMs;
_tooltipFired = false;
if (w is not null)
{
var 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.
}
}