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

@ -11,6 +11,12 @@ namespace AcDream.App.UI;
/// </summary>
public interface IItemListDragHandler
{
/// <summary>The drag STARTED from a cell in this list — retail's RecvNotice_ItemListBeginDrag
/// → RemoveShortcut (decomp 0x004bd930/0x004bd450): the handler removes the lifted item from its
/// model + wire so the source slot empties immediately. The item is "in hand" until
/// HandleDropRelease (place) or the drag ends off-target (stays removed). No restore on cancel.</summary>
void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload);
/// <summary>True ⇒ the target cell shows the accept (green) frame; false ⇒ reject (red).</summary>
bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload);

View file

@ -301,6 +301,9 @@ public sealed class ToolbarController : IItemListDragHandler
// The accept/reject gate (IsShortcutEligible) and the AddShortcut/RemoveShortcut
// wire are Stream B.2; this stub accepts any real item and LOGS the drop (TS-33).
/// <inheritdoc/>
public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) { /* Task 3: remove + wire */ }
/// <inheritdoc/>
public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
=> payload.ObjId != 0;

View file

@ -159,7 +159,11 @@ public sealed class UiItemSlot : UiElement
return true;
case UiEventType.DragBegin:
return true; // we're the source; payload already pulled by UiRoot
// Notify the source list's handler so it can lift (remove + wire) — retail
// RecvNotice_ItemListBeginDrag → RemoveShortcut. UiRoot snapshotted the ghost first.
if (FindList() is { DragHandler: { } lh } liftList && e.Payload is ItemDragPayload lp)
lh.OnDragLift(liftList, this, lp);
return true;
case UiEventType.DragEnter: // pointer entered me mid-drag → ask the list's handler
_dragAccept = (FindList() is { DragHandler: { } h } list
@ -173,9 +177,7 @@ public sealed class UiItemSlot : UiElement
case UiEventType.DropReleased:
_dragAccept = DragAcceptState.None;
if (e.Data0 == 1 // accepted = dropped on a DIFFERENT element (skips self/empty)
&& FindList() is { DragHandler: { } dh } dl
&& e.Payload is ItemDragPayload dp)
if (FindList() is { DragHandler: { } dh } dl && e.Payload is ItemDragPayload dp)
dh.HandleDropRelease(dl, this, dp);
return true;
}

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;
}