feat(ui): D.5.3/B.1 — UiItemSlot drag source + drop target + accept/reject overlay

- UiElement: two new virtuals GetDragPayload()/GetDragGhost() (default null)
  keep UiRoot item-agnostic; any leaf can opt into drag by overriding these.
- UiItemSlot: SlotIndex + SourceKind properties for payload identity; two
  overrides return ItemDragPayload / icon ghost when the slot is occupied.
  FindList() walks the parent chain to locate the owning UiItemList and its
  registered IItemListDragHandler.
- UiItemSlot.OnEvent: MouseDown now just consumes the press; use-item fires
  on Click (mouse-up) so a drag doesn't also trigger the use-item callback.
  DragEnter → ask handler, set Accept/Reject overlay. DragOver → reset to
  None (fires on leave). DropReleased → clear overlay + dispatch to handler
  when Data0 == 1 (accepted). DragBegin consumed (source).
- OnDraw: accept/reject sprite overlay drawn last, guarded on id != 0 to
  avoid the resolve(0)-→-magenta footgun.
- ToolbarControllerTests: Click_emitsUseForBoundItem changed from MouseDown
  to Click to match the new dispatch.
- 12 new DragDropSpineTests pass; full suite 481/483 (2 pre-existing skips).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-20 12:40:53 +02:00
parent 9d483468d9
commit 1672eaa620
4 changed files with 189 additions and 3 deletions

View file

@ -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<ItemDragPayload>(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);
}
}

View file

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