Add AnchorEdges [Flags] enum and Anchors property (default Left|Top, so all existing elements are unchanged) to UiElement. ApplyAnchor() captures the design-time margins on first call then recomputes Left/Top/Width/Height each frame; DrawSelfAndChildren drives it for every child before painting. ComputeAnchoredRect is public + static so it can be unit-tested without a running frame loop. MarkupDocument.Build gains a private Anchor() CSV parser and threads it into the <meter> initializer via the anchor= attribute. vitals.xml: remove title="Vitals" (retail vitals has no heading) and add anchor="left,top,right" to all three meter bars so they stretch when the panel is dragged wider. Two new xUnit tests in UiRootInputTests: Left+Right stretches width; Left+Top only keeps fixed size. All 19 App.Tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
281 lines
12 KiB
C#
281 lines
12 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>
|
|
/// 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>Human-readable name for debugging / FindByName.</summary>
|
|
public string? Name { get; init; }
|
|
|
|
// ── 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 ─────────────────────────────────────────────────────
|
|
public bool Visible { get; set; } = true;
|
|
public bool Enabled { get; set; } = true;
|
|
|
|
/// <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; }
|
|
|
|
/// <summary>Painter's-algorithm z-order within siblings. Higher = on top.</summary>
|
|
public int ZOrder { get; set; }
|
|
|
|
/// <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>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>Minimum size enforced while resizing.</summary>
|
|
public float MinWidth { get; set; } = 40f;
|
|
public float MinHeight { get; set; } = 40f;
|
|
|
|
/// <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>Edges this element anchors to in its parent. Default Left|Top
|
|
/// (pinned top-left, fixed size — no reflow). Left|Right stretches width.</summary>
|
|
public AnchorEdges Anchors { get; set; } = AnchorEdges.Left | AnchorEdges.Top;
|
|
|
|
// ── Tree structure ──────────────────────────────────────────────────
|
|
public UiElement? Parent { get; private set; }
|
|
|
|
private readonly List<UiElement> _children = new();
|
|
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);
|
|
}
|
|
|
|
public virtual bool RemoveChild(UiElement child)
|
|
{
|
|
if (!_children.Remove(child)) return false;
|
|
child.Parent = null;
|
|
return true;
|
|
}
|
|
|
|
// ── 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>Per-frame tick (animations, timers, caret blink).</summary>
|
|
protected virtual void OnTick(double deltaSeconds) { }
|
|
|
|
/// <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>
|
|
/// 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.
|
|
/// </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.
|
|
ctx.PushTransform(Left, Top);
|
|
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);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
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 || ClickThrough) return null;
|
|
|
|
// Children first, in reverse Z-order (topmost first).
|
|
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;
|
|
}
|
|
}
|
|
|
|
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 (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>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);
|
|
}
|
|
}
|