feat(ui): D.5.3/B.1 — toolbar drag handler stub + spine wiring (TS-33)

Implements Task 4 of the drag-drop spine: ToolbarController now satisfies
IItemListDragHandler. The ctor loop wires RegisterDragHandler(this) on every
slot list and stamps Cell.SlotIndex + Cell.SourceKind=ShortcutBar, matching
retail's gmToolbarUI::PostInit/RegisterItemListDragHandler pattern. OnDragOver
accepts any non-empty payload (TS-33 stub; eligibility gate is Stream B.2).
HandleDropRelease logs and returns (no AddShortcut 0x019C / RemoveShortcut
0x019D wire yet). Three new B.1 xUnit tests cover handler registration, accept,
and inert-stub semantics. All 490 app tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-20 13:00:20 +02:00
parent 595c4bdac4
commit 3d04f61451
3 changed files with 86 additions and 2 deletions

View file

@ -150,7 +150,7 @@ accepted-divergence entries (#96, #49, #50).
---
## 4. Temporary stopgap (TS) — 31 rows
## 4. Temporary stopgap (TS) — 32 rows
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
@ -186,6 +186,7 @@ accepted-divergence entries (#96, #49, #50).
| TS-30 | Numbered chat tabs (element ids `0x10000522``0x10000525`) render as clickable buttons but do not switch channel filter or affect the transcript — tab state is a no-op | `src/AcDream.App/UI/Layout/ChatWindowController.cs:210` | Retail's tab switching routes transcript lines by chat channel (`gmMainChatUI::gmScrollWindow` sub-windows per tab); the tab wiring is D.5 scope | Tab clicks produce no visible transcript change; retail would filter to the selected channel — all chat always shows in all tabs | `gmMainChatUI::PostInit` tab setup @0x4ce2a0; holtburger chat tab handling |
| TS-31 | Squelch toggle absent (no `/squelch` slash command, no clickable name-tags to silence); retail's squelch list filters incoming chat lines | `src/AcDream.Core/Chat/ChatLog.cs` | Squelch is a social / moderation feature deferred to post-M1.5; the data structure (`ChatLog`) has no squelch set today | Any player can spam all clients; clickable-name-tag contextual menu (used in retail to squelch, tell, add-to-friends) is absent | `ChatFilter::IsSquelched`; retail right-click player name → Squelch menu |
| TS-32 | `ClientObjectTable` has no pre-queue for a child `CreateObject` that arrives before its parent (out-of-order PARENTED create); such objects are ingested as root objects and their `ContainerId` links a not-yet-known container. Retail's `null_object_table` + `null_weenie_object_table` hold unresolvable objects until the parent arrives | `src/AcDream.Core/Items/ClientObjectTable.cs` (`Ingest`) | PD↔`CreateObject` ordering is handled (upsert semantics); out-of-order PARENTED creates are observed only at high packet loss or in vendor/corpse multi-object bursts on non-loopback links; deferred to D.5.5+ | A container's child object arriving before the container is ingested as a root item — it won't appear in `GetContents` until the next `RecordMembership` or a move event corrects the parent link | `CObjectMaint::null_object_table` / `null_weenie_object_table` (acclient.h / named-retail pc) |
| 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 |
---

View file

@ -23,7 +23,7 @@ namespace AcDream.App.UI.Layout;
/// <c>CreateObject</c> resolves a formerly-unknown guid.
/// </para>
/// </summary>
public sealed class ToolbarController
public sealed class ToolbarController : IItemListDragHandler
{
// Slot element ids in slot-index order (toolbar LayoutDesc 0x21000016, pre-dump).
// Row 1 = slots 0-8 (0x100001A7..0x100001AF), Row 2 = slots 9-17 (0x100006B7..0x100006BF).
@ -96,7 +96,15 @@ public sealed class ToolbarController
{
_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 = ItemDragSource.ShortcutBar;
}
}
// Cache the four mutually-exclusive combat-mode indicator elements.
@ -287,4 +295,22 @@ public sealed class ToolbarController
_useItem(list.Cell.ItemId);
};
}
// ── 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}");
}
}

View file

@ -421,4 +421,61 @@ public class ToolbarControllerTests
Assert.Equal(callsAfterBind, iconCallCount); // unchanged
}
// ── 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
}
}