diff --git a/src/AcDream.App/UI/IItemListDragHandler.cs b/src/AcDream.App/UI/IItemListDragHandler.cs
index 817effa0..2c8f1f96 100644
--- a/src/AcDream.App/UI/IItemListDragHandler.cs
+++ b/src/AcDream.App/UI/IItemListDragHandler.cs
@@ -11,6 +11,12 @@ namespace AcDream.App.UI;
///
public interface IItemListDragHandler
{
+ /// 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.
+ void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload);
+
/// True ⇒ the target cell shows the accept (green) frame; false ⇒ reject (red).
bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload);
diff --git a/src/AcDream.App/UI/Layout/ToolbarController.cs b/src/AcDream.App/UI/Layout/ToolbarController.cs
index 9cb43471..ef84a558 100644
--- a/src/AcDream.App/UI/Layout/ToolbarController.cs
+++ b/src/AcDream.App/UI/Layout/ToolbarController.cs
@@ -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).
+ ///
+ public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) { /* Task 3: remove + wire */ }
+
///
public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
=> payload.ObjId != 0;
diff --git a/src/AcDream.App/UI/UiItemSlot.cs b/src/AcDream.App/UI/UiItemSlot.cs
index bae35fe4..35973c09 100644
--- a/src/AcDream.App/UI/UiItemSlot.cs
+++ b/src/AcDream.App/UI/UiItemSlot.cs
@@ -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;
}
diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs
index 267b5688..4bb20f5d 100644
--- a/src/AcDream.App/UI/UiRoot.cs
+++ b/src/AcDream.App/UI/UiRoot.cs
@@ -71,6 +71,9 @@ public sealed class UiRoot : UiElement
/// Current drag source (set between drag-begin and drop/cancel).
public UiElement? DragSource { get; private set; }
public object? DragPayload { get; private set; }
+ private (uint tex, int w, int h)? _dragGhost;
+ /// Snapshotted drag-ghost (tex,w,h), exposed for tests. See BeginDrag.
+ 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();
}
- /// 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.
- private const float GhostAlpha = 0.6f;
+ private const float GhostAlpha = 1.0f; // retail m_dragIcon is the full icon, no fade
- /// Paint the drag ghost at the cursor. The texture comes from the source
- /// element's so UiRoot stays item-agnostic; the
- /// ghost is NOT a tree element, so it never intercepts hit-tests.
+ /// Paint the drag ghost at the cursor. The texture comes from the snapshotted
+ /// ghost captured in 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.
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;
}
diff --git a/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs b/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs
index 175c6e56..6448256e 100644
--- a/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs
+++ b/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs
@@ -11,6 +11,9 @@ public class DragDropSpineTests
public bool AcceptResult = true;
public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastOver;
public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastDrop;
+ public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastLift;
+ public void OnDragLift(UiItemList list, UiItemSlot cell, ItemDragPayload p)
+ { LastLift = (list, cell, p); }
public bool OnDragOver(UiItemList list, UiItemSlot cell, ItemDragPayload p)
{ LastOver = (list, cell, p); return AcceptResult; }
@@ -125,11 +128,54 @@ public class DragDropSpineTests
}
[Fact]
- public void DropReleased_notAccepted_skipsDispatch()
+ public void DropReleased_dispatchesToHandler_regardlessOfData0()
{
- var (_, cell, h) = ListWithHandler();
+ // Retail model: reaching the cell means a real slot was hit (FinishDrag only delivers on a
+ // hit), so the handler is authoritative — it dispatches whether or not Data0 is set.
+ var (list, cell, h) = ListWithHandler();
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DropReleased, Data0: 0, Payload: SomePayload()));
- Assert.Null(h.LastDrop);
+ Assert.NotNull(h.LastDrop);
+ }
+
+ [Fact]
+ public void DragBegin_callsHandlerOnDragLift()
+ {
+ var (list, cell, h) = ListWithHandler();
+ var p = SomePayload();
+ cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragBegin, Payload: p));
+ Assert.NotNull(h.LastLift);
+ Assert.Same(list, h.LastLift!.Value.list);
+ Assert.Same(cell, h.LastLift.Value.cell);
+ Assert.Same(p, h.LastLift.Value.payload);
+ }
+
+ [Fact]
+ public void Ghost_isSnapshottedAtBeginDrag_survivesSourceCellClearing()
+ {
+ var (root, _, cell) = RootWithBoundSlot(0x5001u); // icon tex 0x99
+ root.OnMouseDown(UiMouseButton.Left, 10, 10);
+ root.OnMouseMove(20, 10); // BeginDrag → snapshot ghost
+ cell.Clear(); // simulate the lift emptying the source
+ Assert.Equal((0x99u, 32, 32), root.DragGhostForTest);
+ }
+
+ [Fact]
+ public void FinishDrag_overNothing_deliversNoDrop_butLiftStands()
+ {
+ var root = new UiRoot { Width = 800, Height = 600 };
+ var list = new UiItemList(_ => (1u, 1, 1)) { Left = 0, Top = 0, Width = 32, Height = 32 };
+ list.Cell.Width = 32; list.Cell.Height = 32;
+ list.Cell.SetItem(0x5001u, 0x99u);
+ var h = new SpyHandler();
+ list.RegisterDragHandler(h);
+ root.AddChild(list);
+
+ root.OnMouseDown(UiMouseButton.Left, 10, 10);
+ root.OnMouseMove(20, 10); // BeginDrag → OnDragLift
+ root.OnMouseUp(UiMouseButton.Left, 600, 500); // release over empty space
+ Assert.NotNull(h.LastLift); // lift happened
+ Assert.Null(h.LastDrop); // no drop dispatched (off-bar)
+ Assert.Null(root.DragSource); // cleaned up
}
// ── Full UiRoot chain: arming + use-vs-drag ─────────────────────────────