From 41bb70c11a5729a28d4533b99a4db66a9acb4dcb Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 20 Jun 2026 15:22:27 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20D.5.3/B.2=20=E2=80=94=20spine:=20dr?= =?UTF-8?q?ag-lift=20hook=20+=20ghost=20snapshot=20(full=20opacity)=20+=20?= =?UTF-8?q?drop-on-hit-only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/IItemListDragHandler.cs | 6 +++ .../UI/Layout/ToolbarController.cs | 3 ++ src/AcDream.App/UI/UiItemSlot.cs | 10 ++-- src/AcDream.App/UI/UiRoot.cs | 39 +++++++------- .../UI/DragDropSpineTests.cs | 52 +++++++++++++++++-- 5 files changed, 84 insertions(+), 26 deletions(-) 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 ─────────────────────────────