feat(ui): port retained widget foundations

This commit is contained in:
Erik 2026-07-10 17:55:41 +02:00
parent 44f9ec13d9
commit d825572e31
44 changed files with 84813 additions and 292 deletions

View file

@ -121,7 +121,17 @@ public abstract class UiElement
// ── State flags ─────────────────────────────────────────────────────
public bool Visible { get; set; } = true;
public bool Enabled { get; set; } = true;
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
@ -206,7 +216,28 @@ public abstract class UiElement
/// <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;
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; }
@ -223,7 +254,9 @@ public abstract class UiElement
public virtual bool RemoveChild(UiElement child)
{
if (!_children.Remove(child)) return false;
if (!_children.Contains(child)) return false;
FindRoot()?.OnSubtreeRemoving(child);
_children.Remove(child);
child.Parent = null;
return true;
}
@ -271,6 +304,9 @@ public abstract class UiElement
/// <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"/>).
@ -296,8 +332,8 @@ public abstract class UiElement
public virtual (uint tex, int w, int h)? GetDragGhost() => null;
/// <summary>
/// Tooltip text for this widget. Retail fires event 0x07 after
/// ~1000ms hover, then queries the widget's virtual "GetString"
/// 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;
@ -418,6 +454,22 @@ public abstract class UiElement
/// 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)
{