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>
This commit is contained in:
parent
2de9cc1c19
commit
24ce8a0b5e
1 changed files with 752 additions and 0 deletions
752
docs/superpowers/plans/2026-06-20-d2b-drag-drop-spine.md
Normal file
752
docs/superpowers/plans/2026-06-20-d2b-drag-drop-spine.md
Normal file
|
|
@ -0,0 +1,752 @@
|
||||||
|
# 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 & 0xE == 0</c> fresh-from-inventory vs
|
||||||
|
/// <c>flags & 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 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;
|
||||||
|
```
|
||||||
|
|
||||||
|
(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.
|
||||||
|
```
|
||||||
Loading…
Add table
Add a link
Reference in a new issue