From 383a969c70f7f0880350462482ed8e6e3ef29af6 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 16 Jun 2026 22:36:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(D.5.1):=20ToolbarController=20=E2=80=94=20?= =?UTF-8?q?bind=2018=20slots,=20populate,=20deferred=20rebind,=20click-to-?= =?UTF-8?q?use?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port of gmToolbarUI::PostInit (slot wiring) + UpdateFromPlayerDesc (flush-and-bind shortcuts from PlayerDescription) + SetDelayedShortcutNum (deferred ItemAdded rebind) + UseShortcut (click → useItem callback). UiItemSlot gains Clicked (Action?) + OnEvent override (MouseDown → Clicked?.Invoke()) matching the retail UIElement_UIItem click dispatch pattern. UiEvent is a positional record struct so the OnEvent override reads e.Type (int) against UiEventType.MouseDown (const int 0x201) — confirmed from UiEvent.cs + UiText.cs before writing. Three tests green (populate bound slot, deferred rebind on ItemAdded, click fires useItem). Full suite: 0 failures. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../UI/Layout/ToolbarController.cs | 139 ++++++++++++++++++ src/AcDream.App/UI/UiItemSlot.cs | 11 ++ .../UI/Layout/ToolbarControllerTests.cs | 85 +++++++++++ 3 files changed, 235 insertions(+) create mode 100644 src/AcDream.App/UI/Layout/ToolbarController.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs diff --git a/src/AcDream.App/UI/Layout/ToolbarController.cs b/src/AcDream.App/UI/Layout/ToolbarController.cs new file mode 100644 index 00000000..aefa6502 --- /dev/null +++ b/src/AcDream.App/UI/Layout/ToolbarController.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; +using AcDream.Core.Items; +using AcDream.Core.Net.Messages; + +namespace AcDream.App.UI.Layout; + +/// +/// Binds the imported gmToolbarUI window (LayoutDesc 0x21000016) to live data — +/// the gm*UI::PostInit analogue. Finds the 18 shortcut slots (UiItemList) by id, +/// populates them from the persisted PlayerDescription shortcuts +/// (UpdateFromPlayerDesc), re-binds deferred slots when an item's CreateObject +/// arrives (SetDelayedShortcutNum), and on click uses the bound item +/// (UseShortcut -> ItemHolder::UseObject -> use-item callback). +/// +/// +/// Retail reference: gmToolbarUI::PostInit grabs each slot widget by its +/// id, calls UpdateFromPlayerDesc to flush-and-bind shortcuts from the +/// PlayerDescription trailer, and hooks OnEvent for the Click case to fire +/// UseShortcut. The deferred-rebind path matches +/// gmToolbarUI::SetDelayedShortcutNum which re-tries binding after +/// CreateObject resolves a formerly-unknown guid. +/// +/// +public sealed class ToolbarController +{ + // 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). + private static readonly uint[] SlotIds = + { + 0x100001A7, 0x100001A8, 0x100001A9, 0x100001AA, 0x100001AB, + 0x100001AC, 0x100001AD, 0x100001AE, 0x100001AF, + 0x100006B7, 0x100006B8, 0x100006B9, 0x100006BA, 0x100006BB, + 0x100006BC, 0x100006BD, 0x100006BE, 0x100006BF, + }; + + // Elements hidden by default in retail gmToolbarUI::PostInit: the selected-object + // vitals meters (health/stamina/mana bars that track your target) and the stack slider. + // Ids confirmed from the toolbar LayoutDesc dump. + private static readonly uint[] HiddenIds = { 0x100001A1, 0x100001A2, 0x100001A4 }; + + private readonly UiItemList?[] _slots = new UiItemList?[SlotIds.Length]; + private readonly ItemRepository _repo; + private readonly Func> _shortcuts; + private readonly Func _iconIds; // (iconId, underlayId, overlayId) → GL tex + private readonly Action _useItem; // guid → fire UseObject + + private ToolbarController( + ImportedLayout layout, + ItemRepository repo, + Func> shortcuts, + Func iconIds, + Action useItem) + { + _repo = repo; + _shortcuts = shortcuts; + _iconIds = iconIds; + _useItem = useItem; + + for (int i = 0; i < SlotIds.Length; i++) + { + _slots[i] = layout.FindElement(SlotIds[i]) as UiItemList; + if (_slots[i] is { } list) + WireClick(list); + } + + // Hide target-object meters + stack slider (gmToolbarUI::PostInit). + foreach (var id in HiddenIds) + if (layout.FindElement(id) is { } e) e.Visible = false; + + // Re-bind any deferred slot whenever the repo learns about a new/updated item. + repo.ItemAdded += _ => Populate(); + repo.ItemPropertiesUpdated += _ => Populate(); + } + + /// + /// Create and bind a to . + /// Calls immediately (binds whatever items are in the repo now). + /// Returns the controller so the caller can call again + /// if the shortcut list is refreshed outside the repo-event path. + /// + /// Imported toolbar layout (LayoutDesc 0x21000016). + /// Live item repository — must stay alive for the controller's lifetime. + /// Provider for the current shortcut bar list. + /// Resolves (iconId, underlayId, overlayId) → GL texture handle. + /// Callback fired when a bound slot is clicked; receives the item guid. + public static ToolbarController Bind( + ImportedLayout layout, + ItemRepository repo, + Func> shortcuts, + Func iconIds, + Action useItem) + { + var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem); + c.Populate(); + return c; + } + + /// + /// Port of gmToolbarUI::UpdateFromPlayerDesc: clear all slots, then bind + /// each shortcut entry that has a resolved item in the repository. + /// Entries whose item is not yet in the repo are silently skipped here; the + /// ItemAdded event re-fires this method when the item arrives + /// (matching retail's SetDelayedShortcutNum deferred-rebind path). + /// + public void Populate() + { + // Clear all slot cells first (flush). + foreach (var list in _slots) list?.Cell.Clear(); + + foreach (var sc in _shortcuts()) + { + if (sc.ObjectGuid == 0) continue; // spell-only shortcut — inventory phase + if (sc.Index >= (uint)_slots.Length) continue; + var list = _slots[(int)sc.Index]; + if (list is null) continue; + + var item = _repo.GetItem(sc.ObjectGuid); + if (item is null) continue; // deferred: ItemAdded will re-call Populate + + uint tex = _iconIds(item.IconId, item.IconUnderlayId, item.IconOverlayId); + list.Cell.SetItem(sc.ObjectGuid, tex); + } + } + + /// + /// Wire the callback on a slot cell so that + /// clicking a bound item fires with the slot's current guid. + /// Mirrors retail's gmToolbarUI click → UseShortcut dispatch. + /// + private void WireClick(UiItemList list) + { + list.Cell.Clicked = () => + { + if (list.Cell.ItemId != 0) + _useItem(list.Cell.ItemId); + }; + } +} diff --git a/src/AcDream.App/UI/UiItemSlot.cs b/src/AcDream.App/UI/UiItemSlot.cs index ebd1f33e..f6dac45d 100644 --- a/src/AcDream.App/UI/UiItemSlot.cs +++ b/src/AcDream.App/UI/UiItemSlot.cs @@ -37,6 +37,17 @@ public sealed class UiItemSlot : UiElement public void Clear() { ItemId = 0; IconTexture = 0; } + /// Invoked by when a left-button-down lands on + /// a bound slot. Wired by ToolbarController to the use-item callback. + public Action? Clicked { get; set; } + + /// + public override bool OnEvent(in UiEvent e) + { + if (e.Type == UiEventType.MouseDown) { Clicked?.Invoke(); return true; } + return false; + } + protected override void OnDraw(UiRenderContext ctx) { if (ItemId != 0 && IconTexture != 0) diff --git a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs new file mode 100644 index 00000000..44bd3416 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.Core.Items; +using AcDream.Core.Net.Messages; +using Xunit; + +namespace AcDream.App.Tests.UI.Layout; + +public class ToolbarControllerTests +{ + private static readonly uint[] Row1 = + { 0x100001A7,0x100001A8,0x100001A9,0x100001AA,0x100001AB,0x100001AC,0x100001AD,0x100001AE,0x100001AF }; + private static readonly uint[] Row2 = + { 0x100006B7,0x100006B8,0x100006B9,0x100006BA,0x100006BB,0x100006BC,0x100006BD,0x100006BE,0x100006BF }; + + private static (ImportedLayout layout, Dictionary slots) FakeToolbar() + { + var dict = new Dictionary(); + var slots = new Dictionary(); + var root = new UiPanel(); + foreach (var id in Row1) AddSlot(id); + foreach (var id in Row2) AddSlot(id); + return (new ImportedLayout(root, dict), slots); + + void AddSlot(uint id) + { + var list = new UiItemList(_ => (0u, 0, 0)) { Width = 32, Height = 32 }; + dict[id] = list; slots[id] = list; root.AddChild(list); + } + } + + [Fact] + public void Populate_bindsShortcutToCorrectSlot() + { + var (layout, slots) = FakeToolbar(); + var repo = new ItemRepository(); + repo.AddOrUpdate(new ItemInstance { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); + var shortcuts = new List + { new(Index: 0, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) }; + + ToolbarController.Bind(layout, repo, () => shortcuts, + iconIds: (_,_,_) => 0x77u, useItem: _ => { }); + + Assert.Equal(0x5001u, slots[Row1[0]].Cell.ItemId); + Assert.Equal(0x77u, slots[Row1[0]].Cell.IconTexture); + Assert.Equal(0u, slots[Row1[1]].Cell.ItemId); // others empty + } + + [Fact] + public void DeferredRebind_whenItemArrivesLate() + { + var (layout, slots) = FakeToolbar(); + var repo = new ItemRepository(); // item NOT present yet + var shortcuts = new List + { new(Index: 2, ObjectGuid: 0x5002u, SpellId: 0, Layer: 0) }; + + ToolbarController.Bind(layout, repo, () => shortcuts, + iconIds: (_,_,_) => 0x88u, useItem: _ => { }); + Assert.Equal(0u, slots[Row1[2]].Cell.ItemId); // not bound yet + + repo.AddOrUpdate(new ItemInstance { ObjectId = 0x5002u, WeenieClassId = 1u, IconId = 0x06005678u }); + + Assert.Equal(0x5002u, slots[Row1[2]].Cell.ItemId); // rebound on ItemAdded + } + + [Fact] + public void Click_emitsUseForBoundItem() + { + var (layout, slots) = FakeToolbar(); + var repo = new ItemRepository(); + repo.AddOrUpdate(new ItemInstance { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); + var shortcuts = new List + { new(Index: 0, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) }; + uint used = 0; + + ToolbarController.Bind(layout, repo, () => shortcuts, + iconIds: (_,_,_) => 0x77u, useItem: g => used = g); + // UiEvent is a positional record struct: (SourceId, Target, Type, Data0..3, Payload) + slots[Row1[0]].Cell.OnEvent(new UiEvent(0u, null, UiEventType.MouseDown)); + + Assert.Equal(0x5001u, used); + } +}