fix(ui): select retained items on mouse down

Port UIElement_ListBox's press-time selection ordering through the shared retained item-list contract. Inventory, loot, paperdoll, and physical shortcuts now update canonical selection before release or drag promotion, while target-mode consumption suppresses drag and release-time activation.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-24 06:17:31 +02:00
parent 2dd5cb80d2
commit 043ab10b3c
18 changed files with 292 additions and 62 deletions

View file

@ -115,8 +115,8 @@ and the final two-client portal-out/materialization observer flow are
user-gated. Deterministic world-lifecycle automation protects fresh login, user-gated. Deterministic world-lifecycle automation protects fresh login,
outdoor/world-edge/dungeon travel, same-location revisit, exact graceful outdoor/world-edge/dungeon travel, same-location revisit, exact graceful
disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts.
Release builds; the appraisal corrective implementation passes 3,496 App Release builds; the appraisal and retained-item input corrections pass 3,548
tests / 3 skips and 7,913 complete-solution tests / 5 skips. App tests / 3 skips and 7,979 complete-solution tests / 5 skips.
**Current work order:** the six-slice pre-M4 world-interaction completion **Current work order:** the six-slice pre-M4 world-interaction completion
program in `docs/plans/2026-07-23-world-interaction-completion.md`: favorite program in `docs/plans/2026-07-23-world-interaction-completion.md`: favorite
@ -125,8 +125,8 @@ equipped-child picking, vendor browsing, and authoritative vendor
transactions. Slices 1 and 2 plus the adjacent shared-cooldown presentation are transactions. Slices 1 and 2 plus the adjacent shared-cooldown presentation are
user-accepted. Slice 3's first connected gate exposed and corrected the user-accepted. Slice 3's first connected gate exposed and corrected the
monster-response flag mismatch, non-retail shared-panel mounting, and missing monster-response flag mismatch, non-retail shared-panel mounting, and missing
inscription transaction. Its independent floaty examination window awaits the inscription transaction. The creature and common-item report corrections are
corrective connected gate. user-accepted; press-time retained-item selection is the current visual gate.
**Structural prerequisite before new M4 subsystem work:** all eight **Structural prerequisite before new M4 subsystem work:** all eight
behavior-preserving `GameWindow` decomposition slices and the automated behavior-preserving `GameWindow` decomposition slices and the automated

View file

@ -113,8 +113,8 @@ and the final two-client portal-out/materialization observer flow are
user-gated. Deterministic world-lifecycle automation protects fresh login, user-gated. Deterministic world-lifecycle automation protects fresh login,
outdoor/world-edge/dungeon travel, same-location revisit, exact graceful outdoor/world-edge/dungeon travel, same-location revisit, exact graceful
disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts.
Release builds; the appraisal corrective implementation passes 3,496 App Release builds; the appraisal and retained-item input corrections pass 3,548
tests / 3 skips and 7,913 complete-solution tests / 5 skips. App tests / 3 skips and 7,979 complete-solution tests / 5 skips.
**Current work order:** the six-slice pre-M4 world-interaction completion **Current work order:** the six-slice pre-M4 world-interaction completion
program in `docs/plans/2026-07-23-world-interaction-completion.md`: favorite program in `docs/plans/2026-07-23-world-interaction-completion.md`: favorite
@ -123,8 +123,8 @@ equipped-child picking, vendor browsing, and authoritative vendor
transactions. Slices 1 and 2 plus the adjacent shared-cooldown presentation are transactions. Slices 1 and 2 plus the adjacent shared-cooldown presentation are
user-accepted. Slice 3's first connected gate exposed and corrected the user-accepted. Slice 3's first connected gate exposed and corrected the
monster-response flag mismatch, non-retail shared-panel mounting, and missing monster-response flag mismatch, non-retail shared-panel mounting, and missing
inscription transaction. Its independent floaty examination window awaits the inscription transaction. The creature and common-item report corrections are
corrective connected gate. user-accepted; press-time retained-item selection is the current visual gate.
**Structural prerequisite before new M4 subsystem work:** all eight **Structural prerequisite before new M4 subsystem work:** all eight
behavior-preserving `GameWindow` decomposition slices and the automated behavior-preserving `GameWindow` decomposition slices and the automated

View file

