Map synthetic move and resize affordances to the exact DAT cursors, make chat top chrome movable, and replace stale primary-panel height caps with a dynamic screen-edge constraint. This keeps the retained wrapper adaptation aligned with retail Dragbar/Resizebar behavior.
633 lines
27 KiB
C#
633 lines
27 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; }
|
|
|
|
/// <summary>Painter's-algorithm z-order within siblings. Higher = on top.</summary>
|
|
public int ZOrder { get; set; }
|
|
|
|
/// <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>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>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();
|
|
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.Contains(child)) return false;
|
|
FindRoot()?.OnSubtreeRemoving(child);
|
|
_children.Remove(child);
|
|
child.Parent = null;
|
|
return true;
|
|
}
|
|
|
|
/// <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
|
|
{
|
|
// 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
|
|
{
|
|
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
|
|
{
|
|
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
|
|
{
|
|
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)
|
|
{
|
|
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;
|
|
|
|
/// <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);
|
|
}
|
|
}
|