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:
parent
9d483468d9
commit
1672eaa620
4 changed files with 189 additions and 3 deletions
|
|
@ -203,6 +203,16 @@ public abstract class UiElement
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public virtual bool OnEvent(in UiEvent e) => false;
|
public virtual bool OnEvent(in UiEvent e) => false;
|
||||||
|
|
||||||
|
/// <summary>The data this element carries when a drag begins. <see cref="UiRoot"/>
|
||||||
|
/// pulls this on drag-promote; a NULL return CANCELS the drag (retail:
|
||||||
|
/// ItemList_BeginDrag only arms an occupied cell). Default null = not draggable.</summary>
|
||||||
|
public virtual object? GetDragPayload() => null;
|
||||||
|
|
||||||
|
/// <summary>The texture <see cref="UiRoot"/> paints at the cursor while this element
|
||||||
|
/// is the drag source: (GL handle, width, height). Null = no ghost. Keeps
|
||||||
|
/// <see cref="UiRoot"/> item-agnostic. Retail analog: m_dragIcon (decomp 229738).</summary>
|
||||||
|
public virtual (uint tex, int w, int h)? GetDragGhost() => null;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tooltip text for this widget. Retail fires event 0x07 after
|
/// Tooltip text for this widget. Retail fires event 0x07 after
|
||||||
/// ~1000ms hover, then queries the widget's virtual "GetString"
|
/// ~1000ms hover, then queries the widget's virtual "GetString"
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,29 @@ public sealed class UiItemSlot : UiElement
|
||||||
/// <summary>Pre-composited icon GL texture for the bound item (0 = none).</summary>
|
/// <summary>Pre-composited icon GL texture for the bound item (0 = none).</summary>
|
||||||
public uint IconTexture { get; private set; }
|
public uint IconTexture { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>This cell's own index within its panel (0..17 toolbar; container slot
|
||||||
|
/// for inventory). Distinct from <see cref="ShortcutNum"/> (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.</summary>
|
||||||
|
public int SlotIndex { get; set; } = -1;
|
||||||
|
|
||||||
|
/// <summary>What kind of slot this is, for the drag payload (retail InqDropIconInfo
|
||||||
|
/// flags). Controller overrides; default Inventory.</summary>
|
||||||
|
public ItemDragSource SourceKind { get; set; } = ItemDragSource.Inventory;
|
||||||
|
|
||||||
|
/// <summary>Drag-rollover accept frame (retail ItemSlot_DragOver_Accept 0x060011F9,
|
||||||
|
/// state id 0x10000041). Configurable; guard id != 0 before resolving.</summary>
|
||||||
|
public uint DragAcceptSprite { get; set; } = 0x060011F9u;
|
||||||
|
/// <summary>Drag-rollover reject frame (retail ItemSlot_DragOver_Reject 0x060011F8,
|
||||||
|
/// state id 0x10000040).</summary>
|
||||||
|
public uint DragRejectSprite { get; set; } = 0x060011F8u;
|
||||||
|
|
||||||
|
/// <summary>Accept/reject overlay state while a drag hovers this cell.</summary>
|
||||||
|
public enum DragAcceptState { None, Accept, Reject }
|
||||||
|
private DragAcceptState _dragAccept = DragAcceptState.None;
|
||||||
|
/// <summary>Current overlay state — internal so unit tests can assert it (InternalsVisibleTo).</summary>
|
||||||
|
internal DragAcceptState DragAcceptVisual => _dragAccept;
|
||||||
|
|
||||||
/// <summary>Empty-slot sprite. Default = the generic toolbar empty-slot border
|
/// <summary>Empty-slot sprite. Default = the generic toolbar empty-slot border
|
||||||
/// 0x060074CF (uiitem template 0x21000037, state ItemSlot_Empty). Configurable so
|
/// 0x060074CF (uiitem template 0x21000037, state ItemSlot_Empty). Configurable so
|
||||||
/// paperdoll equip slots can use their per-slot silhouettes later.</summary>
|
/// paperdoll equip slots can use their per-slot silhouettes later.</summary>
|
||||||
|
|
@ -37,6 +60,22 @@ public sealed class UiItemSlot : UiElement
|
||||||
|
|
||||||
public void Clear() { ItemId = 0; IconTexture = 0; }
|
public void Clear() { ItemId = 0; IconTexture = 0; }
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override object? GetDragPayload()
|
||||||
|
=> ItemId != 0 ? new ItemDragPayload(ItemId, SourceKind, SlotIndex, this) : null;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override (uint tex, int w, int h)? GetDragGhost()
|
||||||
|
=> ItemId != 0 && IconTexture != 0 ? (IconTexture, (int)Width, (int)Height) : null;
|
||||||
|
|
||||||
|
/// <summary>Walk up to the containing <see cref="UiItemList"/> (the drop handler owner).</summary>
|
||||||
|
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) ─────────────────────────────────────────
|
// ── Shortcut number (slot label) ─────────────────────────────────────────
|
||||||
// Port of UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465).
|
// Port of UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465).
|
||||||
// Retail draws the digit on the cell's ShortcutNum sub-element, picking the
|
// Retail draws the digit on the cell's ShortcutNum sub-element, picking the
|
||||||
|
|
@ -100,7 +139,38 @@ public sealed class UiItemSlot : UiElement
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public override bool OnEvent(in UiEvent e)
|
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;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,4 +39,96 @@ public class DragDropSpineTests
|
||||||
list.RegisterDragHandler(h);
|
list.RegisterDragHandler(h);
|
||||||
Assert.Same(h, list.DragHandler);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -89,8 +89,8 @@ public class ToolbarControllerTests
|
||||||
|
|
||||||
ToolbarController.Bind(layout, repo, () => shortcuts,
|
ToolbarController.Bind(layout, repo, () => shortcuts,
|
||||||
iconIds: (_,_,_,_,_) => 0x77u, useItem: g => used = g);
|
iconIds: (_,_,_,_,_) => 0x77u, useItem: g => used = g);
|
||||||
// UiEvent is a positional record struct: (SourceId, Target, Type, Data0..3, Payload)
|
// Use now fires on Click (mouse-up), not MouseDown — drag/click disambiguation.
|
||||||
slots[Row1[0]].Cell.OnEvent(new UiEvent(0u, null, UiEventType.MouseDown));
|
slots[Row1[0]].Cell.OnEvent(new UiEvent(0u, null, UiEventType.Click));
|
||||||
|
|
||||||
Assert.Equal(0x5001u, used);
|
Assert.Equal(0x5001u, used);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue