using System; using System.Collections.Generic; 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 "Device" 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: the DAT_00837ff4 Device object (see /// docs/research/retail-ui/04-input-events.md §2). That object has /// a ~20-slot vtable; the methods we emulate here are: /// /// /// +0x18 / +0x1C : / /// +0x34 : (tooltip delay) /// +0x38 : /// +0x44 : /// +0x48 / +0x4C : / /// +0x74 / +0x78 : drag cursor set / reset /// /// /// 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 { // ── 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; /// Current drag source (set between drag-begin and drop/cancel). public UiElement? DragSource { get; private set; } public object? DragPayload { get; private set; } private UiElement? _lastDragHoverTarget; private int _pressX, _pressY; private bool _dragCandidate; private UiElement? _windowDragTarget; private int _windowDragOffX, _windowDragOffY; 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 // Hover / tooltip tracking. private UiElement? _hoverWidget; private long _hoverStartedMs; private const int TooltipDelayMs = 1000; // retail typical private bool _tooltipFired; 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; private uint _nextEventId = 0x10000001u; public override void AddChild(UiElement child) { if (child.EventId == 0) child.EventId = _nextEventId++; base.AddChild(child); } // ── 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; } 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); ctx.EndOverlayLayer(); } // ── Input entry points (called from GameWindow's Silk.NET handlers) ── public void OnMouseMove(int x, int y) { int dx = x - MouseX; int dy = y - MouseY; MouseX = x; MouseY = y; // Window resize takes precedence over move / drag-drop / hover. if (_resizeTarget is not null) { var (nx, ny, nw, nh) = ResizeRect( _resizeStartX, _resizeStartY, _resizeStartW, _resizeStartH, _resizeEdges, x - _resizeMouseX, y - _resizeMouseY, _resizeTarget.MinWidth, _resizeTarget.MinHeight); _resizeTarget.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) { _windowDragTarget.Left = x - _windowDragOffX; _windowDragTarget.Top = y - _windowDragOffY; 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, payload: null); } } 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); if (btn == UiMouseButton.Left && window is not null) { 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.CapturesPointerDrag) { // The pressed widget owns interior drags (e.g. text selection): // do NOT move the ancestor window. The already-dispatched MouseDown // event + SetCapture(target) let the target drive its own drag via // the MouseMove events it receives while captured. _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) { _resizeTarget = null; ReleaseCapture(); return; } if (_windowDragTarget is not null) { _windowDragTarget = null; ReleaseCapture(); return; } if (DragSource is not null) { FinishDrag(x, y); ReleaseCapture(); _dragCandidate = false; return; } if (Captured is not null) { int rawType = btn switch { UiMouseButton.Left => UiEventType.MouseUp, UiMouseButton.Right => UiEventType.RightUp, UiMouseButton.Middle => UiEventType.MiddleUp, _ => UiEventType.MouseUp, }; var sp = Captured.ScreenPosition; var raw = new UiEvent(Captured.EventId, Captured, rawType, Data0: (int)flags, Data1: (int)(x - sp.X), Data2: (int)(y - sp.Y)); BubbleEvent(Captured, in raw); // If left-up over the same element that received the down, emit Click. if (btn == UiMouseButton.Left && ContainsAbsolute(Captured, x, y)) { var click = new UiEvent(Captured.EventId, Captured, UiEventType.Click, Data0: (int)flags, Data1: (int)(x - sp.X), Data2: (int)(y - sp.Y)); BubbleEvent(Captured, in click); } else if (btn == UiMouseButton.Right && ContainsAbsolute(Captured, x, y)) { var click = new UiEvent(Captured.EventId, Captured, UiEventType.RightClick, Data0: (int)flags); BubbleEvent(Captured, in click); } 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; if (KeyboardFocus is not null) { var lost = new UiEvent(KeyboardFocus.EventId, KeyboardFocus, UiEventType.FocusLost); KeyboardFocus.OnEvent(in lost); } KeyboardFocus = e; if (e is not null) { var gained = new UiEvent(e.EventId, e, UiEventType.FocusGained); e.OnEvent(in gained); } } public void SetCapture(UiElement e) => Captured = e; public void ReleaseCapture() => Captured = null; // ── Drag-drop (retail event chain 0x15 → 0x21 → 0x1C → 0x3E) ──────── private void BeginDrag(UiElement source, object? payload) { DragSource = source; DragPayload = payload; var e = new UiEvent(source.EventId, source, UiEventType.DragBegin, Payload: payload); source.OnEvent(in e); } 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) { var (t, lx, ly) = HitTestTopDown(x, y); var target = t ?? DragSource!; var accepted = t is not null && t != DragSource; var e = new UiEvent(DragSource!.EventId, target, UiEventType.DropReleased, Data0: accepted ? 1 : 0, Data1: (int)lx, Data2: (int)ly, Payload: DragPayload); target.OnEvent(in e); DragSource = null; DragPayload = 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); } public void RegisterTimerEvent(int type, UiElement target, int delayMs, object? payload = null) { _timers.Add((_nowMs + delayMs, new UiEvent(target.EventId, target, type, Payload: payload))); } private readonly List<(long fireAt, UiEvent e)> _timers = new(); 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); } 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); return e; } /// Compute a resized rect from a start rect + drag delta + which edges, /// clamping to (,). 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 x = startX, y = startY, w = startW, h = startH; if ((edges & ResizeEdges.Right) != 0) w = System.Math.Max(minW, startW + dx); if ((edges & ResizeEdges.Bottom) != 0) h = System.Math.Max(minH, startH + dy); if ((edges & ResizeEdges.Left) != 0) { float nw = System.Math.Max(minW, startW - dx); x = startX + (startW - nw); w = nw; } if ((edges & ResizeEdges.Top) != 0) { float nh = System.Math.Max(minH, startH - dy); 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. } }