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

@ -9,24 +9,15 @@ namespace AcDream.App.UI;
public enum ResizeEdges { None = 0, Left = 1, Right = 2, Top = 4, Bottom = 8 }
/// <summary>
/// Top-level UI container. Implements the retail "Device" responsibilities
/// Top-level UI container. Implements the retail <c>UIElementManager</c> responsibilities
/// (mouse cursor tracking, keyboard focus, modal overlay, mouse capture,
/// drag-drop state machine, tooltip timer). Routes Silk.NET input events
/// into the widget tree with retail-faithful <see cref="UiEvent"/>
/// semantics.
///
/// Retail analog: the <c>DAT_00837ff4</c> Device object (see
/// <c>docs/research/retail-ui/04-input-events.md §2</c>). That object has
/// a ~20-slot vtable; the methods we emulate here are:
///
/// <list type="bullet">
/// <item>+0x18 / +0x1C : <see cref="MouseX"/> / <see cref="MouseY"/></item>
/// <item>+0x34 : <see cref="RegisterTimerEvent"/> (tooltip delay)</item>
/// <item>+0x38 : <see cref="FireEvent"/></item>
/// <item>+0x44 : <see cref="KeyboardFocus"/></item>
/// <item>+0x48 / +0x4C : <see cref="SetCapture"/> / <see cref="ReleaseCapture"/></item>
/// <item>+0x74 / +0x78 : drag cursor set / reset</item>
/// </list>
/// Retail analog: <c>UIElementManager::UseTime @ 0x0045CFD0</c>. Tooltip
/// deadlines are polled before global time message 3; there is no generic Device
/// timer queue in the named client.
///
/// When no widget consumes an event, the <see cref="WorldMouseFallThrough"/>
/// or <see cref="WorldKeyFallThrough"/> event fires so the game world
@ -125,8 +116,10 @@ public sealed class UiRoot : UiElement
// Hover / tooltip tracking.
private UiElement? _hoverWidget;
private long _hoverStartedMs;
private const int TooltipDelayMs = 1000; // retail typical
public int TooltipDelayMs { get; set; } = 250;
public int TooltipDurationMs { get; set; } = 10_000;
private bool _tooltipFired;
private long _tooltipShownMs;
private long _nowMs;
@ -152,10 +145,92 @@ public sealed class UiRoot : UiElement
public override void AddChild(UiElement child)
{
if (child.EventId == 0) child.EventId = _nextEventId++;
AssignEventIds(child);
base.AddChild(child);
}
private void AssignEventIds(UiElement element)
{
if (element.EventId == 0)
element.EventId = _nextEventId++;
foreach (var child in element.Children)
AssignEventIds(child);
}
private static void BroadcastGlobalUiTime(UiElement element, double nowSeconds)
{
if (element is IUiGlobalTimeListener listener)
listener.OnGlobalUiTime(nowSeconds);
// A listener may synchronously close/remove a window. Snapshot the walk,
// then skip children no longer owned by this parent so a deleted subtree
// cannot receive a stale pulse and collection mutation cannot invalidate it.
foreach (var child in element.Children.ToArray())
if (ReferenceEquals(child.Parent, element))
BroadcastGlobalUiTime(child, nowSeconds);
}
internal void OnSubtreeRemoving(UiElement subtree)
{
if (IsWithinSubtree(KeyboardFocus, subtree))
SetKeyboardFocus(null);
if (IsWithinSubtree(Captured, subtree))
{
ReleaseCapture();
_dragCandidate = false;
}
if (IsWithinSubtree(DefaultTextInput, subtree))
DefaultTextInput = null;
if (IsWithinSubtree(Modal, subtree))
Modal = null;
if (IsWithinSubtree(DragSource, subtree))
{
DragSource = null;
DragPayload = null;
_dragGhost = null;
_dragCandidate = false;
}
if (IsWithinSubtree(_hoverWidget, subtree))
{
var leave = new UiEvent(_hoverWidget!.EventId, _hoverWidget, UiEventType.HoverLeave);
_hoverWidget.OnEvent(in leave);
_hoverWidget = null;
_tooltipFired = false;
}
if (IsWithinSubtree(_lastDragHoverTarget, subtree))
_lastDragHoverTarget = null;
if (IsWithinSubtree(_lastClickTarget, subtree))
_lastClickTarget = null;
if (IsWithinSubtree(_windowDragTarget, subtree))
{
_windowDragTarget = null;
_dragCandidate = false;
}
if (IsWithinSubtree(_resizeTarget, subtree))
{
_resizeTarget = null;
_dragCandidate = false;
}
foreach (string name in _windows
.Where(pair => IsWithinSubtree(pair.Value, subtree))
.Select(pair => pair.Key)
.ToArray())
{
_windows.Remove(name);
}
}
private static bool IsWithinSubtree(UiElement? element, UiElement subtree)
{
while (element is not null)
{
if (ReferenceEquals(element, subtree)) return true;
element = element.Parent;
}
return false;
}
// ── Per-frame pumping ────────────────────────────────────────────────
public void Tick(double dt, long nowMs)
@ -170,8 +245,18 @@ public sealed class UiRoot : UiElement
var e = new UiEvent(_hoverWidget.EventId, _hoverWidget, UiEventType.Tooltip);
_hoverWidget.OnEvent(in e);
_tooltipFired = true;
_tooltipShownMs = _nowMs;
}
else if (_hoverWidget is not null && _tooltipFired
&& _nowMs - _tooltipShownMs >= TooltipDurationMs)
{
var leave = new UiEvent(_hoverWidget.EventId, _hoverWidget, UiEventType.HoverLeave);
_hoverWidget.OnEvent(in leave);
_hoverWidget = null;
_tooltipFired = false;
}
BroadcastGlobalUiTime(this, nowMs / 1000d);
TickSelfAndChildren(dt);
}
@ -550,7 +635,13 @@ public sealed class UiRoot : UiElement
}
public void SetCapture(UiElement e) => Captured = e;
public void ReleaseCapture() => Captured = null;
public void ReleaseCapture()
{
Captured = null;
// Retail restarts the tooltip idle deadline when capture is released.
_hoverStartedMs = _nowMs;
_tooltipFired = false;
}
// ── Window manager (named top-level windows: Show / Hide / Toggle) ───
@ -694,14 +785,6 @@ public sealed class UiRoot : UiElement
target.OnEvent(in e);
}
public void RegisterTimerEvent(int type, UiElement target, int delayMs,
object? payload = null)
{
_timers.Add((_nowMs + delayMs, new UiEvent(target.EventId, target, type, Payload: payload)));
}
private readonly List<(long fireAt, UiEvent e)> _timers = new();
private void UpdateButtonFlag(UiMouseButton b, bool down)
{
switch (b)