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