using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.UI;
/// Which parent edges a child keeps a fixed margin to on resize.
/// Left+Right ⇒ width stretches; Top+Bottom ⇒ height stretches.
[System.Flags]
public enum AnchorEdges { None = 0, Left = 1, Top = 2, Right = 4, Bottom = 8 }
///
/// Base class for every UI widget in the retained-mode tree.
///
/// Design notes:
/// - Retail AC delegates widget semantics to the external
/// keystone.dll library (see
/// docs/research/retail-ui/02-class-hierarchy.md — there is no
/// widget hierarchy inside acclient.exe itself). We implement
/// our own retained-mode toolkit here, matching the behavior
/// described in the decompile without trying to byte-match Keystone's
/// internal class layout.
/// - Events use the retail-faithful struct and
/// the constants so that hand-ported panel
/// code can use the same magic numbers the decompiled C uses
/// (e.g. if (e.Type == 0x15) ... for drag-begin).
/// - Hit-testing is children-first (topmost wins) with Z-order tie
/// breaking; drawing is back-to-front so later children appear on top.
/// - Coordinates are in screen pixels, origin top-left.
/// is in the parent's local coordinate space.
///
public abstract class UiElement
{
// ── Identity ─────────────────────────────────────────────────────────
///
/// Unique 32-bit event ID. Retail uses the range 0x10000000+
/// for custom app events (see
/// docs/research/retail-ui/04-input-events.md §3). Assigned
/// by when the element is added to the tree.
///
public uint EventId { get; internal set; }
/// Human-readable name for debugging / FindByName.
public string? Name { get; init; }
// ── Geometry ────────────────────────────────────────────────────────
/// X in the parent's local pixel space.
public float Left { get; set; }
public float Top { get; set; }
public float Width { get; set; }
public float Height { get; set; }
/// Absolute (screen-space) top-left, computed by walking Parent.
public Vector2 ScreenPosition
{
get
{
var p = new Vector2(Left, Top);
var parent = Parent;
while (parent is not null)
{
p += new Vector2(parent.Left, parent.Top);
parent = parent.Parent;
}
return p;
}
}
// ── State flags ─────────────────────────────────────────────────────
public bool Visible { get; set; } = true;
public bool Enabled { get; set; } = true;
///
/// If true, skips this element — the event
/// passes through to whatever is behind. Used by decoration widgets
/// (portrait frames, ornamental dividers).
///
public bool ClickThrough { get; set; }
///
/// If true, will set focus here on click,
/// routing WM_KEYDOWN / WM_CHAR to as
/// / .
///
public bool AcceptsFocus { get; set; }
///
/// True if this is a text-entry (edit box); used by focus routing
/// to suppress global hotkeys while typing.
///
public bool IsEditControl { get; set; }
/// Painter's-algorithm z-order within siblings. Higher = on top.
public int ZOrder { get; set; }
/// Window opacity (0..1) multiplied into this element's and its
/// descendants' background + sprite draws (text stays opaque). 1 = fully opaque.
/// Set on a top-level window (e.g. the chat frame) for retail's translucent chat.
public float Opacity { get; set; } = 1f;
/// If true, a left-drag on this element (or a non-draggable child of
/// it) repositions it as a movable window. Intended for top-level panels,
/// whose Left/Top are screen coordinates (Root sits at the origin).
public bool Draggable { get; set; }
/// If true, a left-drag starting near this element's edge/corner
/// resizes it (window resize). Intended for top-level panels.
public bool Resizable { get; set; }
/// If true, a left-drag starting on this element is delivered to the
/// element (e.g. text selection) instead of moving/resizing an ancestor window.
/// Edge resize on a resizable ancestor still wins — only the interior move /
/// drag-drop candidacy is suppressed in favour of the element's own handling.
public bool CapturesPointerDrag { get; set; }
/// If true, a left-press-and-move on this element starts a DRAG-DROP
/// ( promotes to BeginDrag) rather than moving a Draggable
/// ancestor window — so an item cell inside the toolbar frame drags the item, not
/// the window. Distinct from (a self-driven
/// interior drag like text selection, which does NOT promote to BeginDrag). Default
/// false; overridden by drag sources (e.g. an occupied ).
public virtual bool IsDragSource => false;
/// Minimum size enforced while resizing.
public float MinWidth { get; set; } = 40f;
public float MinHeight { get; set; } = 40f;
/// Maximum height enforced while resizing (default unbounded). Pairs with MinHeight.
public float MaxHeight { get; set; } = float.MaxValue;
/// Allow horizontal (width) resize. Ignored unless .
public bool ResizeX { get; set; } = true;
/// Allow vertical (height) resize. Ignored unless .
public bool ResizeY { get; set; } = true;
/// Which edges may start a resize, beyond the ResizeX/ResizeY axis gates. Default: all.
/// Set to e.g. to allow only a bottom-edge drag (the collapse toolbar).
public ResizeEdges ResizableEdges { get; set; } =
ResizeEdges.Left | ResizeEdges.Right | ResizeEdges.Top | ResizeEdges.Bottom;
/// Edges this element anchors to in its parent. Default Left|Top
/// (pinned top-left, fixed size — no reflow). Left|Right stretches width.
public AnchorEdges Anchors { get; set; } = AnchorEdges.Left | AnchorEdges.Top;
// ── Tree structure ──────────────────────────────────────────────────
public UiElement? Parent { get; private set; }
private readonly List _children = new();
public IReadOnlyList Children => _children;
public virtual void AddChild(UiElement child)
{
if (child.Parent is not null) child.Parent.RemoveChild(child);
child.Parent = this;
_children.Add(child);
}
public virtual bool RemoveChild(UiElement child)
{
if (!_children.Remove(child)) return false;
child.Parent = null;
return true;
}
///
/// True if this widget draws its full appearance itself and REPRODUCES its dat
/// sub-elements procedurally (3-slice caps, button labels, scroll arrows, popup
/// rows…) — so the must NOT build
/// those dat child elements as separate widgets (they would double-draw and, worse,
/// steal pointer/focus from the behavioral widget). All registered behavioral widgets
/// (Meter/Menu/Button/Scrollbar/Text/Field) return true; the generic container
/// () and panels return false
/// and recurse their children normally. Mirrors retail, where each
/// UIElement_X::DrawSelf owns its internal structure.
///
public virtual bool ConsumesDatChildren => false;
// ── Virtual overrides ───────────────────────────────────────────────
///
/// Draw THIS element (not its children). Children are composited by
/// after this returns.
///
protected virtual void OnDraw(UiRenderContext ctx) { }
///
/// Draw AFTER this element's own children, but still within this element's
/// transform/alpha (NOT a global pass like ). Use for a
/// window FRAME border, which must be the outermost layer drawn OVER its content's
/// edges (so content can't poke through the frame), while the frame's center fill
/// stays a background in . Default: nothing.
///
protected virtual void OnDrawAfterChildren(UiRenderContext ctx) { }
///
/// Draw content that must sit ON TOP of the ENTIRE UI, regardless of this
/// element's position in the tree — open menus, dropdowns, tooltips. Called in
/// a SECOND traversal after the whole tree's pass, with the
/// same accumulated transform/alpha this element had during its normal draw.
/// Retail spawns popups as ROOT elements (UIElement_Menu::MakePopup) for exactly
/// this reason; this is the equivalent without reparenting. Default: nothing.
///
protected virtual void OnDrawOverlay(UiRenderContext ctx) { }
/// Per-frame tick (animations, timers, caret blink).
protected virtual void OnTick(double deltaSeconds) { }
///
/// Custom hit-test override. Default is a rectangle containment
/// check on (, ).
///
protected virtual bool OnHitTest(float localX, float localY)
=> localX >= 0f && localX < Width && localY >= 0f && localY < Height;
///
/// Event handler. Return true to consume the event (the
/// will stop propagation). Return false
/// to let ancestors / fall-through handle it.
///
public virtual bool OnEvent(in UiEvent e) => false;
/// The data this element carries when a drag begins.
/// pulls this on drag-promote; a NULL return CANCELS the drag (retail:
/// ItemList_BeginDrag only arms an occupied cell). Default null = not draggable.
public virtual object? GetDragPayload() => null;
/// The texture paints at the cursor while this element
/// is the drag source: (GL handle, width, height). Null = no ghost. Keeps
/// item-agnostic. Retail analog: m_dragIcon (decomp 229738).
public virtual (uint tex, int w, int h)? GetDragGhost() => null;
///
/// Tooltip text for this widget. Retail fires event 0x07 after
/// ~1000ms hover, then queries the widget's virtual "GetString"
/// (vtable +0x88) to render the tooltip body.
///
public virtual string? GetTooltipText() => null;
// ── Framework entry points (internal, called by UiRoot) ─────────────
internal void DrawSelfAndChildren(UiRenderContext ctx)
{
if (!Visible) return;
// Translate into our local space + push this window's opacity (multiplies into
// descendants' sprite/rect draws; text bypasses the alpha so it stays sharp).
ctx.PushTransform(Left, Top);
ctx.PushAlpha(Opacity);
try
{
OnDraw(ctx);
// Anchor layout: reflow children to this element's current size.
for (int i = 0; i < _children.Count; i++)
_children[i].ApplyAnchor(Width, Height);
// Children painted back-to-front (lowest ZOrder first).
if (_children.Count > 0)
{
// Avoid LINQ allocation by copying to a temp array and sorting.
var ordered = _children.ToArray();
Array.Sort(ordered, static (a, b) => a.ZOrder.CompareTo(b.ZOrder));
for (int i = 0; i < ordered.Length; i++)
ordered[i].DrawSelfAndChildren(ctx);
}
// Foreground pass for this element (e.g. a window frame's border drawn
// OVER its content's edges). Default no-op for ordinary elements.
OnDrawAfterChildren(ctx);
}
finally
{
ctx.PopAlpha();
ctx.PopTransform();
}
}
/// Second draw traversal: re-walks the tree applying the same
/// transform/alpha as and calls
/// on each element, so popups composite on top of
/// everything drawn in the main pass (dat-font glyphs and sprites share one
/// submission-ordered bucket, so later submissions win).
internal void DrawOverlays(UiRenderContext ctx)
{
if (!Visible) return;
ctx.PushTransform(Left, Top);
ctx.PushAlpha(Opacity);
try
{
OnDrawOverlay(ctx);
if (_children.Count > 0)
{
var ordered = _children.ToArray();
Array.Sort(ordered, static (a, b) => a.ZOrder.CompareTo(b.ZOrder));
for (int i = 0; i < ordered.Length; i++)
ordered[i].DrawOverlays(ctx);
}
}
finally
{
ctx.PopAlpha();
ctx.PopTransform();
}
}
internal void TickSelfAndChildren(double dt)
{
if (!Visible) return;
OnTick(dt);
for (int i = 0; i < _children.Count; i++)
_children[i].TickSelfAndChildren(dt);
}
///
/// Top-down, children-first hit-test. /
/// are in THIS element's local space.
/// Returns the topmost descendant (or this) at the point, or null.
///
internal UiElement? HitTest(float localX, float localY)
{
if (!Visible || !Enabled) return null;
// Children first, in reverse Z-order (topmost first). ClickThrough means
// THIS element is transparent to the pointer — but its children are NOT.
// A ClickThrough container (e.g. a UiDatElement panel that hosts the chat
// input / transcript) must still let the pointer reach its behavioral
// children, so the ClickThrough check happens AFTER the child walk, gating
// only whether THIS element claims the hit.
if (_children.Count > 0)
{
var ordered = _children.ToArray();
Array.Sort(ordered, static (a, b) => b.ZOrder.CompareTo(a.ZOrder));
for (int i = 0; i < ordered.Length; i++)
{
var c = ordered[i];
var childHit = c.HitTest(localX - c.Left, localY - c.Top);
if (childHit is not null) return childHit;
}
}
if (ClickThrough) return null;
return OnHitTest(localX, localY) ? this : null;
}
// ── Anchor layout ────────────────────────────────────────────────────
private bool _anchorCaptured;
private float _amL, _amT, _amR, _amB, _aw0, _ah0;
/// Reposition/resize this element per , keeping
/// the margins captured (at first layout / design size) to each anchored edge.
/// Called by the parent each frame before drawing children.
internal void ApplyAnchor(float parentW, float parentH)
{
if (Anchors == AnchorEdges.None) return;
if (!_anchorCaptured)
{
_amL = Left; _amT = Top;
_amR = parentW - (Left + Width);
_amB = parentH - (Top + Height);
_aw0 = Width; _ah0 = Height;
_anchorCaptured = true;
}
var (x, y, w, h) = ComputeAnchoredRect(Anchors, _amL, _amT, _amR, _amB, _aw0, _ah0, parentW, parentH);
Left = x; Top = y; Width = w; Height = h;
}
/// Forget the captured anchor margins so the next
/// re-captures them from the CURRENT rect. Call after manually repositioning/resizing
/// an anchored element at runtime (e.g. reflowing the chat input when the channel
/// button width changes) so the new rect becomes the anchor baseline.
internal void ResetAnchorCapture() => _anchorCaptured = false;
/// Walk up to the owning (the top of the tree), or null
/// if this element is not attached. Lets a widget reach focus/capture services — e.g.
/// a chat input blurring itself (exiting write mode) after submit.
internal UiRoot? FindRoot()
{
UiElement e = this;
while (e.Parent is not null) e = e.Parent;
return e as UiRoot;
}
/// Compute an anchored child rect. Left&Right ⇒ stretch width
/// (keep both margins); Right only ⇒ pin to right at fixed width; otherwise
/// pin left at fixed width. Same logic vertically.
public static (float x, float y, float w, float h) ComputeAnchoredRect(
AnchorEdges a, float mL, float mT, float mR, float mB,
float w0, float h0, float parentW, float parentH)
{
bool l = (a & AnchorEdges.Left) != 0, r = (a & AnchorEdges.Right) != 0;
float x, w;
if (l && r) { x = mL; w = parentW - mR - mL; }
else if (r) { w = w0; x = parentW - mR - w0; }
else { x = mL; w = w0; }
bool t = (a & AnchorEdges.Top) != 0, b = (a & AnchorEdges.Bottom) != 0;
float y, h;
if (t && b) { y = mT; h = parentH - mB - mT; }
else if (b) { h = h0; y = parentH - mB - h0; }
else { y = mT; h = h0; }
if (w < 0) w = 0;
if (h < 0) h = 0;
return (x, y, w, h);
}
}