acdream/docs/superpowers/plans/2026-06-20-d2b-drag-drop-spine.md
Erik 24ce8a0b5e docs(D.5.3/B.1): drag-drop spine implementation plan
Bite-sized TDD plan for Stream B.1, four tasks matching the spec slices:
 1. ItemDragPayload + ItemDragSource + IItemListDragHandler + UiItemList
    handler registration.
 2. UiElement GetDragPayload/GetDragGhost virtuals + UiItemSlot drag source
    + drop target + accept/reject overlay; use moves MouseDown->Click (fixes
    the existing ToolbarControllerTests.Click test in-task to stay green).
 3. UiRoot payload pull + cancel-on-null + cursor ghost (AP-47).
 4. ToolbarController stub handler + spine wiring (TS-33) + registration tests.

Exact code, commands, and the AP-47/TS-33 register rows inline. Plan + spec
preapproved by the user; executing subagent-driven next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:31:37 +02:00

752 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Drag-drop spine (Stream B.1) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Complete the widget-level drag-drop machine so an item icon can be picked up, dragged with a cursor-following ghost, hovered slots flip accept/reject, and the drop is dispatched to the owning panel's handler — with a visible toolbar stub proving the chain this session.
**Architecture:** `UiRoot` already holds the device-level drag state machine. We add (1) a typed `ItemDragPayload` + `IItemListDragHandler`, (2) two `UiElement` virtuals (`GetDragPayload`/`GetDragGhost`) so `UiRoot` stays item-agnostic, (3) drag/drop event handling + an accept/reject overlay on the cell (`UiItemSlot`), which delegates the accept decision + dispatch UP to its parent `UiItemList`'s registered handler, and (4) a logging toolbar stub handler (wire deferred to B.2). Spec: `docs/superpowers/specs/2026-06-20-d2b-drag-drop-spine-design.md`.
**Tech Stack:** C# / .NET 10, xUnit (`[Fact]`, `Assert.*`), the `src/AcDream.App/UI/` retained-mode toolkit. `InternalsVisibleTo("AcDream.App.Tests")` is set — internal members are test-visible. Tests are pure-logic (no GL); the cursor ghost render is visual-only (no unit test), confirmed by the user at the end.
---
## File Structure
**Create:**
- `src/AcDream.App/UI/ItemDragPayload.cs``ItemDragSource` enum + `ItemDragPayload` record (the drag snapshot).
- `src/AcDream.App/UI/IItemListDragHandler.cs` — the panel-handler interface.
- `tests/AcDream.App.Tests/UI/DragDropSpineTests.cs` — toolkit-level conformance.
**Modify:**
- `src/AcDream.App/UI/UiItemList.cs``DragHandler` property + `RegisterDragHandler`.
- `src/AcDream.App/UI/UiElement.cs``GetDragPayload()` + `GetDragGhost()` virtuals.
- `src/AcDream.App/UI/UiItemSlot.cs``SlotIndex`, `SourceKind`, accept/reject overlay, the two overrides, drag/drop `OnEvent` cases, `FindList`, MouseDown→Click use move.
- `src/AcDream.App/UI/UiRoot.cs``BeginDrag` payload pull + cancel; cursor ghost in `Draw`.
- `src/AcDream.App/UI/Layout/ToolbarController.cs` — implement `IItemListDragHandler`, register on each slot list, set `SlotIndex`/`SourceKind`, stub `HandleDropRelease`.
- `tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs` — fix `Click_emitsUseForBoundItem` (send Click not MouseDown), add registration tests.
- `docs/architecture/retail-divergence-register.md` — add **AP-47** (Task 3) + **TS-33** (Task 4).
**Build/test commands** (run from repo root):
- Build: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
- Test a class: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine"`
- Full app tests: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj`
**Every commit ends with the trailer** `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>` (per CLAUDE.md).
---
## Task 1: Payload types + handler interface + UiItemList registration
**Files:**
- Create: `src/AcDream.App/UI/ItemDragPayload.cs`
- Create: `src/AcDream.App/UI/IItemListDragHandler.cs`
- Modify: `src/AcDream.App/UI/UiItemList.cs`
- Test: `tests/AcDream.App.Tests/UI/DragDropSpineTests.cs`
- [ ] **Step 1: Write the failing test**
Create `tests/AcDream.App.Tests/UI/DragDropSpineTests.cs`:
```csharp
using AcDream.App.UI;
using Xunit;
namespace AcDream.App.Tests.UI;
public class DragDropSpineTests
{
// A spy handler used across the spine tests.
private sealed class SpyHandler : IItemListDragHandler
{
public bool AcceptResult = true;
public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastOver;
public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastDrop;
public bool OnDragOver(UiItemList list, UiItemSlot cell, ItemDragPayload p)
{ LastOver = (list, cell, p); return AcceptResult; }
public void HandleDropRelease(UiItemList list, UiItemSlot cell, ItemDragPayload p)
{ LastDrop = (list, cell, p); }
}
[Fact]
public void Payload_holdsAllFields()
{
var src = new UiItemSlot();
var p = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, src);
Assert.Equal(0x5001u, p.ObjId);
Assert.Equal(ItemDragSource.ShortcutBar, p.SourceKind);
Assert.Equal(3, p.SourceSlot);
Assert.Same(src, p.SourceCell);
}
[Fact]
public void UiItemList_registerDragHandler_roundtrips()
{
var list = new UiItemList(_ => (0u, 0, 0));
Assert.Null(list.DragHandler);
var h = new SpyHandler();
list.RegisterDragHandler(h);
Assert.Same(h, list.DragHandler);
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine"`
Expected: FAIL to compile — `ItemDragPayload`, `ItemDragSource`, `IItemListDragHandler`, `UiItemList.DragHandler`, `RegisterDragHandler` not defined.
- [ ] **Step 3: Create the payload + enum**
Create `src/AcDream.App/UI/ItemDragPayload.cs`:
```csharp
namespace AcDream.App.UI;
/// <summary>
/// Where a dragged item came from — the retail <c>InqDropIconInfo</c> flag
/// distinction (<c>flags &amp; 0xE == 0</c> fresh-from-inventory vs
/// <c>flags &amp; 4</c> within-list reorder) expressed as a typed enum. The drop
/// handler maps SourceKind + target back to the fresh-vs-reorder decision.
/// Decomp anchors: gmToolbarUI 0x004bd162 / 0x004bd1af; InqDropIconInfo 230533.
/// </summary>
public enum ItemDragSource { Inventory, ShortcutBar, Equipment, Ground }
/// <summary>
/// Snapshot of a drag-in-progress, taken at drag-begin (so a server move arriving
/// mid-drag can't mutate it under us). Port of retail's <c>m_dragElement</c> +
/// <c>InqDropIconInfo</c> out-params (objId/container/flags, decomp 230533).
/// <para><c>SourceContainer</c> is intentionally NOT stored: the handler resolves the
/// LIVE container via <c>ClientObjectTable.Get(ObjId).ContainerId</c> at drop — the
/// same container id retail reads off the dragged element, single source of truth.</para>
/// </summary>
public sealed record ItemDragPayload(
uint ObjId, // dragged weenie guid (retail itemID, +0x5FC)
ItemDragSource SourceKind, // what kind of slot it left
int SourceSlot, // the source cell's SlotIndex (retail m_lastShortcutNumDragged)
UiItemSlot SourceCell); // back-ref: reorder-restore / clear source state / ghost
```
- [ ] **Step 4: Create the handler interface**
Create `src/AcDream.App/UI/IItemListDragHandler.cs`:
```csharp
namespace AcDream.App.UI;
/// <summary>
/// A panel controller implements this and registers itself on each of its
/// <see cref="UiItemList"/>s. Port of retail's <c>m_dragHandler</c> vtable
/// (<c>RegisterItemListDragHandler</c>, decomp 230461; confirmed acclient
/// 0x004a539e + the gmToolbarUI block 0x004bdd89).
/// <para><see cref="OnDragOver"/> decides the accept/reject OVERLAY only (advisory).
/// <see cref="HandleDropRelease"/> is authoritative — it performs the action, or
/// no-ops to reject.</para>
/// </summary>
public interface IItemListDragHandler
{
/// <summary>True ⇒ the target cell shows the accept (green) frame; false ⇒ reject (red).</summary>
bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload);
/// <summary>Perform the drop (issue the per-panel wire action), or no-op to reject.</summary>
void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload);
}
```
- [ ] **Step 5: Add registration to UiItemList**
In `src/AcDream.App/UI/UiItemList.cs`, add inside the class (e.g. just after the `SpriteResolve` property at ~line 25):
```csharp
/// <summary>The drag handler this list routes drops to (a panel controller).
/// Null = the list accepts no drops. Retail: UIElement_ItemList::m_dragHandler.</summary>
public IItemListDragHandler? DragHandler { get; private set; }
/// <summary>Register the panel's drag handler on this list. Retail:
/// UIElement_ItemList::RegisterItemListDragHandler (decomp 230461).</summary>
public void RegisterDragHandler(IItemListDragHandler handler) => DragHandler = handler;
```
- [ ] **Step 6: Run test to verify it passes**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine"`
Expected: PASS (2 tests).
- [ ] **Step 7: Commit**
```bash
git add src/AcDream.App/UI/ItemDragPayload.cs src/AcDream.App/UI/IItemListDragHandler.cs src/AcDream.App/UI/UiItemList.cs tests/AcDream.App.Tests/UI/DragDropSpineTests.cs
git commit -m "feat(ui): D.5.3/B.1 — drag payload + handler interface + UiItemList registration" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 2: UiElement drag hooks + UiItemSlot drag/drop + overlay
**Files:**
- Modify: `src/AcDream.App/UI/UiElement.cs`
- Modify: `src/AcDream.App/UI/UiItemSlot.cs`
- Modify: `tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs` (fix the one MouseDown→Click test)
- Test: `tests/AcDream.App.Tests/UI/DragDropSpineTests.cs` (add cases)
- [ ] **Step 1: Write the failing tests** (append to `DragDropSpineTests.cs`, inside the class)
```csharp
// ── 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);
}
```
- [ ] **Step 2: Run to verify it fails**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine"`
Expected: FAIL to compile — `GetDragPayload`/`GetDragGhost` not on `UiItemSlot`, `SlotIndex`/`SourceKind`/`DragAcceptVisual`/`DragAcceptState` not defined.
- [ ] **Step 3: Add the two virtuals to UiElement**
In `src/AcDream.App/UI/UiElement.cs`, add to the "Virtual overrides" region (e.g. after `OnEvent` at ~line 204):
```csharp
/// <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;
```
- [ ] **Step 4: Add drag state + overrides + event handling to UiItemSlot**
In `src/AcDream.App/UI/UiItemSlot.cs`:
(a) Add fields/props after `SourceKind` area — insert after the `IconTexture` property (~line 22):
```csharp
/// <summary>This cell's own index within its panel (0..17 toolbar; container slot
/// for inventory). Distinct from <see cref="ShortcutNum"/> (the 19 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;
```
(b) Add the two overrides (anywhere in the class, e.g. after `Clear()`):
```csharp
/// <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;
}
```
(c) Replace the `OnEvent` method (currently MouseDown→Clicked) with:
```csharp
/// <inheritdoc/>
public override bool OnEvent(in UiEvent e)
{
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;
}
```
(d) In `OnDraw`, add the accept/reject overlay AFTER the digit-overlay block (at the very end of `OnDraw`):
```csharp
// 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);
}
}
```
- [ ] **Step 5: Fix the existing controller test that sent MouseDown**
In `tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs`, in `Click_emitsUseForBoundItem`, change the event from `MouseDown` to `Click` (the test name already says "Click"):
```csharp
// Use now fires on Click (mouse-up), not MouseDown — drag/click disambiguation.
slots[Row1[0]].Cell.OnEvent(new UiEvent(0u, null, UiEventType.Click));
```
- [ ] **Step 6: Run to verify it passes**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine|FullyQualifiedName~ToolbarController|FullyQualifiedName~UiItemSlot"`
Expected: PASS (the new DragDropSpine cases, the fixed ToolbarController test, and all UiItemSlot tests).
- [ ] **Step 7: Commit**
```bash
git add src/AcDream.App/UI/UiElement.cs src/AcDream.App/UI/UiItemSlot.cs tests/AcDream.App.Tests/UI/DragDropSpineTests.cs tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs
git commit -m "feat(ui): D.5.3/B.1 — UiItemSlot drag source + drop target + accept/reject overlay" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 3: UiRoot payload injection + cursor ghost (+ AP-47)
**Files:**
- Modify: `src/AcDream.App/UI/UiRoot.cs`
- Modify: `docs/architecture/retail-divergence-register.md`
- Test: `tests/AcDream.App.Tests/UI/DragDropSpineTests.cs` (add full-chain cases)
- [ ] **Step 1: Write the failing tests** (append to `DragDropSpineTests.cs`, inside the class)
```csharp
// ── Full UiRoot chain: arming + use-vs-drag ─────────────────────────────
// A bound, hit-testable slot inside a list, sized for the hit-test.
private static (UiRoot root, UiItemList list, UiItemSlot cell) RootWithBoundSlot(uint itemId)
{
var root = new UiRoot { Width = 800, Height = 600 };
var list = new UiItemList(_ => (1u, 1, 1)) { Left = 0, Top = 0, Width = 32, Height = 32 };
// Tests don't run OnDraw (which sizes the cell), so size the cell explicitly.
list.Cell.Width = 32; list.Cell.Height = 32;
if (itemId != 0) list.Cell.SetItem(itemId, 0x99u);
root.AddChild(list);
return (root, list, list.Cell);
}
[Fact]
public void BeginDrag_arms_whenPayloadNonNull()
{
var (root, _, cell) = RootWithBoundSlot(0x5001u);
root.OnMouseDown(UiMouseButton.Left, 10, 10);
root.OnMouseMove(20, 10); // >3px → promote to drag
Assert.Same(cell, root.DragSource);
Assert.IsType<ItemDragPayload>(root.DragPayload);
}
[Fact]
public void BeginDrag_doesNotArm_whenPayloadNull_emptySlot()
{
var (root, _, _) = RootWithBoundSlot(0u); // empty cell → GetDragPayload null
root.OnMouseDown(UiMouseButton.Left, 10, 10);
root.OnMouseMove(20, 10);
Assert.Null(root.DragSource); // never armed
}
[Fact]
public void Click_withoutDrag_firesUse()
{
var (root, _, cell) = RootWithBoundSlot(0x5001u);
bool used = false;
cell.Clicked = () => used = true;
root.OnMouseDown(UiMouseButton.Left, 10, 10);
root.OnMouseUp(UiMouseButton.Left, 10, 10); // no move → Click emitted
Assert.True(used);
}
[Fact]
public void CompletedDrag_doesNotFireUse()
{
var (root, _, cell) = RootWithBoundSlot(0x5001u);
bool used = false;
cell.Clicked = () => used = true;
root.OnMouseDown(UiMouseButton.Left, 10, 10);
root.OnMouseMove(20, 10); // promote to drag
root.OnMouseUp(UiMouseButton.Left, 20, 10); // FinishDrag, NOT Click
Assert.False(used);
}
```
- [ ] **Step 2: Run to verify it fails**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine"`
Expected: FAIL — `BeginDrag_doesNotArm_whenPayloadNull_emptySlot` fails (current `BeginDrag` arms unconditionally with `payload: null`, so `DragSource` is non-null), and `CompletedDrag_doesNotFireUse` may behave wrong because a null-payload drag would still arm.
- [ ] **Step 3: Change BeginDrag to pull the payload + cancel when null**
In `src/AcDream.App/UI/UiRoot.cs`, replace the `BeginDrag` method (~line 450):
```csharp
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;
var e = new UiEvent(source.EventId, source, UiEventType.DragBegin, Payload: payload);
source.OnEvent(in e);
}
```
Update the single call site (~line 188 in `OnMouseMove`) from `BeginDrag(Captured, payload: null);` to:
```csharp
BeginDrag(Captured);
```
- [ ] **Step 4: Add the cursor ghost to Draw**
In `src/AcDream.App/UI/UiRoot.cs`, replace the `Draw` method (~line 133) so it paints the ghost in the overlay layer, and add the helper + constant:
```csharp
public void Draw(UiRenderContext ctx)
{
DrawSelfAndChildren(ctx);
// Open popups/menus + the drag ghost draw ON TOP of the whole tree (overlay layer).
ctx.BeginOverlayLayer();
DrawOverlays(ctx);
DrawDragGhost(ctx);
ctx.EndOverlayLayer();
}
/// <summary>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.</summary>
private const float GhostAlpha = 0.6f;
/// <summary>Paint the drag ghost at the cursor. The texture comes from the source
/// element's <see cref="UiElement.GetDragGhost"/> so UiRoot stays item-agnostic; the
/// ghost is NOT a tree element, so it never intercepts hit-tests.</summary>
private void DrawDragGhost(UiRenderContext ctx)
{
if (DragSource?.GetDragGhost() 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));
}
```
- [ ] **Step 5: Run to verify it passes**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine"`
Expected: PASS (all DragDropSpine tests).
- [ ] **Step 6: Add the AP-47 divergence row**
In `docs/architecture/retail-divergence-register.md`: bump the AP section header count `## 3. Documented approximation (AP) — 42 rows``— 43 rows`, and append this row at the END of the AP table (after the `AP-46` row, before the section's `---`):
```markdown
| AP-47 | Cursor drag ghost reuses the full composited `m_pIcon` at reduced alpha (`GhostAlpha=0.6`) instead of retail's dedicated `m_pDragIcon` (base + custom-overlay, NO type-default underlay) | `src/AcDream.App/UI/UiRoot.cs` (`DrawDragGhost`/`GhostAlpha`) → `src/AcDream.App/UI/UiItemSlot.cs` (`GetDragGhost`) | Cosmetic only — the dragged item is still unambiguously identifiable; building the second underlay-less composite is deferred polish; the ghost is item-agnostic in UiRoot via `GetDragGhost()` | The ghost shows the opaque type-default underlay backing rather than retail's underlay-less translucent copy — a subtle look difference while dragging, no functional effect | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407594-407625 (m_pDragIcon path); deep-dive §3.2/§5.5 |
```
- [ ] **Step 7: Commit**
```bash
git add src/AcDream.App/UI/UiRoot.cs docs/architecture/retail-divergence-register.md tests/AcDream.App.Tests/UI/DragDropSpineTests.cs
git commit -m "feat(ui): D.5.3/B.1 — UiRoot payload injection + cursor drag ghost (AP-47)" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 4: Toolbar stub handler + wiring (+ TS-33 + registration tests)
**Files:**
- Modify: `src/AcDream.App/UI/Layout/ToolbarController.cs`
- Modify: `tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs`
- Modify: `docs/architecture/retail-divergence-register.md`
- [ ] **Step 1: Write the failing tests** (append to `ToolbarControllerTests.cs`, inside the class)
```csharp
// ── B.1: drag-drop spine wiring ──────────────────────────────────────────
/// <summary>Bind registers the controller as each slot list's drag handler and
/// stamps every cell's SlotIndex + SourceKind=ShortcutBar.</summary>
[Fact]
public void Bind_registersDragHandler_andStampsSlots()
{
var (layout, slots, _) = FakeToolbar();
var repo = new ClientObjectTable();
var ctrl = ToolbarController.Bind(layout, repo,
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
for (int i = 0; i < Row1.Length; i++)
{
Assert.Same(ctrl, slots[Row1[i]].DragHandler);
Assert.Equal(i, slots[Row1[i]].Cell.SlotIndex);
Assert.Equal(ItemDragSource.ShortcutBar, slots[Row1[i]].Cell.SourceKind);
}
// Bottom row slots are indices 9..17.
for (int j = 0; j < Row2.Length; j++)
Assert.Equal(9 + j, slots[Row2[j]].Cell.SlotIndex);
}
/// <summary>OnDragOver accepts a real item (ObjId != 0). Eligibility (IsShortcutEligible)
/// is Stream B.2; the stub accepts any non-empty payload.</summary>
[Fact]
public void OnDragOver_acceptsRealItem()
{
var (layout, slots, _) = FakeToolbar();
var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(),
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
var list = slots[Row1[0]];
var payload = new ItemDragPayload(0x5001u, ItemDragSource.Inventory, 0, new UiItemSlot());
Assert.True(ctrl.OnDragOver(list, list.Cell, payload));
}
/// <summary>HandleDropRelease is a logging stub (TS-33): it must not throw and must not
/// mutate the target slot (no wire / no store yet).</summary>
[Fact]
public void HandleDropRelease_isInertStub()
{
var (layout, slots, _) = FakeToolbar();
var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(),
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
var list = slots[Row1[2]];
var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 0, new UiItemSlot());
var ex = Record.Exception(() => ctrl.HandleDropRelease(list, list.Cell, payload));
Assert.Null(ex);
Assert.Equal(0u, list.Cell.ItemId); // unchanged — inert stub
}
```
- [ ] **Step 2: Run to verify it fails**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ToolbarController"`
Expected: FAIL to compile — `ToolbarController` doesn't implement `IItemListDragHandler` (`OnDragOver`/`HandleDropRelease`), `DragHandler` not registered.
- [ ] **Step 3: Make ToolbarController implement the handler + wire registration**
In `src/AcDream.App/UI/Layout/ToolbarController.cs`:
(a) Change the class declaration:
```csharp
public sealed class ToolbarController : IItemListDragHandler
```
(b) In the constructor, extend the slot loop (currently `_slots[i] = layout.FindElement(SlotIds[i]) as UiItemList; if (_slots[i] is { } list) WireClick(list);`) to also register the handler + stamp the cell:
```csharp
for (int i = 0; i < SlotIds.Length; i++)
{
_slots[i] = layout.FindElement(SlotIds[i]) as UiItemList;
if (_slots[i] is { } list)
{
WireClick(list);
// B.1 drag-drop spine: this controller is the drop handler for every
// toolbar slot list; each cell knows its slot index + that it's a
// shortcut-bar source (retail UIElement_ItemList::RegisterItemListDragHandler).
list.RegisterDragHandler(this);
list.Cell.SlotIndex = i;
list.Cell.SourceKind = AcDream.App.UI.ItemDragSource.ShortcutBar;
}
}
```
(c) Add the interface implementation (e.g. at the end of the class):
```csharp
// ── IItemListDragHandler (B.1 spine) ─────────────────────────────────────
// Retail: gmToolbarUI is the m_dragHandler for every shortcut slot list.
// 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).
/// <inheritdoc/>
public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
=> payload.ObjId != 0;
/// <inheritdoc/>
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
{
// TS-33: no wire yet. Stream B.2 replaces this with the ShortcutStore mutation +
// AddShortcut 0x019C / RemoveShortcut 0x019D sends (gmToolbarUI::HandleDropRelease :197971).
Console.WriteLine($"[B.2 TODO] drop obj 0x{payload.ObjId:X8} from {payload.SourceKind} " +
$"slot {payload.SourceSlot} → toolbar slot {targetCell.SlotIndex}");
}
```
(d) Confirm `using AcDream.App.UI;` is present at the top (it is). The `ItemDragSource`/`ItemDragPayload`/`UiItemSlot`/`UiItemList`/`IItemListDragHandler` types are all in `AcDream.App.UI`.
- [ ] **Step 4: Run to verify it passes**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ToolbarController"`
Expected: PASS (the 3 new B.1 tests + all existing ToolbarController tests, including the fixed Click test).
- [ ] **Step 5: Add the TS-33 divergence row**
In `docs/architecture/retail-divergence-register.md`: bump the TS section header count `## 4. Temporary stopgap (TS) — 31 rows``— 32 rows`, and append this row at the END of the TS table (after the `TS-32` row, before the section's `---`):
```markdown
| TS-33 | Toolbar `IItemListDragHandler.HandleDropRelease` is a logging stub — no `AddShortcut 0x019C` / `RemoveShortcut 0x019D` wire, no mutable `ShortcutStore`; a drop onto the bar logs and does nothing | `src/AcDream.App/UI/Layout/ToolbarController.cs` (`HandleDropRelease`) | The B.1 spine ships the drag machine (payload/ghost/overlay/dispatch) independent of the wire; the shortcut store + add/remove/reorder sends are Stream B.2, which needs the inventory window as a drag source too | A player dragging onto/within the hotbar sees the ghost + accept overlay but the drop is inert until B.2 — add/remove/reorder don't persist | `gmToolbarUI::HandleDropRelease` acclient_2013_pseudo_c.txt:197971; `AddShortcut`/`RemoveShortcut` 0x019C/0x019D |
```
- [ ] **Step 6: Run the FULL app test suite (no regressions)**
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj`
Expected: build green; all tests pass.
- [ ] **Step 7: Commit**
```bash
git add src/AcDream.App/UI/Layout/ToolbarController.cs tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs docs/architecture/retail-divergence-register.md
git commit -m "feat(ui): D.5.3/B.1 — toolbar drag handler stub + spine wiring (TS-33)" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Final verification
- [ ] **Full solution build + test:** `dotnet build AcDream.slnx -c Debug` and `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` — both green.
- [ ] **Self-check the acceptance criteria** in the spec §9 (UiRoot item-agnostic; AP-47 + TS-33 added; counts bumped).
- [ ] **Hand back for visual verification** (the user runs the client): with `ACDREAM_RETAIL_UI=1`, in-world — grab a hotbar item and drag: a translucent ghost follows the cursor; the hovered slot shows the accept (green) frame; on release over another slot the log prints `[B.2 TODO] drop obj … → toolbar slot N`; the source item stays put; a plain click still USES the item. (Launch per CLAUDE.md "Running the client" — build green first.)
- [ ] **Update memory** if a durable lesson emerged; note in `docs/ISSUES.md` / roadmap only if scope shifted.
```