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