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 (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? _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); 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) { var (nx, ny, nw, nh) = ResizeRect( _resizeStartX, _resizeStartY, _resizeStartW, _resizeStartH, _resizeEdges, x - _resizeMouseX, y - _resizeMouseY, _resizeTarget.MinWidth, _resizeTarget.MinHeight, float.MaxValue, _resizeTarget.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) { _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); } } 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) { 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) { _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; // ── Window manager (named top-level windows: Show / Hide / Toggle) ─── private readonly Dictionary _windows = new(); /// 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 (re-register replaces). The dict is the source /// of truth — independent of UiElement.Name (init-only, not set here). public void RegisterWindow(string name, UiElement window) => _windows[name] = window; /// Make the named window visible. No-op (returns false) if unknown. public bool ShowWindow(string name) { if (!_windows.TryGetValue(name, out var w)) return false; w.Visible = true; BringToFront(w); return true; } /// Hide the named window. No-op (returns false) if unknown. public bool HideWindow(string name) { if (!_windows.TryGetValue(name, out var w)) return false; w.Visible = false; return true; } /// 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) { if (!_windows.TryGetValue(name, out var w)) return false; if (w.Visible) { HideWindow(name); return false; } ShowWindow(name); return true; } /// 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) { int top = window.ZOrder; foreach (var c in Children) if (!ReferenceEquals(c, window)) top = System.Math.Max(top, c.ZOrder + 1); window.ZOrder = top; } // ── 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); } 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); 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(DragSource!.EventId, t, UiEventType.DropReleased, Data1: (int)lx, Data2: (int)ly, Payload: DragPayload); t.OnEvent(in e); } // else: dropped on nothing — no drop fires; the lift (drag-begin removal) stands (retail). 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); } 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); } /// 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. } }