Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
The combat bar and spell bar could not be moved at all: their window mounts Draggable=false (correct - retail never whole-surface-drags them) and the authored move mechanism was missing. Retail registers element class 2 as UIElement_Dragbar (Register @ 0x0046C840); a press inside it calls UIElement::StartMovement on its parent window (StartMouseMoving @ 0x0046C760) and release calls StopMovement (@ 0x0046C7C0). The combat/spell bar layout (LayoutDesc 0x21000073) authors exactly one such element - a 600 x 5 strip along the top edge, which is where the user expects the move cursor. The powerbar, vitals, indicators, radar, and examination layouts author dragbars too, so they all gain their retail handles from this one port. Our importer knew Type 2 by name but built it as a generic UiDatElement - ClickThrough decoration, so the strip never even claimed the pointer. Now: - UiElement.WindowMoveHandle marks an authored handle; the DAT factory sets it for Type-2 elements and opts them out of ClickThrough. - A left-press inside a handle subtree moves the handle's top-level window (the outer frame directly under the root - the mounted analogue of retail's dragbar parent) even when that window is not whole-surface Draggable. Edge-resize still wins; UiLocked still gates, matching the retail locked/fixed parent-flag check. - HoverWindowMove reports the handle so the window-move cursor shows over the strip - and only there - on non-Draggable windows. Four new tests: handle press moves a non-Draggable window and stops on release, hover shows the move cursor over the strip but not the body, UiLocked suppresses both, and the factory builds Type 2 as a pointer-claiming move handle. App Release suite 3,966 / 3 skips. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
703 lines
29 KiB
C#
703 lines
29 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Numerics;
|
|
|
|
namespace AcDream.App.UI;
|
|
|
|
/// <summary>Which parent edges a child keeps a fixed margin to on resize.
|
|
/// Left+Right ⇒ width stretches; Top+Bottom ⇒ height stretches.</summary>
|
|
[System.Flags]
|
|
public enum AnchorEdges { None = 0, Left = 1, Top = 2, Right = 4, Bottom = 8 }
|
|
|
|
/// <summary>Retail dat cursor media attached to a UI state.</summary>
|
|
public readonly record struct UiCursorMedia(uint File, int HotspotX, int HotspotY)
|
|
{
|
|
public bool IsValid => File != 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Base class for every UI widget in the retained-mode tree.
|
|
///
|
|
/// Design notes:
|
|
/// - Retail AC delegates widget semantics to the external
|
|
/// <c>keystone.dll</c> library (see
|
|
/// <c>docs/research/retail-ui/02-class-hierarchy.md</c> — there is no
|
|
/// widget hierarchy inside <c>acclient.exe</c> itself). We implement
|
|
/// our own retained-mode toolkit here, matching the <i>behavior</i>
|
|
/// described in the decompile without trying to byte-match Keystone's
|
|
/// internal class layout.
|
|
/// - Events use the retail-faithful <see cref="UiEvent"/> struct and
|
|
/// the <see cref="UiEventType"/> constants so that hand-ported panel
|
|
/// code can use the same magic numbers the decompiled C uses
|
|
/// (e.g. <c>if (e.Type == 0x15) ...</c> 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 <b>screen pixels</b>, origin top-left.
|
|
/// <see cref="Bounds"/> is in the parent's local coordinate space.
|
|
/// </summary>
|
|
public abstract class UiElement
|
|
{
|
|
// ── Identity ─────────────────────────────────────────────────────────
|
|
/// <summary>
|
|
/// Unique 32-bit event ID. Retail uses the range <c>0x10000000+</c>
|
|
/// for custom app events (see
|
|
/// <c>docs/research/retail-ui/04-input-events.md §3</c>). Assigned
|
|
/// by <see cref="UiRoot"/> when the element is added to the tree.
|
|
/// </summary>
|
|
public uint EventId { get; internal set; }
|
|
|
|
/// <summary>
|
|
/// Dat LayoutDesc element id for widgets imported from retail UI data.
|
|
/// Zero for runtime-created helper widgets. This is intentionally separate
|
|
/// from <see cref="EventId"/>: event ids are runtime-local, while this id is
|
|
/// stable across retail layout dumps, decomp references, and UI probes.
|
|
/// </summary>
|
|
public uint DatElementId { get; internal set; }
|
|
|
|
/// <summary>Human-readable name for debugging / FindByName.</summary>
|
|
public string? Name { get; init; }
|
|
|
|
private readonly Dictionary<string, UiCursorMedia> _stateCursors = new();
|
|
|
|
/// <summary>Retail MediaDescCursor entries keyed by UIStateId.ToString(), or "" for DirectState.</summary>
|
|
public IReadOnlyDictionary<string, UiCursorMedia> StateCursors => _stateCursors;
|
|
|
|
/// <summary>Active state name used for cursor media. Stateful dat widgets override this.</summary>
|
|
public virtual string ActiveCursorStateName => "";
|
|
|
|
public void SetStateCursors(IReadOnlyDictionary<string, UiCursorMedia> 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 ────────────────────────────────────────────────────────
|
|
/// <summary>X in the parent's local pixel space.</summary>
|
|
public float Left { get; set; }
|
|
public float Top { get; set; }
|
|
public float Width { get; set; }
|
|
public float Height { get; set; }
|
|
|
|
/// <summary>Absolute (screen-space) top-left, computed by walking Parent.</summary>
|
|
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();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// If true, <see cref="HitTest"/> skips this element — the event
|
|
/// passes through to whatever is behind. Used by decoration widgets
|
|
/// (portrait frames, ornamental dividers).
|
|
/// </summary>
|
|
public bool ClickThrough { get; set; }
|
|
|
|
/// <summary>
|
|
/// If true, <see cref="UiRoot"/> will set focus here on click,
|
|
/// routing WM_KEYDOWN / WM_CHAR to <see cref="OnEvent"/> as
|
|
/// <see cref="UiEventType.KeyDown"/> / <see cref="UiEventType.Char"/>.
|
|
/// </summary>
|
|
public bool AcceptsFocus { get; set; }
|
|
|
|
/// <summary>
|
|
/// True if this is a text-entry (edit box); used by focus routing
|
|
/// to suppress global hotkeys while typing.
|
|
/// </summary>
|
|
public bool IsEditControl { get; set; }
|
|
|
|
private int _zOrder;
|
|
|
|
/// <summary>Painter's-algorithm z-order within siblings. Higher = on top.</summary>
|
|
public int ZOrder
|
|
{
|
|
get => _zOrder;
|
|
set
|
|
{
|
|
if (_zOrder == value) return;
|
|
_zOrder = value;
|
|
Parent?.InvalidateChildOrder();
|
|
}
|
|
}
|
|
|
|
/// <summary>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.</summary>
|
|
public float Opacity { get; set; } = 1f;
|
|
|
|
/// <summary>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).</summary>
|
|
public bool Draggable { get; set; }
|
|
|
|
/// <summary>Authored window-move handle (retail <c>UIElement_Dragbar</c>, element
|
|
/// class 2, <c>Register @ 0x0046C840</c>): a left-press inside this element's subtree
|
|
/// moves its top-level window even when that window is not whole-surface
|
|
/// <see cref="Draggable"/> (retail <c>StartMouseMoving @ 0x0046C760</c> calls
|
|
/// <c>UIElement::StartMovement</c> 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.</summary>
|
|
public bool WindowMoveHandle { get; set; }
|
|
|
|
/// <summary>Clamp a dragged top-level window fully inside its parent. Retail
|
|
/// <c>gmRadarUI::MoveTo</c> enables this; most windows retain the toolkit default.</summary>
|
|
public bool ConstrainDragToParent { get; set; }
|
|
|
|
/// <summary>
|
|
/// Clamp resize growth to the current parent extent. This is distinct from
|
|
/// <see cref="ConstrainDragToParent"/>: retail's shared main-panel host may be
|
|
/// moved freely, but its bottom resizebar grows only to the desktop edge.
|
|
/// </summary>
|
|
public bool ConstrainResizeToParent { get; set; }
|
|
|
|
/// <summary>If true, a left-drag starting near this element's edge/corner
|
|
/// resizes it (window resize). Intended for top-level panels.</summary>
|
|
public bool Resizable { get; set; }
|
|
|
|
/// <summary>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.</summary>
|
|
public bool CapturesPointerDrag { get; set; }
|
|
|
|
/// <summary>If true, a left-press-and-move on this element starts a DRAG-DROP
|
|
/// (<see cref="UiRoot"/> 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 <see cref="CapturesPointerDrag"/> (a self-driven
|
|
/// interior drag like text selection, which does NOT promote to BeginDrag). Default
|
|
/// false; overridden by drag sources (e.g. an occupied <see cref="UiItemSlot"/>).</summary>
|
|
public virtual bool IsDragSource => false;
|
|
|
|
/// <summary>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. <see cref="UiButton"/>) 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
|
|
/// <see cref="IsDragSource"/> (starts a drag-drop) and <see cref="CapturesPointerDrag"/> (a
|
|
/// self-driven interior drag such as text selection). Default false.</summary>
|
|
public virtual bool HandlesClick => false;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public virtual bool ReceivesHoverMouseMove => false;
|
|
|
|
/// <summary>Minimum size enforced while resizing.</summary>
|
|
public float MinWidth { get; set; } = 40f;
|
|
public float MinHeight { get; set; } = 40f;
|
|
|
|
/// <summary>Maximum size enforced while resizing (default unbounded).</summary>
|
|
public float MaxWidth { get; set; } = float.MaxValue;
|
|
public float MaxHeight { get; set; } = float.MaxValue;
|
|
|
|
/// <summary>Allow horizontal (width) resize. Ignored unless <see cref="Resizable"/>.</summary>
|
|
public bool ResizeX { get; set; } = true;
|
|
/// <summary>Allow vertical (height) resize. Ignored unless <see cref="Resizable"/>.</summary>
|
|
public bool ResizeY { get; set; } = true;
|
|
|
|
/// <summary>Which edges may start a resize, beyond the ResizeX/ResizeY axis gates. Default: all.
|
|
/// Set to e.g. <see cref="ResizeEdges.Bottom"/> to allow only a bottom-edge drag (the collapse toolbar).</summary>
|
|
public ResizeEdges ResizableEdges { get; set; } =
|
|
ResizeEdges.Left | ResizeEdges.Right | ResizeEdges.Top | ResizeEdges.Bottom;
|
|
|
|
/// <summary>Edges this element anchors to in its parent. Default Left|Top
|
|
/// (pinned top-left, fixed size — no reflow). Left|Right stretches width.</summary>
|
|
private AnchorEdges _anchors = AnchorEdges.Left | AnchorEdges.Top;
|
|
|
|
/// <summary>Edges this programmatic element anchors to in its parent. Assigning
|
|
/// this compatibility policy explicitly opts out of an imported DAT
|
|
/// <see cref="LayoutPolicy"/>.</summary>
|
|
public AnchorEdges Anchors
|
|
{
|
|
get => _anchors;
|
|
set
|
|
{
|
|
_anchors = value;
|
|
LayoutPolicy = null;
|
|
_anchorCaptured = false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Exact raw-edge policy for imported DAT elements. Null for roots and
|
|
/// programmatic widgets. Controllers may set null to opt out or call
|
|
/// <see cref="UiLayoutPolicy.Rebase"/> after intentional geometry changes.
|
|
/// </summary>
|
|
public UiLayoutPolicy? LayoutPolicy { get; set; }
|
|
|
|
// ── Tree structure ──────────────────────────────────────────────────
|
|
public UiElement? Parent { get; private set; }
|
|
|
|
private readonly List<UiElement> _children = new();
|
|
private UiElement[]? _childrenBackToFront;
|
|
private UiElement[]? _childrenFrontToBack;
|
|
public IReadOnlyList<UiElement> 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();
|
|
}
|
|
|
|
public virtual bool RemoveChild(UiElement child)
|
|
{
|
|
if (!_children.Contains(child)) return false;
|
|
FindRoot()?.OnSubtreeRemoving(child);
|
|
_children.Remove(child);
|
|
child.Parent = null;
|
|
InvalidateChildOrder();
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 <see cref="AcDream.App.UI.Layout.LayoutImporter"/> 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 <c>true</c>; the generic container
|
|
/// (<see cref="AcDream.App.UI.Layout.UiDatElement"/>) and panels return <c>false</c>
|
|
/// and recurse their children normally. Mirrors retail, where each
|
|
/// <c>UIElement_X::DrawSelf</c> owns its internal structure.
|
|
/// </summary>
|
|
public virtual bool ConsumesDatChildren => false;
|
|
|
|
// ── Virtual overrides ───────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Draw THIS element (not its children). Children are composited by
|
|
/// <see cref="UiRoot"/> after this returns.
|
|
/// </summary>
|
|
protected virtual void OnDraw(UiRenderContext ctx) { }
|
|
|
|
/// <summary>
|
|
/// Draw AFTER this element's own children, but still within this element's
|
|
/// transform/alpha (NOT a global pass like <see cref="OnDrawOverlay"/>). 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 <see cref="OnDraw"/>. Default: nothing.
|
|
/// </summary>
|
|
protected virtual void OnDrawAfterChildren(UiRenderContext ctx) { }
|
|
|
|
/// <summary>
|
|
/// 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 <see cref="OnDraw"/> 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.
|
|
/// </summary>
|
|
protected virtual void OnDrawOverlay(UiRenderContext ctx) { }
|
|
|
|
/// <summary>
|
|
/// When true, descendant drawing and hit-testing are clipped to this element's
|
|
/// local bounds. Scrollable listboxes use this so edge rows can remain visible
|
|
/// at arbitrary pixel offsets without painting or receiving input outside the viewport.
|
|
/// </summary>
|
|
protected virtual bool ClipsChildren => false;
|
|
|
|
/// <summary>Per-frame tick (animations, timers, caret blink).</summary>
|
|
protected virtual void OnTick(double deltaSeconds) { }
|
|
|
|
/// <summary>Called synchronously after <see cref="Enabled"/> changes.</summary>
|
|
protected virtual void OnEnabledChanged() { }
|
|
|
|
/// <summary>
|
|
/// Custom hit-test override. Default is a rectangle containment
|
|
/// check on (<see cref="Width"/>, <see cref="Height"/>).
|
|
/// </summary>
|
|
protected virtual bool OnHitTest(float localX, float localY)
|
|
=> localX >= 0f && localX < Width && localY >= 0f && localY < Height;
|
|
|
|
/// <summary>
|
|
/// Event handler. Return <c>true</c> to consume the event (the
|
|
/// <see cref="UiRoot"/> will stop propagation). Return <c>false</c>
|
|
/// to let ancestors / fall-through handle it.
|
|
/// </summary>
|
|
public virtual bool OnEvent(in UiEvent e) => false;
|
|
|
|
/// <summary>The data this element carries when a drag begins. <see cref="UiRoot"/>
|
|
/// pulls this on drag-promote; a NULL return CANCELS the drag (retail:
|
|
/// ItemList_BeginDrag only arms an occupied cell). Default null = not draggable.</summary>
|
|
public virtual object? GetDragPayload() => null;
|
|
|
|
/// <summary>The texture <see cref="UiRoot"/> paints at the cursor while this element
|
|
/// is the drag source: (GL handle, width, height). Null = no ghost. Keeps
|
|
/// <see cref="UiRoot"/> item-agnostic. Retail analog: m_dragIcon (decomp 229738).</summary>
|
|
public virtual (uint tex, int w, int h)? GetDragGhost() => null;
|
|
|
|
/// <summary>
|
|
/// 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 <c>m_elem_Icon_Ghosted</c> around a physical
|
|
/// item drag (<c>ItemList_BeginDrag</c> 0x004e32d0; <c>SetWaitingState</c> 0x004e11b0).
|
|
/// </summary>
|
|
internal virtual void SetDragSourceActive(bool active, object? payload) { }
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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)
|
|
{
|
|
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].DrawSelfAndChildren(ctx);
|
|
}
|
|
finally
|
|
{
|
|
if (clipsChildren)
|
|
ctx.PopClip();
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
}
|
|
|
|
/// <summary>Second draw traversal: re-walks the tree applying the same
|
|
/// transform/alpha as <see cref="DrawSelfAndChildren"/> and calls
|
|
/// <see cref="OnDrawOverlay"/> 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).</summary>
|
|
internal void DrawOverlays(UiRenderContext ctx)
|
|
{
|
|
if (!Visible) return;
|
|
ctx.PushTransform(Left, Top);
|
|
ctx.PushAlpha(Opacity);
|
|
try
|
|
{
|
|
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)
|
|
{
|
|
if (!Visible) return;
|
|
OnTick(dt);
|
|
for (int i = 0; i < _children.Count; i++)
|
|
_children[i].TickSelfAndChildren(dt);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Top-down, children-first hit-test. <paramref name="localX"/> /
|
|
/// <paramref name="localY"/> are in THIS element's local space.
|
|
/// Returns the topmost descendant (or this) at the point, or null.
|
|
/// </summary>
|
|
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;
|
|
|
|
/// <summary>Reposition/resize this element per <see cref="Anchors"/>, keeping
|
|
/// the margins captured (at first layout / design size) to each anchored edge.
|
|
/// Called by the parent each frame before drawing children.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
internal void CaptureCurrentAnchorBaseline()
|
|
{
|
|
if (Parent is null || Anchors == AnchorEdges.None) return;
|
|
_anchorCaptured = false;
|
|
ApplyAnchor(Parent.Width, Parent.Height);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
internal void RebaseChildLayoutBaselines()
|
|
{
|
|
foreach (var child in _children)
|
|
child.ResetAnchorCapture();
|
|
}
|
|
|
|
/// <summary>Walk up to the owning <see cref="UiRoot"/> (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.</summary>
|
|
internal UiRoot? FindRoot()
|
|
{
|
|
UiElement e = this;
|
|
while (e.Parent is not null) e = e.Parent;
|
|
return e as UiRoot;
|
|
}
|
|
|
|
/// <summary>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.</summary>
|
|
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);
|
|
}
|
|
}
|