diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs
index b22da24e..04f37772 100644
--- a/src/AcDream.App/UI/UiElement.cs
+++ b/src/AcDream.App/UI/UiElement.cs
@@ -203,6 +203,16 @@ public abstract class UiElement
///
public virtual bool OnEvent(in UiEvent e) => false;
+ /// The data this element carries when a drag begins.
+ /// pulls this on drag-promote; a NULL return CANCELS the drag (retail:
+ /// ItemList_BeginDrag only arms an occupied cell). Default null = not draggable.
+ public virtual object? GetDragPayload() => null;
+
+ /// The texture paints at the cursor while this element
+ /// is the drag source: (GL handle, width, height). Null = no ghost. Keeps
+ /// item-agnostic. Retail analog: m_dragIcon (decomp 229738).
+ public virtual (uint tex, int w, int h)? GetDragGhost() => null;
+
///
/// Tooltip text for this widget. Retail fires event 0x07 after
/// ~1000ms hover, then queries the widget's virtual "GetString"
diff --git a/src/AcDream.App/UI/UiItemSlot.cs b/src/AcDream.App/UI/UiItemSlot.cs
index d3ff3b7d..21cbc11f 100644
--- a/src/AcDream.App/UI/UiItemSlot.cs
+++ b/src/AcDream.App/UI/UiItemSlot.cs
@@ -21,6 +21,29 @@ public sealed class UiItemSlot : UiElement
/// Pre-composited icon GL texture for the bound item (0 = none).
public uint IconTexture { get; private set; }
+ /// This cell's own index within its panel (0..17 toolbar; container slot
+ /// for inventory). Distinct from (the 1–9 label, -1 on the
+ /// bottom row). Set by the controller; used as the drag payload's SourceSlot and to
+ /// identify the drop TARGET slot.
+ public int SlotIndex { get; set; } = -1;
+
+ /// What kind of slot this is, for the drag payload (retail InqDropIconInfo
+ /// flags). Controller overrides; default Inventory.
+ public ItemDragSource SourceKind { get; set; } = ItemDragSource.Inventory;
+
+ /// Drag-rollover accept frame (retail ItemSlot_DragOver_Accept 0x060011F9,
+ /// state id 0x10000041). Configurable; guard id != 0 before resolving.
+ public uint DragAcceptSprite { get; set; } = 0x060011F9u;
+ /// Drag-rollover reject frame (retail ItemSlot_DragOver_Reject 0x060011F8,
+ /// state id 0x10000040).
+ public uint DragRejectSprite { get; set; } = 0x060011F8u;
+
+ /// Accept/reject overlay state while a drag hovers this cell.
+ public enum DragAcceptState { None, Accept, Reject }
+ private DragAcceptState _dragAccept = DragAcceptState.None;
+ /// Current overlay state — internal so unit tests can assert it (InternalsVisibleTo).
+ internal DragAcceptState DragAcceptVisual => _dragAccept;
+
/// Empty-slot sprite. Default = the generic toolbar empty-slot border
/// 0x060074CF (uiitem template 0x21000037, state ItemSlot_Empty). Configurable so
/// paperdoll equip slots can use their per-slot silhouettes later.
@@ -37,6 +60,22 @@ public sealed class UiItemSlot : UiElement
public void Clear() { ItemId = 0; IconTexture = 0; }
+ ///
+ public override object? GetDragPayload()
+ => ItemId != 0 ? new ItemDragPayload(ItemId, SourceKind, SlotIndex, this) : null;
+
+ ///
+ public override (uint tex, int w, int h)? GetDragGhost()
+ => ItemId != 0 && IconTexture != 0 ? (IconTexture, (int)Width, (int)Height) : null;
+
+ /// Walk up to the containing (the drop handler owner).
+ private UiItemList? FindList()
+ {
+ UiElement? e = Parent;
+ while (e is not null) { if (e is UiItemList l) return l; e = e.Parent; }
+ return null;
+ }
+
// ── Shortcut number (slot label) ─────────────────────────────────────────
// Port of UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465).
// Retail draws the digit on the cell's ShortcutNum sub-element, picking the
@@ -100,7 +139,38 @@ public sealed class UiItemSlot : UiElement
///
public override bool OnEvent(in UiEvent e)
{
- if (e.Type == UiEventType.MouseDown) { Clicked?.Invoke(); return true; }
+ switch (e.Type)
+ {
+ // Use fires on CLICK (mouse-up over the same cell), not MouseDown — so a
+ // drag (press + >3px move) does NOT also use the item. UiRoot suppresses the
+ // post-drag Click (UiRoot.cs FinishDrag returns before the Click emit).
+ case UiEventType.MouseDown:
+ return true; // consume the press; no use here
+ case UiEventType.Click:
+ Clicked?.Invoke();
+ return true;
+
+ case UiEventType.DragBegin:
+ return true; // we're the source; payload already pulled by UiRoot
+
+ case UiEventType.DragEnter: // pointer entered me mid-drag → ask the list's handler
+ _dragAccept = (FindList() is { DragHandler: { } h } list
+ && e.Payload is ItemDragPayload p && h.OnDragOver(list, this, p))
+ ? DragAcceptState.Accept : DragAcceptState.Reject;
+ return true;
+
+ case UiEventType.DragOver: // UiRoot fires this on LEAVE → neutral
+ _dragAccept = DragAcceptState.None;
+ return true;
+
+ 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)
+ dh.HandleDropRelease(dl, this, dp);
+ return true;
+ }
return false;
}
@@ -139,5 +209,19 @@ public sealed class UiItemSlot : UiElement
}
}
}
+
+ // Drag-rollover accept/reject frame (retail SetDragAcceptState 0x10000041/40).
+ // Guard id != 0 BEFORE resolving — resolve(0) returns the 1×1 magenta placeholder
+ // with a non-zero GL handle (feedback_ui_resolve_zero_magenta).
+ if (_dragAccept != DragAcceptState.None && SpriteResolve is not null)
+ {
+ uint id = _dragAccept == DragAcceptState.Accept ? DragAcceptSprite : DragRejectSprite;
+ if (id != 0)
+ {
+ var (tex, _, _) = SpriteResolve(id);
+ if (tex != 0)
+ ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
+ }
+ }
}
}
diff --git a/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs b/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs
index b6f67bfc..d37bc66a 100644
--- a/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs
+++ b/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs
@@ -39,4 +39,96 @@ public class DragDropSpineTests
list.RegisterDragHandler(h);
Assert.Same(h, list.DragHandler);
}
+
+ // ── UiItemSlot drag-source payload/ghost ────────────────────────────────
+ [Fact]
+ public void GetDragPayload_emptyCell_isNull()
+ => Assert.Null(new UiItemSlot().GetDragPayload());
+
+ [Fact]
+ public void GetDragPayload_boundCell_snapshotsFields()
+ {
+ var cell = new UiItemSlot { SlotIndex = 4, SourceKind = ItemDragSource.ShortcutBar };
+ cell.SetItem(0x5001u, 0x99u);
+ var p = Assert.IsType(cell.GetDragPayload());
+ Assert.Equal(0x5001u, p.ObjId);
+ Assert.Equal(ItemDragSource.ShortcutBar, p.SourceKind);
+ Assert.Equal(4, p.SourceSlot);
+ Assert.Same(cell, p.SourceCell);
+ }
+
+ [Fact]
+ public void GetDragGhost_emptyCell_isNull()
+ => Assert.Null(new UiItemSlot().GetDragGhost());
+
+ [Fact]
+ public void GetDragGhost_boundCell_returnsIconTuple()
+ {
+ var cell = new UiItemSlot { Width = 32, Height = 32 };
+ cell.SetItem(0x5001u, 0x99u);
+ var g = cell.GetDragGhost();
+ Assert.NotNull(g);
+ Assert.Equal(0x99u, g!.Value.tex);
+ Assert.Equal(32, g.Value.w);
+ Assert.Equal(32, g.Value.h);
+ }
+
+ // ── cell drop-target: DragEnter overlay + DropReleased dispatch ──────────
+ private static (UiItemList list, UiItemSlot cell, SpyHandler h) ListWithHandler()
+ {
+ var list = new UiItemList(_ => (1u, 1, 1)); // non-zero resolve so overlay draw is harmless
+ var h = new SpyHandler();
+ list.RegisterDragHandler(h);
+ return (list, list.Cell, h);
+ }
+
+ private static ItemDragPayload SomePayload()
+ => new(0x5001u, ItemDragSource.ShortcutBar, 0, new UiItemSlot());
+
+ [Fact]
+ public void DragEnter_setsAcceptOverlay_whenHandlerAccepts()
+ {
+ var (_, cell, h) = ListWithHandler();
+ h.AcceptResult = true;
+ cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload()));
+ Assert.Equal(UiItemSlot.DragAcceptState.Accept, cell.DragAcceptVisual);
+ }
+
+ [Fact]
+ public void DragEnter_setsRejectOverlay_whenHandlerRejects()
+ {
+ var (_, cell, h) = ListWithHandler();
+ h.AcceptResult = false;
+ cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload()));
+ Assert.Equal(UiItemSlot.DragAcceptState.Reject, cell.DragAcceptVisual);
+ }
+
+ [Fact]
+ public void DragOver_resetsOverlayToNeutral()
+ {
+ var (_, cell, h) = ListWithHandler();
+ cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload()));
+ cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragOver, Payload: SomePayload()));
+ Assert.Equal(UiItemSlot.DragAcceptState.None, cell.DragAcceptVisual);
+ }
+
+ [Fact]
+ public void DropReleased_accepted_dispatchesToHandler()
+ {
+ var (list, cell, h) = ListWithHandler();
+ var p = SomePayload();
+ cell.OnEvent(new UiEvent(0u, cell, UiEventType.DropReleased, Data0: 1, Payload: p));
+ Assert.NotNull(h.LastDrop);
+ Assert.Same(list, h.LastDrop!.Value.list);
+ Assert.Same(cell, h.LastDrop.Value.cell);
+ Assert.Same(p, h.LastDrop.Value.payload);
+ }
+
+ [Fact]
+ public void DropReleased_notAccepted_skipsDispatch()
+ {
+ var (_, cell, h) = ListWithHandler();
+ cell.OnEvent(new UiEvent(0u, cell, UiEventType.DropReleased, Data0: 0, Payload: SomePayload()));
+ Assert.Null(h.LastDrop);
+ }
}
diff --git a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs
index 9668a586..b4204f4d 100644
--- a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs
@@ -89,8 +89,8 @@ public class ToolbarControllerTests
ToolbarController.Bind(layout, repo, () => shortcuts,
iconIds: (_,_,_,_,_) => 0x77u, useItem: g => used = g);
- // UiEvent is a positional record struct: (SourceId, Target, Type, Data0..3, Payload)
- slots[Row1[0]].Cell.OnEvent(new UiEvent(0u, null, UiEventType.MouseDown));
+ // Use now fires on Click (mouse-up), not MouseDown — drag/click disambiguation.
+ slots[Row1[0]].Cell.OnEvent(new UiEvent(0u, null, UiEventType.Click));
Assert.Equal(0x5001u, used);
}