diff --git a/AetherBags/AddonLifecycles/InventoryLifecycles.cs b/AetherBags/AddonLifecycles/InventoryLifecycles.cs
index 33c2922..4e35a9b 100644
--- a/AetherBags/AddonLifecycles/InventoryLifecycles.cs
+++ b/AetherBags/AddonLifecycles/InventoryLifecycles.cs
@@ -14,6 +14,14 @@ using ValueType = FFXIVClientStructs.FFXIV.Component.GUI.ValueType;
namespace AetherBags.AddonLifecycles;
+public static unsafe class DragDropState
+{
+ ///
+ /// Returns true if the game's drag-drop manager is currently dragging.
+ ///
+ public static bool IsDragging => AtkStage.Instance()->DragDropManager.IsDragging;
+}
+
public class InventoryLifecycles : IDisposable
{
@@ -30,12 +38,12 @@ public class InventoryLifecycles : IDisposable
Services.AddonLifecycle.RegisterListener(AddonEvent.PreFinalize, retainer, OnPreFinalize);
// PreRefresh Handlers
- Services.AddonLifecycle.RegisterListener(AddonEvent.PreRefresh, ["Inventory", "InventoryLarge", "InventoryExpansion"], InventoryPreRefreshHandler);
+ Services.AddonLifecycle.RegisterListener(AddonEvent.PreRefresh, bags, InventoryPreRefreshHandler);
// PostRequestedUpdate
Services.AddonLifecycle.RegisterListener(AddonEvent.PostRequestedUpdate, "Inventory", OnInventoryUpdate);
Services.AddonLifecycle.RegisterListener(AddonEvent.PostRequestedUpdate, "InventoryBuddy", OnSaddleBagUpdate);
- Services.AddonLifecycle.RegisterListener(AddonEvent.PostRequestedUpdate, ["InventoryRetainer", "InventoryRetainerLarge"], OnRetainerInventoryUpdate);
+ Services.AddonLifecycle.RegisterListener(AddonEvent.PostRequestedUpdate, retainer, OnRetainerInventoryUpdate);
// PreShow
Services.AddonLifecycle.RegisterListener(AddonEvent.PreOpen, "InventoryBuddy", OnSaddleBagOpen);
@@ -181,6 +189,10 @@ public class InventoryLifecycles : IDisposable
if (IsInUnsafeState())
return;
+ if (DragDropState.IsDragging)
+ return;
+
+ System.LootedItemsTracker.FlushPendingChanges();
System.AddonInventoryWindow?.RefreshFromLifecycle();
}
@@ -189,6 +201,9 @@ public class InventoryLifecycles : IDisposable
if (IsInUnsafeState())
return;
+ if (DragDropState.IsDragging)
+ return;
+
System.AddonSaddleBagWindow?.RefreshFromLifecycle();
}
@@ -197,6 +212,9 @@ public class InventoryLifecycles : IDisposable
if (IsInUnsafeState())
return;
+ if (DragDropState.IsDragging)
+ return;
+
System.AddonRetainerWindow?.RefreshFromLifecycle();
}
diff --git a/AetherBags/Addons/AddonInventoryWindow.cs b/AetherBags/Addons/AddonInventoryWindow.cs
index 2c1f5de..5053139 100644
--- a/AetherBags/Addons/AddonInventoryWindow.cs
+++ b/AetherBags/Addons/AddonInventoryWindow.cs
@@ -101,12 +101,7 @@ public unsafe class AddonInventoryWindow : InventoryAddonBase
private void OnLootedItemsChanged(IReadOnlyList lootedItems)
{
if (!IsOpen || !IsSetupComplete) return;
-
- Services.Framework.RunOnTick(() =>
- {
- if (!IsOpen) return;
- UpdateLootedCategory(lootedItems);
- }, delayTicks: 1);
+ UpdateLootedCategory(lootedItems);
}
private void UpdateLootedCategory(IReadOnlyList lootedItems)
@@ -119,6 +114,7 @@ public unsafe class AddonInventoryWindow : InventoryAddonBase
{
CategoriesNode.SetHoistedNode(_lootedCategoryNode);
}
+ AutoSizeWindow();
}
else
{
@@ -131,6 +127,7 @@ public unsafe class AddonInventoryWindow : InventoryAddonBase
CategoriesNode.RemoveNode(_lootedCategoryNode);
}
+ AutoSizeWindow();
}
}
@@ -158,6 +155,7 @@ public unsafe class AddonInventoryWindow : InventoryAddonBase
}, delayTicks: 3);
}
+
protected override void OnFinalize(AtkUnitBase* addon)
{
System.LootedItemsTracker.OnLootedItemsChanged -= OnLootedItemsChanged;
diff --git a/AetherBags/Addons/InventoryAddonBase.cs b/AetherBags/Addons/InventoryAddonBase.cs
index 3010e67..d07c8d6 100644
--- a/AetherBags/Addons/InventoryAddonBase.cs
+++ b/AetherBags/Addons/InventoryAddonBase.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Numerics;
+using AetherBags.AddonLifecycles;
using AetherBags.Configuration;
using AetherBags.Helpers;
using AetherBags.Inventory;
@@ -169,11 +170,10 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow
CategoriesNode.SyncWithListDataByKey(
dataList: categories,
getKeyFromData: categorizedInventory => categorizedInventory.Key,
- getKeyFromNode: node => node.Key,
+ getKeyFromNode: node => node.CategorizedInventory.Key,
updateNode: (node, data) =>
{
- node.CategorizedInventory = data;
- node.ItemsPerLine = Math.Min(data.Items.Count, maxItemsPerLine);
+ node.SetCategoryData(data, Math.Min(data.Items.Count, maxItemsPerLine));
node.RefreshNodeVisuals();
},
createNodeMethod: _ => CreateCategoryNode());
@@ -409,6 +409,9 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow
{
base.OnRequestedUpdate(addon, numberArrayData, stringArrayData);
+ if (DragDropState.IsDragging)
+ return;
+
InventoryState.RefreshFromGame();
RefreshCategoriesCore(autosize: true);
}
diff --git a/AetherBags/Inventory/LootedItemsTracker.cs b/AetherBags/Inventory/LootedItemsTracker.cs
index 08fe6c4..2aeeec4 100644
--- a/AetherBags/Inventory/LootedItemsTracker.cs
+++ b/AetherBags/Inventory/LootedItemsTracker.cs
@@ -3,8 +3,11 @@ using System.Collections.Generic;
using System.Linq;
using AetherBags.Inventory.Items;
using AetherBags.Inventory.Scanning;
+using Dalamud.Game.Inventory;
using Dalamud.Game.Inventory.InventoryEventArgTypes;
+using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.Game;
+using Lumina.Excel.Sheets;
namespace AetherBags.Inventory;
@@ -12,20 +15,32 @@ public sealed unsafe class LootedItemsTracker : IDisposable
{
private static IReadOnlyList StandardInventories => InventoryScanner.StandardInventories;
+ private const int BatchDelayMs = 300;
+
private readonly List _lootedItems = new(capacity: 64);
+ private readonly Dictionary<(uint ItemId, bool IsHq), (InventoryItem Item, int Quantity)> _pendingChanges = new(capacity: 32);
+
private bool _isEnabled;
+ private long _batchStartTick;
+ private bool _hasPendingRemoval;
public event Action>? OnLootedItemsChanged;
public IReadOnlyList LootedItems => _lootedItems;
+ public bool HasPendingChanges => _pendingChanges.Count > 0 || _hasPendingRemoval;
+
public void Enable()
{
if (_isEnabled) return;
_isEnabled = true;
_lootedItems.Clear();
+ _pendingChanges.Clear();
+ _batchStartTick = 0;
+ _hasPendingRemoval = false;
Services.GameInventory.InventoryChangedRaw += OnInventoryChangedRaw;
+ Services.Framework.Update += OnFrameworkUpdate;
}
public void Disable()
@@ -34,13 +49,17 @@ public sealed unsafe class LootedItemsTracker : IDisposable
_isEnabled = false;
Services.GameInventory.InventoryChangedRaw -= OnInventoryChangedRaw;
+ Services.Framework.Update -= OnFrameworkUpdate;
_lootedItems.Clear();
+ _pendingChanges.Clear();
+ _batchStartTick = 0;
+ _hasPendingRemoval = false;
}
public void Clear()
{
_lootedItems.Clear();
- OnLootedItemsChanged?.Invoke(_lootedItems);
+ _hasPendingRemoval = true;
}
public void RemoveByIndex(int index)
@@ -50,12 +69,20 @@ public sealed unsafe class LootedItemsTracker : IDisposable
if (_lootedItems[i].Index == index)
{
_lootedItems.RemoveAt(i);
- OnLootedItemsChanged?.Invoke(_lootedItems);
+ _hasPendingRemoval = true;
return;
}
}
}
+ public void FlushPendingChanges()
+ {
+ if (_pendingChanges.Count == 0 && !_hasPendingRemoval) return;
+
+ _hasPendingRemoval = false;
+ OnLootedItemsChanged?.Invoke(_lootedItems);
+ }
+
public void Dispose()
{
Disable();
@@ -66,13 +93,18 @@ public sealed unsafe class LootedItemsTracker : IDisposable
if (!_isEnabled) return;
if (!Services.ClientState.IsLoggedIn) return;
- bool hasChanges = false;
+ bool anyAdded = false;
foreach (var eventData in events)
{
- if (!StandardInventories.Contains((InventoryType)eventData.Item.ContainerType)) continue;
+ if (!StandardInventories.Contains((InventoryType)eventData.Item.ContainerType))
+ continue;
- if (eventData is not (InventoryItemAddedArgs or InventoryItemChangedArgs)) continue;
+ if (eventData.Item.ContainerType == GameInventoryType.DamagedGear)
+ continue;
+
+ if (eventData is not (InventoryItemAddedArgs or InventoryItemChangedArgs))
+ continue;
if (eventData is InventoryItemChangedArgs changedArgs &&
changedArgs.OldItemState.Quantity >= changedArgs.Item.Quantity)
@@ -80,22 +112,71 @@ public sealed unsafe class LootedItemsTracker : IDisposable
continue;
}
- var inventoryItem = (InventoryItem*)eventData.Item.Address;
+ if (ShouldFilterItem(eventData.Item.ItemId))
+ continue;
+
+ var inventoryItem = *(InventoryItem*)eventData.Item.Address;
var changeAmount = eventData is InventoryItemChangedArgs changed
? changed.Item.Quantity - changed.OldItemState.Quantity
: eventData.Item.Quantity;
- _lootedItems.Add(new LootedItemInfo(
- _lootedItems.Count,
- *inventoryItem,
- changeAmount));
+ var key = (inventoryItem.ItemId, IsHq: inventoryItem.Flags.HasFlag(InventoryItem.ItemFlags.HighQuality));
- hasChanges = true;
+ if (_pendingChanges.TryGetValue(key, out var existing))
+ {
+ _pendingChanges[key] = (inventoryItem, existing.Quantity + changeAmount);
+ }
+ else
+ {
+ _pendingChanges[key] = (inventoryItem, changeAmount);
+ }
+
+ anyAdded = true;
}
- if (hasChanges)
+ if (anyAdded && _batchStartTick == 0)
{
- OnLootedItemsChanged?.Invoke(_lootedItems);
+ _batchStartTick = Environment.TickCount64;
}
}
+
+ private void OnFrameworkUpdate(IFramework framework)
+ {
+ if (_batchStartTick == 0)
+ return;
+
+ if (Environment.TickCount64 < _batchStartTick + BatchDelayMs)
+ return;
+
+ _batchStartTick = 0;
+
+ if (_pendingChanges.Count == 0)
+ return;
+
+ foreach (var ((itemId, isHq), (item, quantity)) in _pendingChanges)
+ {
+ if (quantity <= 0)
+ continue;
+
+ _lootedItems.Add(new LootedItemInfo(
+ _lootedItems.Count,
+ item,
+ quantity));
+ }
+
+ _pendingChanges.Clear();
+
+ OnLootedItemsChanged?.Invoke(_lootedItems);
+ }
+
+ private static bool ShouldFilterItem(uint itemId)
+ {
+ if (!Services.DataManager.GetExcelSheet- ().TryGetRow(itemId, out var item))
+ return false;
+
+ if (item.ItemUICategory.RowId == 62)
+ return true;
+
+ return false;
+ }
}
diff --git a/AetherBags/Nodes/Inventory/InventoryCategoryNode.cs b/AetherBags/Nodes/Inventory/InventoryCategoryNode.cs
index 09e2501..afff5fd 100644
--- a/AetherBags/Nodes/Inventory/InventoryCategoryNode.cs
+++ b/AetherBags/Nodes/Inventory/InventoryCategoryNode.cs
@@ -1,5 +1,8 @@
using System;
+using System.Collections.Generic;
using System.Numerics;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
using AetherBags.Helpers;
using AetherBags.Inventory;
using AetherBags.Inventory.Categories;
@@ -33,6 +36,11 @@ public class InventoryCategoryNode : InventoryCategoryNodeBase
private float _baseHeaderWidth = 96f;
private string _fullHeaderText = string.Empty;
+ private uint _lastCategoryKey;
+ private int _lastItemCount;
+ private ulong _lastItemsHash;
+ private int _lastItemsPerLine;
+
public event Action? HeaderHoverChanged;
public Action? OnRefreshRequested { get; set; }
public Action? OnDragEnd { get; set; }
@@ -68,26 +76,68 @@ public class InventoryCategoryNode : InventoryCategoryNodeBase
_itemGridNode.AttachNode(this);
}
+ private CategorizedInventory _categorizedInventory;
+
public CategorizedInventory CategorizedInventory
{
- get;
- set
+ get => _categorizedInventory;
+ set => SetCategoryData(value, _itemGridNode.ItemsPerLine);
+ }
+
+ public void SetCategoryData(CategorizedInventory data, int itemsPerLine)
+ {
+ bool categoryChanged = data.Key != _lastCategoryKey;
+ bool itemsPerLineChanged = itemsPerLine != _lastItemsPerLine;
+
+ ulong itemsHash = ComputeItemsHash(CollectionsMarshal.AsSpan(data.Items));
+ bool itemsChanged = data.Items.Count != _lastItemCount || itemsHash != _lastItemsHash;
+
+ _lastCategoryKey = data.Key;
+ _lastItemCount = data.Items.Count;
+ _lastItemsHash = itemsHash;
+ _lastItemsPerLine = itemsPerLine;
+
+ _categorizedInventory = data;
+
+ _fullHeaderText = System.Config.General.ShowCategoryItemCount
+ ? $"{data.Category.Name} ({data.Items.Count})"
+ : data.Category.Name;
+
+ _categoryNameTextNode.String = _fullHeaderText;
+ _categoryNameTextNode.TextColor = data.Category.Color;
+ _categoryNameTextNode.TextTooltip = data.Category.Description;
+
+ if (itemsChanged || categoryChanged)
{
- field = value;
+ using (_itemGridNode.DeferRecalculateLayout())
+ {
+ _itemGridNode.ItemsPerLine = itemsPerLine;
+ UpdateItemGrid();
+ }
+ }
+ else if (itemsPerLineChanged)
+ {
+ _itemGridNode.ItemsPerLine = itemsPerLine;
+ }
- _fullHeaderText = System.Config.General.ShowCategoryItemCount
- ? $"{value.Category.Name} ({value.Items.Count})"
- : value.Category.Name;
-
- _categoryNameTextNode.String = _fullHeaderText;
- _categoryNameTextNode.TextColor = value.Category.Color;
- _categoryNameTextNode.TextTooltip = value.Category.Description;
-
- UpdateItemGrid();
+ if (categoryChanged || itemsChanged || itemsPerLineChanged)
+ {
RecalculateSize();
}
}
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static ulong ComputeItemsHash(ReadOnlySpan items)
+ {
+ ulong hash = 14695981039346656037UL; // FNV-1a offset basis
+ foreach (var item in items)
+ {
+ hash ^= item.Key;
+ hash *= 1099511628211UL; // FNV-1a prime
+ }
+ return hash;
+ }
+
public int ItemsPerLine
{
get => _itemGridNode.ItemsPerLine;
@@ -212,10 +262,43 @@ public class InventoryCategoryNode : InventoryCategoryNodeBase
private void UpdateItemGrid()
{
- _itemGridNode.SyncWithListData(
- CategorizedInventory.Items,
- node => node.ItemInfo,
- CreateInventoryDragDropNode);
+ _itemGridNode.SyncWithListDataByKey(
+ dataList: CategorizedInventory.Items,
+ getKeyFromData: item => item.Key,
+ getKeyFromNode: node => node.ItemInfo?.Key ?? 0,
+ updateNode: UpdateInventoryDragDropNode,
+ createNodeMethod: CreateInventoryDragDropNode);
+ }
+
+ private void UpdateInventoryDragDropNode(InventoryDragDropNode node, ItemInfo data)
+ {
+ if (node.ItemInfo?.Key == data.Key)
+ {
+ node.ItemInfo = data;
+ node.Alpha = data.VisualAlpha;
+ node.AddColor = data.HighlightOverlayColor;
+ node.IsDraggable = !data.IsSlotBlocked;
+ return;
+ }
+
+ InventoryItem item = data.Item;
+ InventoryMappedLocation visualLocation = data.VisualLocation;
+
+ var visualInvType = InventoryType.GetInventoryTypeFromContainerId(visualLocation.Container);
+ int absoluteIndex = visualInvType.GetInventoryStartIndex + visualLocation.Slot;
+
+ node.ItemInfo = data;
+ node.IconId = item.IconId;
+ node.Alpha = data.VisualAlpha;
+ node.AddColor = data.HighlightOverlayColor;
+ node.IsDraggable = !data.IsSlotBlocked;
+ node.Payload = new DragDropPayload
+ {
+ Type = DragDropType.Item,
+ Int1 = visualLocation.Container,
+ Int2 = visualLocation.Slot,
+ ReferenceIndex = (short)absoluteIndex
+ };
}
private unsafe InventoryDragDropNode CreateInventoryDragDropNode(ItemInfo data)
@@ -268,16 +351,31 @@ public class InventoryCategoryNode : InventoryCategoryNodeBase
public void RefreshNodeVisuals()
{
- foreach (var node in _itemGridNode.Nodes)
+ var nodes = _itemGridNode.Nodes;
+ for (int i = 0; i < nodes.Count; i++)
{
- if (node is not InventoryDragDropNode itemNode || itemNode.ItemInfo == null) continue;
+ if (nodes[i] is not InventoryDragDropNode itemNode || itemNode.ItemInfo == null)
+ continue;
- itemNode.Alpha = itemNode.ItemInfo.VisualAlpha;
- itemNode.AddColor = itemNode.ItemInfo.HighlightOverlayColor;
- itemNode.IsDraggable = !itemNode.ItemInfo.IsSlotBlocked;
+ var info = itemNode.ItemInfo;
+ float newAlpha = info.VisualAlpha;
+ Vector3 newColor = info.HighlightOverlayColor;
+ bool newDraggable = !info.IsSlotBlocked;
+
+ if (!NearlyEqual(itemNode.Alpha, newAlpha))
+ itemNode.Alpha = newAlpha;
+
+ if (itemNode.AddColor != newColor)
+ itemNode.AddColor = newColor;
+
+ if (itemNode.IsDraggable != newDraggable)
+ itemNode.IsDraggable = newDraggable;
}
}
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static bool NearlyEqual(float a, float b) => MathF.Abs(a - b) < 0.001f;
+
private unsafe void OnDiscard(DragDropNode node, ItemInfo item)
{
uint addonId = RaptureAtkUnitManager.Instance()->GetAddonByNode(node)->Id;
@@ -325,4 +423,4 @@ public class InventoryCategoryNode : InventoryCategoryNodeBase
Services.Logger.Error(ex, "[OnPayload] Error handling payload acceptance");
}
}
-}
\ No newline at end of file
+}
diff --git a/AetherBags/Nodes/Inventory/LootedItemDisplayNode.cs b/AetherBags/Nodes/Inventory/LootedItemDisplayNode.cs
index b9a7a80..04f1834 100644
--- a/AetherBags/Nodes/Inventory/LootedItemDisplayNode.cs
+++ b/AetherBags/Nodes/Inventory/LootedItemDisplayNode.cs
@@ -1,7 +1,7 @@
using System;
-using System.Numerics;
using AetherBags.Inventory.Items;
using FFXIVClientStructs.FFXIV.Client.UI;
+using FFXIVClientStructs.FFXIV.Common.Math;
using FFXIVClientStructs.FFXIV.Component.GUI;
using KamiToolKit.Classes;
using KamiToolKit.Nodes;
@@ -11,11 +11,10 @@ namespace AetherBags.Nodes.Inventory;
///
/// A display-only item node for looted items. Not draggable, but shows tooltip and can be dismissed.
///
-public unsafe class LootedItemDisplayNode : SimpleComponentNode
+public sealed unsafe class LootedItemDisplayNode : SimpleComponentNode
{
private readonly IconNode _iconNode;
private readonly TextNode _quantityTextNode;
- private readonly ResNode _collisionNode;
public Action? OnDismiss { get; set; }
@@ -27,54 +26,42 @@ public unsafe class LootedItemDisplayNode : SimpleComponentNode
{
Position = new Vector2(0, 0),
Size = new Vector2(42, 46),
- NodeFlags = NodeFlags.Visible | NodeFlags.Enabled,
};
+ _iconNode.AddEvent(AtkEventType.MouseClick, OnMouseClick);
_iconNode.AttachNode(this);
_quantityTextNode = new TextNode
{
Size = new Vector2(40.0f, 12.0f),
Position = new Vector2(4.0f, 34.0f),
- NodeFlags = NodeFlags.Enabled | NodeFlags.EmitsEvents,
Color = ColorHelper.GetColor(50),
TextOutlineColor = ColorHelper.GetColor(51),
TextFlags = TextFlags.Edge,
AlignmentType = AlignmentType.Right,
};
_quantityTextNode.AttachNode(this);
+ }
- _collisionNode = new ResNode
+ public LootedItemInfo LootedItem
+ {
+ get;
+ set
{
- Size = new Vector2(42, 46),
- NodeFlags = NodeFlags.Enabled | NodeFlags.EmitsEvents | NodeFlags.HasCollision,
- };
- _collisionNode.AddEvent(AtkEventType.MouseOver, OnMouseOver);
- _collisionNode.AddEvent(AtkEventType.MouseOut, OnMouseOut);
- _collisionNode.AddEvent(AtkEventType.MouseClick, OnMouseClick);
- _collisionNode.AttachNode(this);
- }
+ bool needsCollisionUpdate = field is null && value is not null;
+ field = value;
+ var item = value.Item;
+ _iconNode.IconId = item.IconId;
+ _iconNode.ItemTooltip = item.ItemId;
+ _quantityTextNode.String = value.Quantity > 1 ? value.Quantity.ToString() : string.Empty;
- public LootedItemInfo LootedItem { get; private set; } = null!;
-
- public void SetLootedItem(LootedItemInfo lootedItem)
- {
- LootedItem = lootedItem;
- var item = lootedItem.Item;
- _iconNode.IconId = item.IconId;
- _quantityTextNode.String = lootedItem.Quantity > 1 ? lootedItem.Quantity.ToString() : string.Empty;
- }
-
- private void OnMouseOver(AtkEventListener* thisPtr, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, AtkEventData* atkEventData)
- {
- var item = LootedItem.Item;
- _collisionNode.ShowInventoryItemTooltip(item.Container, item.Slot);
- }
-
- private void OnMouseOut(AtkEventListener* thisPtr, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, AtkEventData* atkEventData)
- {
- ushort addonId = RaptureAtkUnitManager.Instance()->GetAddonByNode(_collisionNode)->Id;
- AtkStage.Instance()->TooltipManager.HideTooltip(addonId);
- }
+ if (needsCollisionUpdate)
+ {
+ var addon = RaptureAtkUnitManager.Instance()->GetAddonByNode(this);
+ if (addon is not null)
+ addon->UpdateCollisionNodeList(false);
+ }
+ }
+ } = null!;
private void OnMouseClick(AtkEventListener* thisPtr, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, AtkEventData* atkEventData)
{
diff --git a/AetherBags/Nodes/Inventory/LootedItemsCategoryNode.cs b/AetherBags/Nodes/Inventory/LootedItemsCategoryNode.cs
index bbe3af0..99f6688 100644
--- a/AetherBags/Nodes/Inventory/LootedItemsCategoryNode.cs
+++ b/AetherBags/Nodes/Inventory/LootedItemsCategoryNode.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Numerics;
+using System.Runtime.CompilerServices;
using AetherBags.Inventory.Items;
using AetherBags.Nodes.Layout;
using FFXIVClientStructs.FFXIV.Component.GUI;
@@ -27,6 +28,9 @@ public class LootedItemsCategoryNode : InventoryCategoryNodeBase
private IReadOnlyList _lootedItems = Array.Empty();
+ private int _lastItemCount;
+ private long _lastItemsHash;
+
private int _hoverRefs;
private bool _headerExpanded;
private float _baseHeaderWidth = 96f;
@@ -95,10 +99,40 @@ public class LootedItemsCategoryNode : InventoryCategoryNodeBase
public void UpdateLootedItems(IReadOnlyList lootedItems)
{
+ long newHash = ComputeItemsHash(lootedItems);
+ bool itemsChanged = lootedItems.Count != _lastItemCount || newHash != _lastItemsHash;
+
+ _lastItemCount = lootedItems.Count;
+ _lastItemsHash = newHash;
_lootedItems = lootedItems;
+
UpdateHeaderText();
- SyncItemGrid();
- RecalculateSize();
+
+ if (itemsChanged)
+ {
+ using (_itemGridNode.DeferRecalculateLayout())
+ {
+ SyncItemGrid();
+ }
+ RecalculateSize();
+ }
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static long ComputeItemsHash(IReadOnlyList items)
+ {
+ unchecked
+ {
+ long hash = unchecked((long)14695981039346656037UL);
+ for (int i = 0; i < items.Count; i++)
+ {
+ hash ^= items[i].Index;
+ hash *= 1099511628211L;
+ hash ^= items[i].Item.ItemId;
+ hash *= 1099511628211L;
+ }
+ return hash;
+ }
}
private void UpdateHeaderText()
@@ -163,20 +197,26 @@ public class LootedItemsCategoryNode : InventoryCategoryNodeBase
private void SyncItemGrid()
{
- _itemGridNode.SyncWithListData(
- _lootedItems,
- node => node.LootedItem,
- CreateLootedItemNode);
+ _itemGridNode.SyncWithListDataByKey(
+ dataList: _lootedItems,
+ getKeyFromData: item => item.Index,
+ getKeyFromNode: node => node.LootedItem?.Index ?? -1,
+ updateNode: UpdateLootedItemNode,
+ createNodeMethod: CreateLootedItemNode);
+ }
+
+ private static void UpdateLootedItemNode(LootedItemDisplayNode node, LootedItemInfo data)
+ {
+ node.LootedItem = data;
}
private LootedItemDisplayNode CreateLootedItemNode(LootedItemInfo lootedItem)
{
- var node = new LootedItemDisplayNode
+ return new LootedItemDisplayNode
{
OnDismiss = OnItemDismissed,
+ LootedItem = lootedItem,
};
- node.SetLootedItem(lootedItem);
- return node;
}
private void OnItemDismissed(LootedItemDisplayNode node)
diff --git a/AetherBags/Nodes/Layout/DeferrableLayoutListNode.cs b/AetherBags/Nodes/Layout/DeferrableLayoutListNode.cs
new file mode 100644
index 0000000..5efaf83
--- /dev/null
+++ b/AetherBags/Nodes/Layout/DeferrableLayoutListNode.cs
@@ -0,0 +1,502 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using System.Runtime.CompilerServices;
+using FFXIVClientStructs.FFXIV.Component.GUI;
+using KamiToolKit;
+using KamiToolKit.Nodes;
+
+namespace AetherBags.Nodes.Layout;
+
+public abstract class DeferrableLayoutListNode : SimpleComponentNode
+{
+ protected readonly List NodeList = [];
+ private bool _suppressRecalculateLayout;
+ private int _deferRecalcDepth;
+ private bool _pendingRecalc;
+
+ ///
+ /// Hide and detach a node from the UI tree without disposing it.
+ /// Disposal happens later when KamiToolKit cleans up detached nodes.
+ ///
+ protected static void SafeDetachNode(NodeBase node)
+ {
+ try
+ {
+ node.IsVisible = false;
+ node.DetachNode();
+ }
+ catch (Exception ex)
+ {
+ Services.Logger.Error(ex, $"[SafeDetachNode] Error detaching {node.GetType().Name}");
+ }
+ }
+
+ public IEnumerable GetNodes() where T : NodeBase
+ {
+ for (int i = 0; i < NodeList.Count; i++)
+ {
+ if (NodeList[i] is T t)
+ yield return t;
+ }
+ }
+
+ public IReadOnlyList Nodes => NodeList;
+
+ public bool ClipListContents
+ {
+ get => NodeFlags.HasFlag(NodeFlags.Clip);
+ set
+ {
+ if (value)
+ AddFlags(NodeFlags.Clip);
+ else
+ RemoveFlags(NodeFlags.Clip);
+ }
+ }
+
+ public float ItemSpacing { get; set; }
+
+ public float FirstItemSpacing { get; set; }
+
+ public void RecalculateLayout()
+ {
+ if (_suppressRecalculateLayout) return;
+
+ if (_deferRecalcDepth > 0)
+ {
+ _pendingRecalc = true;
+ return;
+ }
+
+ InternalRecalculateLayout();
+
+ for (int i = 0; i < NodeList.Count; i++)
+ {
+ if (NodeList[i] is DeferrableLayoutListNode subNode)
+ subNode.RecalculateLayout();
+ }
+ }
+
+ [Obsolete]
+ protected virtual void AdjustNode(NodeBase node) { }
+
+ protected abstract void InternalRecalculateLayout();
+
+ public ICollection InitialNodes
+ {
+ init => AddNode(value);
+ }
+
+ public void AddNode(IEnumerable nodes)
+ {
+ _suppressRecalculateLayout = true;
+ try
+ {
+ foreach (var node in nodes)
+ {
+ AddNode(node);
+ }
+ }
+ finally
+ {
+ _suppressRecalculateLayout = false;
+ }
+ RecalculateLayout();
+ }
+
+ public virtual void AddNode(NodeBase? node)
+ {
+ if (node is null) return;
+
+ NodeList.Add(node);
+
+ node.AttachNode(this);
+
+ RecalculateLayout();
+ }
+
+ public void RemoveNode(params NodeBase[] items)
+ {
+ _suppressRecalculateLayout = true;
+ try
+ {
+ foreach (var node in items)
+ {
+ RemoveNode(node);
+ }
+ }
+ finally
+ {
+ _suppressRecalculateLayout = false;
+ }
+ RecalculateLayout();
+ }
+
+ public virtual void RemoveNode(NodeBase node)
+ {
+ if (!NodeList.Contains(node)) return;
+
+ NodeList.Remove(node);
+ SafeDetachNode(node);
+
+ RecalculateLayout();
+ }
+
+ public void AddDummy(float size = 0.0f)
+ {
+ var dummyNode = new ResNode
+ {
+ Size = new Vector2(size, size),
+ };
+
+ AddNode(dummyNode);
+ }
+
+ public virtual void Clear()
+ {
+ _suppressRecalculateLayout = true;
+ try
+ {
+ for (int i = NodeList.Count - 1; i >= 0; i--)
+ {
+ var node = NodeList[i];
+ NodeList.RemoveAt(i);
+ SafeDetachNode(node);
+ }
+ }
+ finally
+ {
+ _suppressRecalculateLayout = false;
+ }
+ RecalculateLayout();
+ }
+
+ public delegate TU CreateNewNode(T data) where TU : NodeBase;
+
+ public delegate T GetDataFromNode(TU node) where TU : NodeBase;
+
+ private List? _existingScratch;
+ private List? _desiredScratch;
+ private List? _toRemoveScratch;
+ private HashSet