feat(ui): D.5.3/B.2 — spine: drag-lift hook + ghost snapshot (full opacity) + drop-on-hit-only

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-20 15:22:27 +02:00
parent a497cc631e
commit 41bb70c11a
5 changed files with 84 additions and 26 deletions

View file

@ -71,6 +71,9 @@ public sealed class UiRoot : UiElement
/// <summary>Current drag source (set between drag-begin and drop/cancel).</summary>
public UiElement? DragSource { get; private set; }
public object? DragPayload { get; private set; }
private (uint tex, int w, int h)? _dragGhost;
/// <summary>Snapshotted drag-ghost (tex,w,h), exposed for tests. See BeginDrag.</summary>
internal (uint tex, int w, int h)? DragGhostForTest => _dragGhost;
private UiElement? _lastDragHoverTarget;
private int _pressX, _pressY;
private bool _dragCandidate;
@ -145,17 +148,15 @@ public sealed class UiRoot : UiElement
ctx.EndOverlayLayer();
}
/// <summary>Translucency of the cursor-following drag ghost. AP-47: we reuse the
/// item's full composited icon at this alpha rather than retail's dedicated
/// underlay-less m_pDragIcon.</summary>
private const float GhostAlpha = 0.6f;
private const float GhostAlpha = 1.0f; // retail m_dragIcon is the full icon, no fade
/// <summary>Paint the drag ghost at the cursor. The texture comes from the source
/// element's <see cref="UiElement.GetDragGhost"/> so UiRoot stays item-agnostic; the
/// ghost is NOT a tree element, so it never intercepts hit-tests.</summary>
/// <summary>Paint the drag ghost at the cursor. The texture comes from the snapshotted
/// ghost captured in <see cref="BeginDrag"/> so UiRoot stays item-agnostic and the ghost
/// survives the source cell emptying on lift; the ghost is NOT a tree element, so it
/// never intercepts hit-tests.</summary>
private void DrawDragGhost(UiRenderContext ctx)
{
if (DragSource?.GetDragGhost() is not { } g || g.tex == 0) return;
if (_dragGhost is not { } g || g.tex == 0) return;
ctx.DrawSprite(g.tex, MouseX - g.w / 2f, MouseY - g.h / 2f, g.w, g.h,
0f, 0f, 1f, 1f, new Vector4(1f, 1f, 1f, GhostAlpha));
}
@ -476,13 +477,11 @@ public sealed class UiRoot : UiElement
private void BeginDrag(UiElement source)
{
// Pull the payload from the source; a null payload (e.g. an empty item cell)
// CANCELS the drag — retail's ItemList_BeginDrag only arms an occupied cell.
var payload = source.GetDragPayload();
if (payload is null) { _dragCandidate = false; return; }
DragSource = source;
DragPayload = payload;
_dragGhost = source.GetDragGhost(); // snapshot NOW — the DragBegin handler may empty the source cell
var e = new UiEvent(source.EventId, source, UiEventType.DragBegin, Payload: payload);
source.OnEvent(in e);
}
@ -515,16 +514,18 @@ public sealed class UiRoot : UiElement
private void FinishDrag(int x, int y)
{
var (t, lx, ly) = HitTestTopDown(x, y);
var target = t ?? DragSource!;
var accepted = t is not null && t != DragSource;
var e = new UiEvent(DragSource!.EventId, target, UiEventType.DropReleased,
Data0: accepted ? 1 : 0,
Data1: (int)lx, Data2: (int)ly,
Payload: DragPayload);
target.OnEvent(in e);
if (t is not null)
{
// Dropped on a real element — deliver DropReleased; the hit cell's handler places.
// A non-item target's OnEvent ignores it, so an off-bar drop leaves the lift's removal.
var e = new UiEvent(DragSource!.EventId, t, UiEventType.DropReleased,
Data1: (int)lx, Data2: (int)ly, Payload: DragPayload);
t.OnEvent(in e);
}
// else: dropped on nothing — no drop fires; the lift (drag-begin removal) stands (retail).
DragSource = null;
DragPayload = null;
_dragGhost = null;
_lastDragHoverTarget = null;
}