@ -151,6 +151,13 @@ The separate retained ItemList path now does the same for occupied backpack,
side-bag, loot-container, paperdoll-slot, and physical shortcut cells; it side-bag, loot-container, paperdoll-slot, and physical shortcut cells; it
selects the item before entering the shared appraisal owner, and RMB movement selects the item before entering the shared appraisal owner, and RMB movement
neither appraises nor lifts the item. neither appraises nor lifts the item.
The adjacent input-latency correction ports
`UIElement_ListBox::MouseDown @ 0x0046E3A0`: physical retained items now update
canonical selection and their green frame on left-button down, before the
three-pixel drag threshold. Target mode is intercepted at that same press.
Bag opening, item use/equip, and shortcut activation remain completed
click/double-click actions, so a drag or target-consumed press cannot also
activate the item.
**Files:** `src/AcDream.Core.Net/Messages/AppraiseInfoParser.cs`; **Files:** `src/AcDream.Core.Net/Messages/AppraiseInfoParser.cs`;
`src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/RetailUiRuntime.cs`;
@ -176,6 +183,8 @@ Right-clicking a visible world object opens the same examination window;
right-dragging the camera does not. right-dragging the camera does not.
Right-clicking an occupied retained item cell selects and examines it without Right-clicking an occupied retained item cell selects and examines it without
using, equipping, looting, or dragging it. using, equipping, looting, or dragging it.
Left-pressing one selects it before the button is released; holding and
dragging preserves that selection without activating the item.
The Black Phyntos Hive specifically reads `Value: ???`, `Burden: Unknown`, The Black Phyntos Hive specifically reads `Value: ???`, `Burden: Unknown`,
then its description after one retail paragraph break, without the bogus then its description after one retail paragraph break, without the bogus
255-item/255-container line. 255-item/255-container line.

View file

@ -35,7 +35,10 @@ the configurable `SelectRight` action's retail release-completed gesture,
including drag cancellation, then reuses the canonical including drag cancellation, then reuses the canonical
picker/selection/appraisal path. The parallel retained ItemList branch now picker/selection/appraisal path. The parallel retained ItemList branch now
selects and appraises occupied inventory, external-container, paperdoll, and selects and appraises occupied inventory, external-container, paperdoll, and
physical-shortcut cells through the same request owner. Slice 3's first connected gate exposed physical-shortcut cells through the same request owner. Its shared physical
left-click contract now also matches retail's press-time selection: the
selection frame updates on MouseDown before drag promotion, while completed
click/double-click remains the activation boundary. Slice 3's first connected gate exposed
three deeper defects, now corrected: the response enum mislabeled ACE's three deeper defects, now corrected: the response enum mislabeled ACE's
creature bit `0x0100` and dropped monster packets while retaining the busy creature bit `0x0100` and dropped monster packets while retaining the busy
cursor; the imported 310 x 400 examination layout was incorrectly hosted as cursor; the imported 310 x 400 examination layout was incorrectly hosted as

View file

@ -104,6 +104,10 @@ instead of appraising the release point.
Retail's separate retained ItemList right-click branch now covers physical Retail's separate retained ItemList right-click branch now covers physical
backpack, loot, paperdoll, and toolbar item cells through the same selection backpack, loot, paperdoll, and toolbar item cells through the same selection
and appraisal owner. and appraisal owner.
The same shared ItemList contract now ports retail's left-button-down
selection timing across inventory, loot, paperdoll, and physical shortcuts:
selection is visible before release or drag promotion, while use/open/equip
remains release-completed.
Slice 3's first connected gate found and corrected the real remaining defects: Slice 3's first connected gate found and corrected the real remaining defects:
ACE creature flag `0x0100` was mislabeled as a weapon profile and left the ACE creature flag `0x0100` was mislabeled as a weapon profile and left the
appraisal busy reference held, retail's examination UI had been mounted as a appraisal busy reference held, retail's examination UI had been mounted as a

View file

@ -222,9 +222,10 @@ present in the authored base name. Empty `AddItemInfo` calls are retained as
real report fragments, restoring retail's intentional blank rows after real report fragments, restoring retail's intentional blank rows after
workmanship, before armor level, around rating/special-property blocks, and at workmanship, before armor level, around rating/special-property blocks, and at
the later use/item-level boundaries. Focused wire, projection, object-table, the later use/item-level boundaries. Focused wire, projection, object-table,
title, and boots-layout fixtures protect the full path. The Release solution title, and boots-layout fixtures protect the full path. With the subsequent
build and 3,533 App tests / 3 skips plus 7,956 complete-solution tests / right-click and press-time retained-item input ports, the Release solution
5 skips pass; the connected boots comparison is the remaining visual gate. build and 3,548 App tests / 3 skips plus 7,979 complete-solution tests /
5 skips pass.
The world right-click follow-up ports The world right-click follow-up ports
`UIElement_SmartBoxWrapper::MouseUp @ 0x004E5820` and the `UIElement_SmartBoxWrapper::MouseUp @ 0x004E5820` and the
@ -241,6 +242,13 @@ The retained follow-up ports the separate
backpack, side-bag, loot, paperdoll-slot, or physical toolbar cell now selects backpack, side-bag, loot, paperdoll-slot, or physical toolbar cell now selects
its item and enters that same appraisal owner on a completed right-click. its item and enters that same appraisal owner on a completed right-click.
Right-button movement cancels the click and can never begin an item drag. Right-button movement cancels the click and can never begin an item drag.
The input-latency follow-up ports `UIElement_ListBox::MouseDown @ 0x0046E3A0`
and the physical left-click branch of
`UIElement_ItemList::ListenToElementMessage @ 0x004E4D50`. Canonical selection
and the retained green frame update during left-button down, before the
three-pixel drag threshold. Target mode is offered first. Opening, using,
equipping, looting, and shortcut activation remain completed-click or
double-click actions and are suppressed when target mode consumed the press.
## Slice 1 — spell-bar overflow arrows ## Slice 1 — spell-bar overflow arrows

