using System; using System.Collections.Generic; using System.Numerics; namespace AcDream.App.UI; /// /// 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; } /// /// 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; } /// 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 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); } // ── 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; // 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, lx, ly) = HitTestTopDown(x, y); if (target is null) { WorldMouseFallThrough?.Invoke(btn, x, y, flags); return; } // Set keyboard focus if target accepts it. if (target.AcceptsFocus) SetKeyboardFocus(target); // Capture + arm drag candidate (drag promotes on subsequent MouseMove > threshold). SetCapture(target); _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, }; var e = new UiEvent(target.EventId, target, rawType, Data0: (int)flags, Data1: (int)lx, Data2: (int)ly); 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 (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) { // 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 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. } }