feat(ui): complete retail quick-slot input

Port global toolbar use/select/create actions, migrate the collapsed Ctrl-number bindings, and route them through a focused retained-UI controller. Preserve shortcut aliases as aliases across inventory and paperdoll drops with retail's neutral/accept/reject drag states, preventing physical item moves such as unwielding an equipped helmet.

Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-11 08:59:49 +02:00
parent 6fcc510d5d
commit e65119f0c6
24 changed files with 650 additions and 69 deletions

View file

@ -379,24 +379,41 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
/// until the server confirms the move. Retail dims the source; we leave it + the floating ghost.</summary>
public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) { }
/// <summary>Advisory accept/reject overlay (green insert-arrow / red circle). Grid drops are always
/// valid (reorder/insert); a side-bag/main-pack drop is valid unless that container is KNOWN-full.</summary>
public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
/// <summary>Advisory neutral/accept/reject overlay. Shortcut aliases stay neutral; physical grid
/// drops accept; a side-bag/main-pack drop rejects only when that container is known full.</summary>
public ItemDragAcceptance OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
{
if (payload.ObjId == 0) return false;
if (targetList == _contentsGrid) return true;
// UIElement_ItemList::ItemList_DragOver @ 0x004E3400 only evaluates
// physical targets when (DropItemFlags & 0xE) == 0. A toolbar alias
// carries bit 4: leave the inventory target neutral while the alias's
// remove-on-lift stands.
if (payload.SourceKind == ItemDragSource.ShortcutBar)
return ItemDragAcceptance.None;
if (payload.ObjId == 0)
return ItemDragAcceptance.Reject;
if (targetList == _contentsGrid)
return ItemDragAcceptance.Accept;
if (targetList == _containerList || targetList == _topContainer)
{
if (targetCell.ItemId == 0 || targetCell.ItemId == payload.ObjId) return false;
return !IsContainerFull(targetCell.ItemId);
if (targetCell.ItemId == 0 || targetCell.ItemId == payload.ObjId)
return ItemDragAcceptance.Reject;
return IsContainerFull(targetCell.ItemId)
? ItemDragAcceptance.Reject
: ItemDragAcceptance.Accept;
}
return false;
return ItemDragAcceptance.Reject;
}
/// <summary>Perform the move: resolve (container, placement) from the target, optimistically move
/// locally (instant), and send PutItemInContainer. Retail: HandleDropRelease + ItemList_InsertItem.</summary>
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
{
// UIElement_ItemList::HandleDropRelease @ 0x004E4790 applies the same
// (flags & 0xE) == 0 gate before AcceptDragObject. A shortcut alias is
// not the equipped/contained object it points at.
if (payload.SourceKind == ItemDragSource.ShortcutBar)
return;
uint item = payload.ObjId;
if (item == 0) return;

View file

@ -259,11 +259,18 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
/// <summary>Accept iff the dragged item can be worn here (its ValidLocations intersects the slot mask).
/// Retail: gmPaperDollUI::OnItemListDragOver (decomp 174302).</summary>
public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
public ItemDragAcceptance OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
{
// gmPaperDollUI::OnItemListDragOver @ 0x004A4270 ignores shortcut
// aliases through its (DropItemFlags & 0xE) == 0 gate.
if (payload.SourceKind == ItemDragSource.ShortcutBar)
return ItemDragAcceptance.None;
var item = _objects.Get(payload.ObjId);
if (item is null) return false;
return (item.ValidLocations & MaskFor(targetList)) != EquipMask.None;
if (item is null)
return ItemDragAcceptance.Reject;
return (item.ValidLocations & MaskFor(targetList)) != EquipMask.None
? ItemDragAcceptance.Accept
: ItemDragAcceptance.Reject;
}
/// <summary>Wield the dropped item: optimistic local equip (instant) + GetAndWieldItem 0x001A.
@ -271,6 +278,10 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
/// chosen finger). Retail: gmPaperDollUI HandleDropRelease → GetAndWieldItem.</summary>
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
{
// gmPaperDollUI::HandleDropRelease @ 0x004A4D80 applies the same flag
// gate before accepting/wielding the physical object.
if (payload.SourceKind == ItemDragSource.ShortcutBar)
return;
var item = _objects.Get(payload.ObjId);
if (item is null) return;
EquipMask wieldMask = ItemEquipRules.ResolvePaperdollDropWieldMask(item, MaskFor(targetList));

View file

@ -71,6 +71,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
private readonly Action<uint, uint>? _sendAddShortcut; // (index, objectGuid)
private readonly Action<uint>? _sendRemoveShortcut; // (index)
private readonly ItemInteractionController? _itemInteraction;
private readonly Action<uint>? _selectItem;
private bool _disposed;
// Digit sprite DID arrays for slot labels (top row, numbers 1-9).
@ -98,7 +99,8 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
ItemInteractionController? itemInteraction = null,
Action<uint, uint>? sendAddShortcut = null,
Action<uint>? sendRemoveShortcut = null,
Action? toggleCombat = null)
Action? toggleCombat = null,
Action<uint>? selectItem = null)
{
_repo = repo;
_combatState = combatState;
@ -111,6 +113,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
_itemInteraction = itemInteraction;
_sendAddShortcut = sendAddShortcut;
_sendRemoveShortcut = sendRemoveShortcut;
_selectItem = selectItem;
for (int i = 0; i < SlotIds.Length; i++)
{
@ -233,11 +236,12 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
ItemInteractionController? itemInteraction = null,
Action<uint, uint>? sendAddShortcut = null,
Action<uint>? sendRemoveShortcut = null,
Action? toggleCombat = null)
Action? toggleCombat = null,
Action<uint>? selectItem = null)
{
var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem, combatState,
peaceDigits, warDigits, emptyDigits, itemInteraction,
sendAddShortcut, sendRemoveShortcut, toggleCombat);
sendAddShortcut, sendRemoveShortcut, toggleCombat, selectItem);
c.Populate();
return c;
}
@ -387,6 +391,95 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
};
}
/// <summary>
/// Port of <c>gmToolbarUI::UseShortcut @ 0x004BD350</c>. Target mode
/// is one-shot and precedes both normal use and selection.
/// </summary>
public bool UseShortcut(int slot, bool use)
{
if ((uint)slot >= _slots.Length || _slots[slot]?.Cell is not { } cell)
return false;
uint itemId = cell.ItemId;
if (_itemInteraction?.IsTargetModeActive == true)
{
if (itemId != 0)
_itemInteraction.OfferPrimaryClick(itemId);
else
_itemInteraction.CancelTargetMode();
return true;
}
if (itemId == 0)
return false;
if (use)
{
if (_itemInteraction is not null)
_itemInteraction.ActivateItem(itemId);
else
_useItem(itemId);
}
else
{
_selectItem?.Invoke(itemId);
}
return true;
}
/// <summary>
/// Auto-slot form of retail <c>gmToolbarUI::CreateShortcutToItem @
/// 0x004BDAC0</c>, used by the global CreateShortcut action. It rejects
/// stuck/non-player objects, non-player creatures, unowned objects,
/// duplicates, and a full bar before emitting AddShortcut.
/// </summary>
public bool CreateShortcutToItem(uint itemId)
{
if (itemId == 0 || _repo.Get(itemId) is not { } item)
return false;
var flags = (PublicWeenieFlags)(item.PublicWeenieBitfield ?? 0u);
bool isPlayer = itemId == _itemInteraction?.PlayerGuid
|| (flags & PublicWeenieFlags.Player) != 0;
if ((flags & PublicWeenieFlags.Stuck) != 0 && !isPlayer)
return false;
if ((item.Type & ItemType.Creature) != 0 && !isPlayer)
return false;
if (_itemInteraction?.IsOwnedByPlayer(itemId) != true)
return false;
EnsureStoreLoadedForMutation();
for (int slot = 0; slot < _slots.Length; slot++)
if (_store.Get(slot) == itemId)
return false;
int empty = -1;
for (int slot = 0; slot < _slots.Length; slot++)
{
if (_store.IsEmpty(slot))
{
empty = slot;
break;
}
}
if (empty < 0)
return false;
_store.Set(empty, itemId);
_sendAddShortcut?.Invoke((uint)empty, itemId);
Populate();
return true;
}
private void EnsureStoreLoadedForMutation()
{
if (_storeLoaded)
return;
_store.Load(System.Linq.Enumerable.Select(_shortcuts(), e => ((int)e.Index, e.ObjectGuid)));
_storeLoaded = true;
}
// ── IItemListDragHandler (B.2 live handler) ──────────────────────────────
// Retail: gmToolbarUI is the m_dragHandler for every shortcut slot list.
// Retail model (remove-on-lift / place-on-drop / no-restore):
@ -407,8 +500,8 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
}
/// <inheritdoc/>
public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
=> payload.ObjId != 0;
public ItemDragAcceptance OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
=> payload.ObjId != 0 ? ItemDragAcceptance.Accept : ItemDragAcceptance.None;
/// <inheritdoc/>
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)