View file

@ -190,6 +190,52 @@ procedural arrow. The indicator is persistent selection presentation. Retail
also has a separate, short-lived click confirmation owned by SmartBox; it is not also has a separate, short-lived click confirmation owned by SmartBox; it is not
implemented in `CPhysicsPart::Draw` or `ACCWeenieObject::SetSelected`. implemented in `CPhysicsPart::Draw` or `ACCWeenieObject::SetSelected`.
## Retained physical-item press, activation, and drag ordering
`UIElement_ListBox::MouseDown @ 0x0046E3A0`
```text
on left mouse down over a single-selection ListBox:
item = GetItemUnderMouse()
if item exists:
SetSelectedItem(item, notify=true)
if drag-selection is enabled:
StartDragSelect()
```
`UIElement_ItemList::ListenToElementMessage @ 0x004E4D50`
```text
on the physical-item left-button-down notice:
item = UIItem under the mouse
if a target mode is active:
HandleTargetedUseLeftClick(item)
consume the notice
else if item.itemID != 0:
if this list uses single selection:
update the list selection
SetSelectedObject(item.itemID)
on the physical-item double-click notice:
if item.itemID != 0:
UseObject(item.itemID)
```
`gmPaperDollUI::ListenToElementMessage @ 0x004A5C30` uses the same ordering
for a worn item: target mode first, otherwise set the selected object on the
left-button-down notice; right-click selects and examines.
Therefore selection is not a completed-click action. The green selection frame
appears while the left button is still held, before the three-pixel drag
threshold can promote the press into a drag. Opening a bag, using/equipping an
item, and shortcut activation remain completed click or double-click actions,
so dragging never also activates the source. A target-mode-consumed press
suppresses the rest of that physical-item click gesture.
Acdream implements that split once in `UiItemList`/`UiItemSlot`. Inventory,
external-container loot, paperdoll equipment, and physical toolbar shortcuts
only supply their selection source and release-time activation command.
## Right-click selection and examination ## Right-click selection and examination
`UIElement_SmartBoxWrapper::MouseUp @ 0x004E5820` `UIElement_SmartBoxWrapper::MouseUp @ 0x004E5820`

View file

