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 }
/// Retail dat cursor media attached to a UI state.
public readonly record struct UiCursorMedia(uint File, int HotspotX, int HotspotY)
{
public bool IsValid => File != 0;
}
///
/// 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; }
///
/// Dat LayoutDesc element id for widgets imported from retail UI data.
/// Zero for runtime-created helper widgets. This is intentionally separate
/// from : event ids are runtime-local, while this id is
/// stable across retail layout dumps, decomp references, and UI probes.
///
public uint DatElementId { get; internal set; }
/// Human-readable name for debugging / FindByName.
public string? Name { get; init; }
///
/// GF-13 (Campaign CC gate round 1, Batch A): mirrors
/// ElementInfo.Invisible (dat property 0x3B) — a PURE DATA
/// PASSTHROUGH set by LayoutImporter.BuildWidget at construction.
/// The shared importer does NOT act on this flag (1,083 elements author
/// it client-wide, docs/ISSUES.md #408); it exists only so a screen that
/// owns its own mounted subtree can honor it explicitly, the way
/// CharacterCreationUiController does for the chargen screen
/// (register AP-230). Reading this never changes by
/// itself.
///
public bool AuthoredInvisible { get; internal set; }
///
/// #409 (client-wide retail tooltip system): mirrors
/// ElementInfo.TooltipEnabled (dat property 0x4B) — a
/// PURE DATA PASSTHROUGH set by LayoutImporter.BuildWidget.
/// Gates whether may show a
/// tooltip for this widget at all (retail's per-element
/// UIRegion::SetTooltipOn bit, checked by
/// UIElement::MouseHover @0x00462520 alongside the global
/// enable preference). See .
///
public bool AuthoredTooltipEnabled { get; internal set; }
///
/// #409: mirrors ElementInfo.TooltipText (dat property
/// 0x49), already resolved to a display string through
/// DatStringResolver and escape-normalized at import time —
/// the same treatment every other authored StringInfo caption
/// gets. Null when the element authors no tooltip text.
///
public string? AuthoredTooltipText { get; internal set; }
///
/// #409: mirrors ElementInfo.TooltipRootElementId (dat property
/// 0x47) — the element-desc id WITHIN
/// 's LayoutDesc to instantiate
/// as the tooltip popup's root. Zero when absent.
///
public uint AuthoredTooltipRootElementId { get; internal set; }
///
/// #409: mirrors ElementInfo.TooltipLayoutDid (dat property
/// 0x48) — the tooltip popup LayoutDesc DID (retail authors
/// 0x21000041 on every tooltip-bearing element). Zero when
/// absent.
///
public uint AuthoredTooltipLayoutDid { get; internal set; }
///
/// #409 (live-failure round): the DID of the LayoutDesc this widget
/// was imported from, stamped by LayoutImporter.Import's dat shell.
/// Retail's UIElement::StartTooltipAtMouse @0x00460D70 falls back to
/// exactly this — this->m_layout->m_DID (@0x00460E7E) — when
/// the hovered element authors a tooltip popup ROOT (P0x47) but no
/// popup LAYOUT (P0x48), i.e. the popup root lives in the element's
/// own layout. Zero when the widget came through the pure
/// LayoutImporter.Build layer (which has no dat context) or was
/// constructed directly; the presenter then behaves exactly as it did
/// before this field existed.
///
public uint SourceLayoutDid { get; internal set; }
///
/// Optional game object represented by this retained widget for retail's
/// global found-object cursor. Most widgets leave this unset. The toolbar
/// inventory/backpack button supplies the player object: retail treats that
/// button as the self/backpack target (the same target used by its click and
/// drop paths), so hovering it selects the Found cursor family even though
/// the authored element is a Button rather than a UIItem.
///
public Func? FoundObjectGuidProvider { get; set; }
///
/// #409: mirrors ElementInfo.TooltipTextChildElementId (dat
/// property 0x4A). Meaningful only when read off a tooltip
/// POPUP's own instantiated ROOT widget — see the
/// ElementInfo field's own doc for why retail reads this off
/// the popup, not the hovering trigger element.
///
public uint AuthoredTooltipTextChildElementId { get; internal set; }
///
/// #409: mirrors ElementInfo.TooltipDelaySeconds (dat property
/// 0x50) — a per-element hover-dwell override in seconds, used
/// by 's tooltip timer in place of the global
/// when present. Null = no override.
///
public float? AuthoredTooltipDelaySeconds { get; internal set; }
///
/// #409 F8: mirrors ElementInfo.MaxWidth/MinWidth (dat
/// properties 0x3D/0x3F) — the UIElement::ResizeTo
/// @0x00463C30 auto-resize width clamp. Null = no authored
/// override on that side.
///
public int? AuthoredResizeMaxWidth { get; internal set; }
public int? AuthoredResizeMinWidth { get; internal set; }
///
/// #409 F8: mirrors ElementInfo.MaxHeight/MinHeight (dat
/// properties 0x3C/0x3E) — the height side of the same
/// UIElement::ResizeTo @0x00463C30 clamp. Null = no authored
/// override on that side.
///
public int? AuthoredResizeMaxHeight { get; internal set; }
public int? AuthoredResizeMinHeight { get; internal set; }
private readonly Dictionary _stateCursors = new();
/// Retail MediaDescCursor entries keyed by UIStateId.ToString(), or "" for DirectState.
public IReadOnlyDictionary StateCursors => _stateCursors;
/// Active state name used for cursor media. Stateful dat widgets override this.
public virtual string ActiveCursorStateName => "";
public void SetStateCursors(IReadOnlyDictionary cursors)
{
_stateCursors.Clear();
foreach (var kv in cursors)
{
if (kv.Value.IsValid)
_stateCursors[kv.Key] = kv.Value;
}
}
public UiCursorMedia ActiveCursor()
=> CursorForState(ActiveCursorStateName, allowFallback: true);
public UiCursorMedia CursorForState(string stateName, bool allowFallback = true)
{
if (!string.IsNullOrEmpty(stateName)
&& _stateCursors.TryGetValue(stateName, out var named)
&& named.IsValid)
return named;
if (!allowFallback)
return default;
if (_stateCursors.TryGetValue("", out var direct) && direct.IsValid)
return direct;
if (_stateCursors.TryGetValue("Normal", out var normal) && normal.IsValid)
return normal;
return default;
}
// ── 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 ─────────────────────────────────────────────────────
private bool _visible = true;
public bool Visible
{
get => _visible;
set
{
if (_visible == value) return;
// A top-level visibility transition is also an ownership boundary:
// focus/capture/modal state must be released before the subtree becomes
// unreachable to input. UiRoot filters these notifications through the
// registered-window manager, while ordinary child visibility changes
// remain cheap and behavior-neutral.
UiRoot? root = FindRoot();
root?.OnElementVisibilityChanging(this, value);
_visible = value;
root?.OnElementVisibilityChanged(this, value);
}
}
private bool _enabled = true;
public bool Enabled
{
get => _enabled;
set
{
if (_enabled == value) return;
_enabled = value;
OnEnabledChanged();
}
}
///
/// 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; }
///
/// Optional live visibility reader, evaluated once per tick. Markup
/// visible="{Binding}" uses this so a panel can show and hide itself
/// from its binding object's state without the owner touching UI objects.
///
public Func? VisibleSource { 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; }
private int _zOrder;
/// Painter's-algorithm z-order within siblings. Higher = on top.
public int ZOrder
{
get => _zOrder;
set
{
if (_zOrder == value) return;
_zOrder = value;
Parent?.InvalidateChildOrder();
}
}
/// 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; }
/// Authored window-move handle (retail UIElement_Dragbar, element
/// class 2, Register @ 0x0046C840): a left-press inside this element's subtree
/// moves its top-level window even when that window is not whole-surface
/// (retail StartMouseMoving @ 0x0046C760 calls
/// UIElement::StartMovement on the parent window). Set by the DAT widget
/// factory for Type-2 layout elements — e.g. the 600 x 5 strip along the top of the
/// combat/spell bar. Hovering it shows the window-move cursor.
public bool WindowMoveHandle { get; set; }
/// Clamp a dragged top-level window fully inside its parent. Retail
/// gmRadarUI::MoveTo enables this; most windows retain the toolkit default.
public bool ConstrainDragToParent { get; set; }
///
/// Clamp resize growth to the current parent extent. This is distinct from
/// : retail's shared main-panel host may be
/// moved freely, but its bottom resizebar grows only to the desktop edge.
///
public bool ConstrainResizeToParent { 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;
/// If true, a left-press on this element is handled BY the element (it receives the Click
/// on release) instead of being captured as a whole-window move on a Draggable ancestor. Set by
/// interactive leaf widgets (e.g. ) so they stay clickable inside a
/// whole-window-Draggable frame like the inventory window — where, without this, the IA-12
/// whole-window-drag swallows the press and the Click is never emitted. Distinct from
/// (starts a drag-drop) and (a
/// self-driven interior drag such as text selection). Default false.
public virtual bool HandlesClick => false;
///
/// Whether pointer movement should continue reaching this element while it remains
/// hovered and no mouse capture is active. Composite controls use this to update
/// procedural sub-region hover state without broadening mouse-move delivery to every
/// retained widget.
///
public virtual bool ReceivesHoverMouseMove => false;
/// Minimum size enforced while resizing.
public float MinWidth { get; set; } = 40f;
public float MinHeight { get; set; } = 40f;
/// Maximum size enforced while resizing (default unbounded).
public float MaxWidth { get; set; } = float.MaxValue;
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.
private AnchorEdges _anchors = AnchorEdges.Left | AnchorEdges.Top;
/// Edges this programmatic element anchors to in its parent. Assigning
/// this compatibility policy explicitly opts out of an imported DAT
/// .
public AnchorEdges Anchors
{
get => _anchors;
set
{
_anchors = value;
LayoutPolicy = null;
_anchorCaptured = false;
}
}
///
/// Exact raw-edge policy for imported DAT elements. Null for roots and
/// programmatic widgets. Controllers may set null to opt out or call
/// after intentional geometry changes.
///
public UiLayoutPolicy? LayoutPolicy { get; set; }
// ── Tree structure ──────────────────────────────────────────────────
public UiElement? Parent { get; private set; }
private readonly List _children = new();
private UiElement[]? _childrenBackToFront;
private UiElement[]? _childrenFrontToBack;
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);
InvalidateChildOrder();
}
///
/// Retail's GetChildRecursive: a depth-first search of
/// and every descendant (not just direct
/// children) for a widget carrying .
/// Shared by 's tab-table resolution and any
/// controller that needs to resolve an element that may be
/// DUPLICATED (same numeric id) across sibling subtrees — e.g. the
/// Options panel's Apply/Reset/Defaults buttons, which the
/// Character/Chat/Config pages each author under the SAME ids
/// (Campaign OP research doc §3.1), so a flat
/// id lookup over the whole
/// panel tree cannot reliably pick the right page's own instance —
/// scoping the search to one page's subtree root can.
///
public static UiElement? FindDescendant(UiElement root, uint datElementId)
{
ArgumentNullException.ThrowIfNull(root);
if (root.DatElementId == datElementId) return root;
foreach (UiElement child in root.Children)
{
UiElement? found = FindDescendant(child, datElementId);
if (found is not null) return found;
}
return null;
}
public virtual bool RemoveChild(UiElement child)
{
if (!_children.Contains(child)) return false;
FindRoot()?.OnSubtreeRemoving(child);
_children.Remove(child);
child.Parent = null;
InvalidateChildOrder();
return true;
}
///
/// Stable snapshots of the current sibling order. Retained UI mutation is
/// render-thread-owned, so the arrays can be reused until membership or a
/// child's Z-order changes. A traversal keeps its local array if a callback
/// mutates the tree, preserving the prior snapshot semantics without a
/// per-element allocation on every draw and overlay pass.
///
internal UiElement[] ChildrenBackToFrontSnapshot()
{
if (_childrenBackToFront is not null)
return _childrenBackToFront;
_childrenBackToFront = _children.ToArray();
Array.Sort(
_childrenBackToFront,
static (a, b) => a.ZOrder.CompareTo(b.ZOrder));
return _childrenBackToFront;
}
internal UiElement[] ChildrenFrontToBackSnapshot()
{
if (_childrenFrontToBack is not null)
return _childrenFrontToBack;
UiElement[] backToFront = ChildrenBackToFrontSnapshot();
_childrenFrontToBack = new UiElement[backToFront.Length];
for (int source = backToFront.Length - 1, destination = 0;
source >= 0;
source--, destination++)
{
_childrenFrontToBack[destination] = backToFront[source];
}
return _childrenFrontToBack;
}
private void InvalidateChildOrder()
{
_childrenBackToFront = null;
_childrenFrontToBack = null;
}
///
/// 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) { }
///
/// Whether THIS element's own draw AND its descendants' drawing/hit-testing are
/// clipped to this element's local bounds. THIS IS THE DEFAULT (true) FOR EVERY
/// ELEMENT — CT-GF1 port of retail's ancestor-clip chain: UIRegion::DrawHere
/// @0x0069FA30 takes the element's screen Box2D plus a
/// SmartArray<Box2D> of inherited clip rects, intersects them (the
/// min/max clamp loop @0x0069FAA7..0x0069FB82), and draws — EraseSelf/
/// DrawChildren/DrawSelf ALL receive the intersected rect — ONLY
/// when the intersection is non-empty (the var_24 gate @0x0069FB8E). An
/// element positioned outside its parent's box therefore silently disappears in
/// retail, exactly like /
/// now does for every element by default —
/// 's fix-round shape (S2) pushes right after
/// PushAlpha and wraps OnDraw + the children walk +
/// OnDrawAfterChildren in ONE block, the literal DrawHere shape
/// (retail clips the element's OWN DrawSelf too, not just its children —
/// UIElement_Text::DrawSelf @0x00467AA0 locks glyph blits to its own
/// clipped surface rect; UIRegion::DrawSelf @0x0069F1A0 blits per clip
/// rect). Not just the scrollable listboxes that opted in before this default
/// flipped (owner gate finding: the Titles page's authored divider 0x10000530
/// escaped the Character window at the CT6-correct 372px mounted default —
/// retail clips it away; acdream drew it floating above the window).
///
///
/// Override to ONLY for a widget that must draw or accept
/// input beyond its own bounds by deliberate design — today just
/// (whose popup, and its own out-of-bounds OnHitTest
/// override, stands in for retail's separate top-level popup region — see
/// for the drawing half of that opt-out and the
/// divergence register row it cites) and (whose own region
/// IS the screen — the viewport itself already scissors it, so narrowing to
/// (0,0,Width,Height) here would blank the whole UI the moment the root's
/// own tracked size is ever momentarily zero, e.g. before the first resize event
/// lands).
///
///
protected virtual bool ClipsChildren => true;
///
/// True when this element's content must ignore the
/// standard ancestor clip chain that now threads through
/// every element by default (CT-GF1). Retail spawns popups/dropdowns as SEPARATE
/// top-level regions (UIElement_Menu::MakePopup), so only the SCREEN clips
/// them — never an intervening window or panel's own client rect. Ours draws a
/// popup INLINE from its owning widget instead of reparenting to a new root (see
/// 's own doc comment — that second traversal already
/// exists so popups composite "regardless of this element's position in the
/// tree"), so without this escape hatch the new default clip would wrongly cut off
/// a popup that legitimately extends outside its owning window — e.g. a dropdown
/// opened near the bottom of a short window. Default false (ordinary overlay
/// content, if any is ever added beyond , stays clipped like
/// everything else). See the divergence register row this property's introducing
/// commit adds for the seam it stands in for.
///
protected virtual bool ExpandsClipForPopup => false;
/// Per-frame tick (animations, timers, caret blink).
protected virtual void OnTick(double deltaSeconds) { }
/// Called synchronously after changes.
protected virtual void OnEnabledChanged() { }
///
/// 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;
///
/// Notifies the source widget when the root starts or finishes carrying its drag payload.
/// This is a retained-widget lifecycle hook rather than item-specific root logic. Retail's
/// item implementation uses it to show/hide m_elem_Icon_Ghosted around a physical
/// item drag (ItemList_BeginDrag 0x004e32d0; SetWaitingState 0x004e11b0).
///
internal virtual void SetDragSourceActive(bool active, object? payload) { }
///
/// Tooltip text for this widget. Retail fires event 0x07 after the configured
/// hover delay (0.25 seconds by default), 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, AND text draws — CH6c ported DrawStringDat/DrawString
// through the same ApplyAlpha chokepoint as sprites/rects, matching retail's
// ChatInterface::SetOpacity (0x004F3120), which fades the whole composited window
// surface, chrome and glyphs together, not text-stays-sharp over a translucent panel).
ctx.PushTransform(Left, Top);
ctx.PushAlpha(Opacity);
// CT-GF1 fix round (S2): the clip now wraps OnDraw + children +
// OnDrawAfterChildren — the LITERAL UIRegion::DrawHere @0x0069FA30 shape,
// which clips the element's OWN DrawSelf to the intersected rect, not just its
// children (UIElement_Text::DrawSelf @0x00467AA0 locks glyph blits to arg3;
// UIRegion::DrawSelf @0x0069F1A0 blits per clip rect). Pushed right after
// PushAlpha, popped in the one finally below, so it is balanced regardless of
// which branch below runs.
bool clipsChildren = ClipsChildren;
if (clipsChildren)
ctx.PushClip(0f, 0f, Width, Height);
try
{
// N2 fix round: retail's var_24 gate @0x0069FB8E — an EMPTY intersected
// clip skips EraseSelf/DrawChildren/DrawSelf outright for the whole
// subtree. DrawOverlays (the popup's SEPARATE second traversal) does not
// share this walk or its clip-stack state, so an open UiMenu popup nested
// here keeps drawing there regardless of this cull — see
// UiAncestorClipTests' menu-inside-a-fully-clipped-window coverage.
if (!ctx.CurrentClipIsEmpty)
{
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)
{
UiElement[] ordered = ChildrenBackToFrontSnapshot();
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
{
if (clipsChildren)
ctx.PopClip();
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
{
// ExpandsClipForPopup (CT-GF1): a popup drawn here must ignore whatever
// ancestor clip the walk down to this element accumulated — see the
// property's own doc comment for the retail-parity rationale.
if (ExpandsClipForPopup)
{
ctx.PushClipUnbounded();
try { OnDrawOverlay(ctx); }
finally { ctx.PopClip(); }
}
else
{
OnDrawOverlay(ctx);
}
if (_children.Count > 0)
{
bool clipsChildren = ClipsChildren;
if (clipsChildren)
ctx.PushClip(0f, 0f, Width, Height);
try
{
UiElement[] ordered = ChildrenBackToFrontSnapshot();
for (int i = 0; i < ordered.Length; i++)
ordered[i].DrawOverlays(ctx);
}
finally
{
if (clipsChildren)
ctx.PopClip();
}
}
}
finally
{
ctx.PopAlpha();
ctx.PopTransform();
}
}
internal void TickSelfAndChildren(double dt)
{
// Evaluated before the Visible gate on purpose: the gate returns early
// for a hidden element, so a source read after it could turn an element
// off but never back on.
if (VisibleSource is { } visibility)
Visible = visibility();
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;
if (ClipsChildren
&& (localX < 0f || localX >= Width || localY < 0f || localY >= Height))
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)
{
UiElement[] ordered = ChildrenFrontToBackSnapshot();
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 (LayoutPolicy is not null)
{
var current = UiPixelRect.FromPositionAndSize(
(int)Left,
(int)Top,
(int)Width,
(int)Height);
var parent = UiPixelRect.FromPositionAndSize(0, 0, (int)parentW, (int)parentH);
var next = LayoutPolicy.Apply(current, parent);
Left = next.X0;
Top = next.Y0;
Width = next.Width;
Height = next.Height;
return;
}
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;
}
///
/// Make the current geometry the new layout baseline after an intentional
/// controller/runtime change. Compatibility anchors recapture their margins on
/// the next layout pass; imported raw-edge policies rebase immediately against
/// the current parent rect.
///
internal void ResetAnchorCapture()
{
_anchorCaptured = false;
if (LayoutPolicy is null || Parent is null) return;
LayoutPolicy.Rebase(
UiPixelRect.FromPositionAndSize(
(int)Left,
(int)Top,
(int)Width,
(int)Height),
UiPixelRect.FromPositionAndSize(
0,
0,
(int)Parent.Width,
(int)Parent.Height));
}
///
/// Capture the current mounted rect as the baseline for a compatibility
/// anchor policy immediately. Hidden trees do not receive a layout traversal,
/// so lazy capture after a persistence resize would use the wrong parent size.
///
internal void CaptureCurrentAnchorBaseline()
{
if (Parent is null || Anchors == AnchorEdges.None) return;
_anchorCaptured = false;
ApplyAnchor(Parent.Width, Parent.Height);
}
///
/// Rebase each immediate child after a host deliberately changes this element's
/// design extent. This is used when a production DAT root contains auxiliary
/// space that is cropped by its retail window mount; descendants must reflow
/// from the mounted content rect, not from the uncropped LayoutDesc display box.
///
internal void RebaseChildLayoutBaselines()
{
foreach (var child in _children)
child.ResetAnchorCapture();
}
/// 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);
}
}