View file

@ -0,0 +1,71 @@
using AcDream.Core.Selection;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Focused retained-toolbar adapter for retail global input actions. The input
/// dispatcher owns chords/scopes; this class only maps semantic actions to the
/// exact <c>gmToolbarUI::ListenToGlobalMessage @ 0x004BE4E0</c> slot intent.
/// </summary>
public sealed class ToolbarInputController
{
private readonly ToolbarController _toolbar;
private readonly SelectionState _selection;
public ToolbarInputController(ToolbarController toolbar, SelectionState selection)
{
_toolbar = toolbar ?? throw new ArgumentNullException(nameof(toolbar));
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
}
/// <summary>Returns true when <paramref name="action"/> belongs to the toolbar.</summary>
public bool Handle(InputAction action)
{
if (TryMapShortcut(action, out int slot, out bool use))
{
_toolbar.UseShortcut(slot, use);
return true;
}
if (action == InputAction.CreateShortcut)
{
_toolbar.CreateShortcutToItem(_selection.SelectedObjectId ?? 0u);
return true;
}
return false;
}
internal static bool TryMapShortcut(InputAction action, out int slot, out bool use)
{
int value = (int)action;
if (value >= (int)InputAction.UseQuickSlot_1
&& value <= (int)InputAction.UseQuickSlot_9)
{
slot = value - (int)InputAction.UseQuickSlot_1;
use = true;
return true;
}
if (value >= (int)InputAction.SelectQuickSlot_1
&& value <= (int)InputAction.SelectQuickSlot_9)
{
slot = value - (int)InputAction.SelectQuickSlot_1;
use = false;
return true;
}
if (value >= (int)InputAction.UseQuickSlot_14
&& value <= (int)InputAction.UseQuickSlot_18)
{
slot = 13 + value - (int)InputAction.UseQuickSlot_14;
use = true;
return true;
}
slot = -1;
use = false;
return false;
}
}