@ -83,6 +83,9 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
ConfigureList(_topContainer, ContainerCellSize, horizontalScroll: false, containerEmptySprite); ConfigureList(_topContainer, ContainerCellSize, horizontalScroll: false, containerEmptySprite);
ConfigureList(_containerList, ContainerCellSize, horizontalScroll: true, containerEmptySprite); ConfigureList(_containerList, ContainerCellSize, horizontalScroll: true, containerEmptySprite);
ConfigureList(_contentsList, ItemCellSize, horizontalScroll: true, contentsEmptySprite); ConfigureList(_contentsList, ItemCellSize, horizontalScroll: true, contentsEmptySprite);
_topContainer.PrimaryItemPressed = PressItem;
_containerList.PrimaryItemPressed = PressItem;
_contentsList.PrimaryItemPressed = PressItem;
_topContainer.ExamineItemRequested = ExamineItem; _topContainer.ExamineItemRequested = ExamineItem;
_containerList.ExamineItemRequested = ExamineItem; _containerList.ExamineItemRequested = ExamineItem;
_contentsList.ExamineItemRequested = ExamineItem; _contentsList.ExamineItemRequested = ExamineItem;
@ -325,7 +328,6 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
private void AddRootCell(uint guid) private void AddRootCell(uint guid)
{ {
UiItemSlot cell = CreateCell(_topContainer, guid, ItemDragSource.Ground); UiItemSlot cell = CreateCell(_topContainer, guid, ItemDragSource.Ground);
cell.Clicked = () => HandlePrimaryClick(guid, () => Select(guid));
cell.DoubleClicked = RequestClose; cell.DoubleClicked = RequestClose;
cell.IsOpenContainer = _openContainer == guid; cell.IsOpenContainer = _openContainer == guid;
SetCapacity(cell, guid); SetCapacity(cell, guid);
@ -335,7 +337,7 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
private void AddContainerCell(uint guid) private void AddContainerCell(uint guid)
{ {
UiItemSlot cell = CreateCell(_containerList, guid, ItemDragSource.Ground); UiItemSlot cell = CreateCell(_containerList, guid, ItemDragSource.Ground);
cell.Clicked = () => HandlePrimaryClick(guid, () => OpenNestedContainer(guid)); cell.Clicked = () => OpenNestedContainer(guid);
SetCapacity(cell, guid); SetCapacity(cell, guid);
_containerList.AddItem(cell); _containerList.AddItem(cell);
} }
@ -343,7 +345,6 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
private void AddContentsCell(uint guid) private void AddContentsCell(uint guid)
{ {
UiItemSlot cell = CreateCell(_contentsList, guid, ItemDragSource.Ground); UiItemSlot cell = CreateCell(_contentsList, guid, ItemDragSource.Ground);
cell.Clicked = () => HandlePrimaryClick(guid, () => Select(guid));
cell.DoubleClicked = () => _itemInteraction.ActivateItem(guid); cell.DoubleClicked = () => _itemInteraction.ActivateItem(guid);
cell.DragAcceptSprite = 0x060011F9u; cell.DragAcceptSprite = 0x060011F9u;
cell.DragRejectSprite = 0x060011F8u; cell.DragRejectSprite = 0x060011F8u;
@ -384,11 +385,12 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
_itemInteraction.ExamineSelectedOrEnterMode(guid); _itemInteraction.ExamineSelectedOrEnterMode(guid);
} }
private void HandlePrimaryClick(uint guid, Action fallback) private bool PressItem(uint guid)
{ {
if (_itemInteraction.OfferPrimaryClick(guid) != ItemPrimaryClickResult.NotActive) if (_itemInteraction.OfferPrimaryClick(guid) != ItemPrimaryClickResult.NotActive)
return; return true;
fallback(); Select(guid);
return false;
} }
private void ApplyIndicators() private void ApplyIndicators()
@ -573,6 +575,9 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
_objects.Cleared -= OnObjectsCleared; _objects.Cleared -= OnObjectsCleared;
_selection.Changed -= OnSelectionChanged; _selection.Changed -= OnSelectionChanged;
_itemInteraction.StateChanged -= OnInteractionStateChanged; _itemInteraction.StateChanged -= OnInteractionStateChanged;
_topContainer.PrimaryItemPressed = null;
_containerList.PrimaryItemPressed = null;
_contentsList.PrimaryItemPressed = null;
_topContainer.ExamineItemRequested = null; _topContainer.ExamineItemRequested = null;
_containerList.ExamineItemRequested = null; _containerList.ExamineItemRequested = null;
_contentsList.ExamineItemRequested = null; _contentsList.ExamineItemRequested = null;

View file

@ -153,9 +153,21 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
_contentsGrid?.RegisterDragHandler(this); _contentsGrid?.RegisterDragHandler(this);
_containerList?.RegisterDragHandler(this); _containerList?.RegisterDragHandler(this);
_topContainer?.RegisterDragHandler(this); _topContainer?.RegisterDragHandler(this);
if (_contentsGrid is not null) _contentsGrid.ExamineItemRequested = ExamineItem; if (_contentsGrid is not null)
if (_containerList is not null) _containerList.ExamineItemRequested = ExamineItem; {
if (_topContainer is not null) _topContainer.ExamineItemRequested = ExamineItem; _contentsGrid.PrimaryItemPressed = PressItem;
_contentsGrid.ExamineItemRequested = ExamineItem;
}
if (_containerList is not null)
{
_containerList.PrimaryItemPressed = PressItem;
_containerList.ExamineItemRequested = ExamineItem;
}
if (_topContainer is not null)
{
_topContainer.PrimaryItemPressed = PressSelfItem;
_topContainer.ExamineItemRequested = ExamineItem;
}
// Burden meter: vertical 11×58 bar (gmBackpackUI m_burdenMeter, retail direction 4). // Burden meter: vertical 11×58 bar (gmBackpackUI m_burdenMeter, retail direction 4).
_burdenMeter = layout.FindElement(BurdenMeterId) as UiMeter; _burdenMeter = layout.FindElement(BurdenMeterId) as UiMeter;
@ -432,12 +444,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
dragIconTexture: _dragIconIds?.Invoke( dragIconTexture: _dragIconIds?.Invoke(
ItemType.Container, PlayerPackBaseIcon, 0u, 0u, 0u) ?? 0u); ItemType.Container, PlayerPackBaseIcon, 0u, 0u, 0u) ?? 0u);
main.DragAcceptSprite = 0x060011F7u; main.DragRejectSprite = 0x060011F8u; main.DragAcceptSprite = 0x060011F7u; main.DragRejectSprite = 0x060011F8u;
main.Clicked = () => main.Clicked = () => OpenContainer(p);
{
if (_itemInteraction?.OfferSelfPrimaryClick()
is not null and not ItemPrimaryClickResult.NotActive) return;
OpenContainer(p);
};
main.DoubleClicked = () => _itemInteraction?.ActivateItem(p); main.DoubleClicked = () => _itemInteraction?.ActivateItem(p);
SetCapacityBar(main, p); // main-pack fullness (items / ItemsCapacity) SetCapacityBar(main, p); // main-pack fullness (items / ItemsCapacity)
_topContainer.AddItem(main); _topContainer.AddItem(main);
@ -480,22 +487,28 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
cell.DoubleClicked = () => _itemInteraction?.ActivateItem(guid); cell.DoubleClicked = () => _itemInteraction?.ActivateItem(guid);
if (isContainer) if (isContainer)
{ {
cell.Clicked = () => HandlePrimaryClick(guid, () => OpenContainer(guid)); cell.Clicked = () => OpenContainer(guid);
SetCapacityBar(cell, guid); SetCapacityBar(cell, guid);
} }
else
{
cell.Clicked = () => HandlePrimaryClick(guid, () => SelectItem(guid));
}
list.AddItem(cell); list.AddItem(cell);
} }
private void HandlePrimaryClick(uint guid, Action fallback) private bool PressItem(uint guid)
{ {
if (_itemInteraction?.OfferPrimaryClick(guid) if (_itemInteraction?.OfferPrimaryClick(guid)
is not null and not ItemPrimaryClickResult.NotActive) is not null and not ItemPrimaryClickResult.NotActive)
return; return true;
fallback(); SelectItem(guid);
return false;
}
private bool PressSelfItem(uint guid)
{
if (_itemInteraction?.OfferSelfPrimaryClick()
is not null and not ItemPrimaryClickResult.NotActive)
return true;
SelectItem(guid);
return false;
} }
private void AddEmptyCell(UiItemList list) private void AddEmptyCell(UiItemList list)
@ -931,9 +944,21 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
_objects.ObjectUpdated -= OnObjectChanged; _objects.ObjectUpdated -= OnObjectChanged;
_objects.Cleared -= OnObjectsCleared; _objects.Cleared -= OnObjectsCleared;
_selection.Changed -= OnSelectionChanged; _selection.Changed -= OnSelectionChanged;
if (_contentsGrid is not null) _contentsGrid.ExamineItemRequested = null; if (_contentsGrid is not null)
if (_containerList is not null) _containerList.ExamineItemRequested = null; {
if (_topContainer is not null) _topContainer.ExamineItemRequested = null; _contentsGrid.PrimaryItemPressed = null;
_contentsGrid.ExamineItemRequested = null;
}
if (_containerList is not null)
{
_containerList.PrimaryItemPressed = null;
_containerList.ExamineItemRequested = null;
}
if (_topContainer is not null)
{
_topContainer.PrimaryItemPressed = null;
_topContainer.ExamineItemRequested = null;
}
if (_itemInteraction is not null) if (_itemInteraction is not null)
{ {
_itemInteraction.StateChanged -= OnInteractionStateChanged; _itemInteraction.StateChanged -= OnInteractionStateChanged;

View file

@ -84,6 +84,7 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
var (element, mask, _, unlockBit) = PaperdollSlotBackgrounds.Definitions[i]; var (element, mask, _, unlockBit) = PaperdollSlotBackgrounds.Definitions[i];
if (layout.FindElement(element) is not UiItemList list) continue; if (layout.FindElement(element) is not UiItemList list) continue;
list.RegisterDragHandler(this); list.RegisterDragHandler(this);
list.PrimaryItemPressed = PressItem;
list.ExamineItemRequested = ExamineItem; list.ExamineItemRequested = ExamineItem;
list.Cell.SourceKind = ItemDragSource.Equipment; list.Cell.SourceKind = ItemDragSource.Equipment;
list.Cell.SlotIndex = i; // definition position = equipped drag-payload SourceSlot list.Cell.SlotIndex = i; // definition position = equipped drag-payload SourceSlot
@ -91,15 +92,6 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
&& emptySlotSprites.TryGetValue(element, out uint authoredSprite) && emptySlotSprites.TryGetValue(element, out uint authoredSprite)
? authoredSprite ? authoredSprite
: emptySlotSprite; : emptySlotSprite;
list.Cell.Clicked = () =>
{
uint itemId = list.Cell.ItemId;
if (itemId != 0 && _itemInteraction?.OfferPrimaryClick(itemId)
is not null and not ItemPrimaryClickResult.NotActive)
return;
if (itemId != 0)
_selection.Select(itemId, SelectionChangeSource.Paperdoll);
};
list.Cell.DoubleClicked = () => list.Cell.DoubleClicked = () =>
{ {
if (list.Cell.ItemId != 0) if (list.Cell.ItemId != 0)
@ -357,6 +349,14 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
_itemInteraction.ExamineSelectedOrEnterMode(itemId); _itemInteraction.ExamineSelectedOrEnterMode(itemId);
} }
private bool PressItem(uint itemId)
{
if (_itemInteraction.OfferPrimaryClick(itemId) != ItemPrimaryClickResult.NotActive)
return true;
_selection.Select(itemId, SelectionChangeSource.Paperdoll);
return false;
}
/// <summary>Detach event handlers (idempotent).</summary> /// <summary>Detach event handlers (idempotent).</summary>
public void Dispose() public void Dispose()
{ {
@ -369,7 +369,10 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
_objects.Cleared -= OnObjectsCleared; _objects.Cleared -= OnObjectsCleared;
_selection.Changed -= OnSelectionChanged; _selection.Changed -= OnSelectionChanged;
foreach (var (_, list) in _slots) foreach (var (_, list) in _slots)
{
list.PrimaryItemPressed = null;
list.ExamineItemRequested = null; list.ExamineItemRequested = null;
}
if (_dollViewport is UiViewport doll) if (_dollViewport is UiViewport doll)
doll.ClickedAt = null; doll.ClickedAt = null;
switch (_dollDragMask) switch (_dollDragMask)

View file

@ -138,6 +138,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
if (_slots[i] is { } list) if (_slots[i] is { } list)
{ {
WireClick(list); WireClick(list);
list.PrimaryItemPressed = PressItem;
list.ExamineItemRequested = ExamineItem; list.ExamineItemRequested = ExamineItem;
// B.1 drag-drop spine: this controller is the drop handler for every // 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 // toolbar slot list; each cell knows its slot index + that it's a
@ -532,6 +533,19 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
}; };
} }
private bool PressItem(uint itemId)
{
if (_itemInteraction?.OfferPrimaryClick(itemId)
is not null and not ItemPrimaryClickResult.NotActive)
return true;
if (_selectItem is not null)
_selectItem(itemId);
else
_selection?.Select(itemId, SelectionChangeSource.Toolbar);
return false;
}
private void ExamineItem(uint itemId) private void ExamineItem(uint itemId)
{ {
if (_selectItem is not null) if (_selectItem is not null)
@ -777,7 +791,10 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
_selection.Changed -= OnSelectionChanged; _selection.Changed -= OnSelectionChanged;
foreach (UiItemList? list in _slots) foreach (UiItemList? list in _slots)
if (list is not null) if (list is not null)
{
list.PrimaryItemPressed = null;
list.ExamineItemRequested = null; list.ExamineItemRequested = null;
}
_repo.ObjectAdded -= OnRepositoryObjectChanged; _repo.ObjectAdded -= OnRepositoryObjectChanged;
_repo.ObjectUpdated -= OnRepositoryObjectChanged; _repo.ObjectUpdated -= OnRepositoryObjectChanged;
_repo.ObjectRemoved -= OnRepositoryObjectChanged; _repo.ObjectRemoved -= OnRepositoryObjectChanged;

View file

@ -73,6 +73,20 @@ public sealed class UiItemList : UiElement
/// </summary> /// </summary>
public Action<object, int, int>? CatalogDropped { get; set; } public Action<object, int, int>? CatalogDropped { get; set; }
/// <summary>
/// Physical-item left-button-down command supplied by the owning panel.
/// Return <see langword="true"/> when a target mode consumed the press so
/// the slot suppresses drag promotion and the completed Click/DoubleClick
/// gesture.
///
/// Retail references:
/// <c>UIElement_ListBox::MouseDown @ 0x0046E3A0</c> selects the item under
/// the pointer during MouseDown, before optional drag selection;
/// <c>UIElement_ItemList::ListenToElementMessage @ 0x004E4D50</c> handles
/// target mode before ordinary physical-item selection.
/// </summary>
public Func<uint, bool>? PrimaryItemPressed { get; set; }
/// <summary> /// <summary>
/// Appraisal command supplied by the owning panel controller for physical /// Appraisal command supplied by the owning panel controller for physical
/// item cells. Retail <c>UIElement_ItemList::ListenToElementMessage /// item cells. Retail <c>UIElement_ItemList::ListenToElementMessage

View file

@ -99,6 +99,7 @@ public class UiItemSlot : UiElement
private bool _waiting; private bool _waiting;
internal bool WaitingVisual => _waiting; internal bool WaitingVisual => _waiting;
private bool _primaryPressConsumed;
/// <summary>RenderSurface id -> (GL texture, w, h). Set by the factory/controller.</summary> /// <summary>RenderSurface id -> (GL texture, w, h). Set by the factory/controller.</summary>
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; } public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
@ -134,11 +135,14 @@ public class UiItemSlot : UiElement
DragIconTexture = 0; DragIconTexture = 0;
Shortcut = null; Shortcut = null;
_waiting = false; _waiting = false;
_primaryPressConsumed = false;
} }
/// <inheritdoc/> /// <inheritdoc/>
public override object? GetDragPayload() public override object? GetDragPayload()
=> ItemId != 0 ? new ItemDragPayload(ItemId, SourceKind, SlotIndex, this, Shortcut) : null; => ItemId != 0 && !_primaryPressConsumed
? new ItemDragPayload(ItemId, SourceKind, SlotIndex, this, Shortcut)
: null;
/// <inheritdoc/> /// <inheritdoc/>
public override (uint tex, int w, int h)? GetDragGhost() public override (uint tex, int w, int h)? GetDragGhost()
@ -251,8 +255,10 @@ public class UiItemSlot : UiElement
// ── Events / draw ───────────────────────────────────────────────────────── // ── Events / draw ─────────────────────────────────────────────────────────
/// <summary>Invoked by <see cref="OnEvent"/> when a left-button-down lands on /// <summary>Invoked by <see cref="OnEvent"/> when a completed left click lands
/// a bound slot. Wired by <c>ToolbarController</c> to the use-item callback.</summary> /// on a bound slot. Selection already occurred on MouseDown through the owning
/// <see cref="UiItemList"/>; this callback performs release-time activation
/// such as opening a bag or using a shortcut.</summary>
public Action? Clicked { get; set; } public Action? Clicked { get; set; }
/// <summary>Invoked by <see cref="OnEvent"/> when a double-click lands on a bound slot.</summary> /// <summary>Invoked by <see cref="OnEvent"/> when a double-click lands on a bound slot.</summary>
@ -267,11 +273,20 @@ public class UiItemSlot : UiElement
// drag (press + >3px move) does NOT also use the item. UiRoot suppresses the // drag (press + >3px move) does NOT also use the item. UiRoot suppresses the
// post-drag Click (UiRoot.cs FinishDrag returns before the Click emit). // post-drag Click (UiRoot.cs FinishDrag returns before the Click emit).
case UiEventType.MouseDown: case UiEventType.MouseDown:
return true; // consume the press; no use here // UIElement_ListBox::MouseDown @ 0x0046E3A0 selects immediately,
// before StartDragSelect. UIElement_ItemList's notice handler
// intercepts target mode first. Activation remains release-time,
// so a press promoted to drag never also uses the item.
_primaryPressConsumed = ItemId != 0
&& FindList() is { PrimaryItemPressed: { } pressed }
&& pressed(ItemId);
return true;
case UiEventType.Click: case UiEventType.Click:
if (!_primaryPressConsumed)
Clicked?.Invoke(); Clicked?.Invoke();
return true; return true;
case UiEventType.DoubleClick: case UiEventType.DoubleClick:
if (!_primaryPressConsumed)
DoubleClicked?.Invoke(); DoubleClicked?.Invoke();
return true; return true;
case UiEventType.RightClick: case UiEventType.RightClick:

View file

@ -275,6 +275,61 @@ public class DragDropSpineTests
Assert.True(used); Assert.True(used);
} }
[Fact]
public void PhysicalItemPress_selectsBeforeRelease_thenClickActivates()
{
var (root, list, cell) = RootWithBoundSlot(0x5001u);
var selected = new List<uint>();
bool used = false;
list.PrimaryItemPressed = item =>
{
selected.Add(item);
return false;
};
cell.Clicked = () => used = true;
root.OnMouseDown(UiMouseButton.Left, 10, 10);
Assert.Equal(new uint[] { 0x5001u }, selected);
Assert.False(used);
root.OnMouseUp(UiMouseButton.Left, 10, 10);
Assert.True(used);
}
[Fact]
public void ConsumedPhysicalItemPress_suppressesCompletedActivation()
{
var (root, list, cell) = RootWithBoundSlot(0x5001u);
bool used = false;
bool doubleUsed = false;
list.PrimaryItemPressed = _ => true;
cell.Clicked = () => used = true;
cell.DoubleClicked = () => doubleUsed = true;
root.OnMouseDown(UiMouseButton.Left, 10, 10);
root.OnMouseUp(UiMouseButton.Left, 10, 10);
root.OnMouseDown(UiMouseButton.Left, 10, 10);
root.OnMouseUp(UiMouseButton.Left, 10, 10);
Assert.False(used);
Assert.False(doubleUsed);
}
[Fact]
public void ConsumedPhysicalItemPress_cannotPromoteIntoDrag()
{
var (root, list, _) = RootWithBoundSlot(0x5001u);
list.PrimaryItemPressed = _ => true;
root.OnMouseDown(UiMouseButton.Left, 10, 10);
root.OnMouseMove(20, 10);
Assert.Null(root.DragSource);
Assert.Null(root.DragPayload);
}
[Fact] [Fact]
public void RightClick_withoutDrag_requestsItemListAppraisal() public void RightClick_withoutDrag_requestsItemListAppraisal()
{ {

View file

@ -199,6 +199,20 @@ public sealed class ExternalContainerControllerTests
Assert.Empty(h.Pickups); Assert.Empty(h.Pickups);
} }
[Fact]
public void MouseDownLoot_selectsBeforeCompletedClick()
{
using var h = new Harness();
h.Open(Chest, new ContainerContentEntry(Item, 0u));
UiItemSlot cell = h.Contents.GetItem(0)!;
cell.OnEvent(new UiEvent(0u, cell, UiEventType.MouseDown));
Assert.Equal(Item, h.Selection.SelectedObjectId);
Assert.True(cell.Selected);
Assert.Empty(h.Pickups);
}
[Fact] [Fact]
public void CloseAndRangeExit_SendUseOnceWithoutNoLongerViewing() public void CloseAndRangeExit_SendUseOnceWithoutNoLongerViewing()
{ {

View file

@ -460,7 +460,7 @@ public class InventoryControllerTests
} }
[Fact] [Fact]
public void ClickGridItem_movesSquareOnly_noWire_keepsOpenContainer() public void MouseDownGridItem_movesSquareImmediately_noWire_keepsOpenContainer()
{ {
var (layout, grid, _, top, _, _, _, _) = BuildLayout(); var (layout, grid, _, top, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable(); var objects = new ClientObjectTable();
@ -468,7 +468,8 @@ public class InventoryControllerTests
var uses = new List<uint>(); var uses = new List<uint>();
Bind(layout, objects, uses: uses); Bind(layout, objects, uses: uses);
grid.GetItem(0)!.Clicked!(); // select the loose item UiItemSlot item = grid.GetItem(0)!;
item.OnEvent(new UiEvent(0u, item, UiEventType.MouseDown));
Assert.True(grid.GetItem(0)!.Selected); // square on the selected grid item Assert.True(grid.GetItem(0)!.Selected); // square on the selected grid item
Assert.True(top.GetItem(0)!.IsOpenContainer); // main pack still the open container (unchanged) Assert.True(top.GetItem(0)!.IsOpenContainer); // main pack still the open container (unchanged)
@ -476,7 +477,7 @@ public class InventoryControllerTests
} }
[Fact] [Fact]
public void ClickGridItem_updatesSharedSelection_andExternalSelectionMovesSquare() public void MouseDownGridItem_updatesSharedSelection_andExternalSelectionMovesSquare()
{ {
var (layout, grid, _, _, _, _, _, _) = BuildLayout(); var (layout, grid, _, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable(); var objects = new ClientObjectTable();
@ -485,7 +486,8 @@ public class InventoryControllerTests
var selection = new SelectionState(); var selection = new SelectionState();
Bind(layout, objects, selection: selection); Bind(layout, objects, selection: selection);
grid.GetItem(0)!.Clicked!(); UiItemSlot item = grid.GetItem(0)!;
item.OnEvent(new UiEvent(0u, item, UiEventType.MouseDown));
Assert.Equal(0xAu, selection.SelectedObjectId); Assert.Equal(0xAu, selection.SelectedObjectId);
Assert.True(grid.GetItem(0)!.Selected); Assert.True(grid.GetItem(0)!.Selected);
@ -555,7 +557,8 @@ public class InventoryControllerTests
selection: new SelectionState(), selection: new SelectionState(),
datFont: null, datFont: null,
itemInteraction: interaction); itemInteraction: interaction);
grid.GetItem(0)!.Clicked!(); UiItemSlot item = grid.GetItem(0)!;
item.OnEvent(new UiEvent(0u, item, UiEventType.MouseDown));
Assert.True(grid.GetItem(0)!.Selected); Assert.True(grid.GetItem(0)!.Selected);
Assert.True(interaction.ActivateItem(0xAu)); Assert.True(interaction.ActivateItem(0xAu));

View file

@ -176,7 +176,7 @@ public class PaperdollControllerTests
} }
[Fact] [Fact]
public void ClickEquippedItem_updatesSharedSelection() public void MouseDownEquippedItem_updatesSharedSelection()
{ {
var (layout, lists) = BuildLayout(); var (layout, lists) = BuildLayout();
var objects = new ClientObjectTable(); var objects = new ClientObjectTable();
@ -184,7 +184,8 @@ public class PaperdollControllerTests
var selection = new SelectionState(); var selection = new SelectionState();
Bind(layout, objects, selection: selection); Bind(layout, objects, selection: selection);
lists[HeadSlot].Cell.Clicked!(); UiItemSlot cell = lists[HeadSlot].Cell;
cell.OnEvent(new UiEvent(0u, cell, UiEventType.MouseDown));
Assert.Equal(0xA01u, selection.SelectedObjectId); Assert.Equal(0xA01u, selection.SelectedObjectId);
Assert.True(lists[HeadSlot].Cell.Selected); Assert.True(lists[HeadSlot].Cell.Selected);

View file

@ -192,11 +192,19 @@ public class ToolbarControllerTests
var shortcuts = new List<ShortcutEntry> var shortcuts = new List<ShortcutEntry>
{ new(Index: 0, ObjectId: 0x5001u, SpellId: 0) }; { new(Index: 0, ObjectId: 0x5001u, SpellId: 0) };
uint used = 0; uint used = 0;
var selection = new SelectionState();
ToolbarController.Bind(layout, repo, () => shortcuts, ToolbarController.Bind(layout, repo, () => shortcuts,
iconIds: (_,_,_,_,_) => 0x77u, useItem: g => used = g); iconIds: (_,_,_,_,_) => 0x77u,
useItem: g => used = g,
selection: selection);
UiItemSlot cell = slots[Row1[0]].Cell;
cell.OnEvent(new UiEvent(0u, cell, UiEventType.MouseDown));
Assert.Equal(0x5001u, selection.SelectedObjectId);
Assert.Equal(0u, used);
// Use now fires on Click (mouse-up), not MouseDown — drag/click disambiguation. // Use now fires on Click (mouse-up), not MouseDown — drag/click disambiguation.
slots[Row1[0]].Cell.OnEvent(new UiEvent(0u, null, UiEventType.Click)); cell.OnEvent(new UiEvent(0u, cell, UiEventType.Click));
Assert.Equal(0x5001u, used); Assert.Equal(0x5001u, used);
} }