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? _dataKeysScratch; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private List RentExistingList(int capacity) + { + var list = _existingScratch ?? new List(capacity); + list.Clear(); + if (list.Capacity < capacity) list.Capacity = capacity; + _existingScratch = null; + return list; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void ReturnExistingList(List list) + { + list.Clear(); + _existingScratch = list; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private List RentDesiredList(int capacity) + { + var list = _desiredScratch ?? new List(capacity); + list.Clear(); + if (list.Capacity < capacity) list.Capacity = capacity; + _desiredScratch = null; + return list; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void ReturnDesiredList(List list) + { + list.Clear(); + _desiredScratch = list; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private List RentRemoveList(int capacity) + { + var list = _toRemoveScratch ?? new List(capacity); + list.Clear(); + if (list.Capacity < capacity) list.Capacity = capacity; + _toRemoveScratch = null; + return list; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void ReturnRemoveList(List list) + { + list.Clear(); + _toRemoveScratch = list; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private HashSet RentKeySet(int capacity) + { + var set = _dataKeysScratch ?? new HashSet(capacity); + set.Clear(); + _dataKeysScratch = null; + return set; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void ReturnKeySet(HashSet set) + { + set.Clear(); + _dataKeysScratch = set; + } + + + public bool SyncWithListDataByKey( + IReadOnlyList dataList, + Func getKeyFromData, + Func getKeyFromNode, + Action updateNode, + CreateNewNode createNodeMethod, + IEqualityComparer? keyComparer = null) where TU : NodeBase where TKey : notnull + { + keyComparer ??= EqualityComparer.Default; + + int dataCount = dataList.Count; + + var desiredKeys = RentKeySet(dataCount); + for (int i = 0; i < dataCount; i++) + { + desiredKeys.Add(getKeyFromData(dataList[i])!); + } + + var existing = RentExistingList(NodeList.Count); + var toRemove = RentRemoveList(16); + + for (int i = 0; i < NodeList.Count; i++) + { + if (NodeList[i] is TU tu) + { + var key = getKeyFromNode(tu); + if (desiredKeys.Contains(key)) + { + existing.Add(tu); + } + else + { + toRemove.Add(tu); + } + } + } + + bool structureChanged = toRemove.Count > 0; + + if (toRemove.Count > 0) + { + _suppressRecalculateLayout = true; + try + { + for (int i = 0; i < toRemove.Count; i++) + { + var node = toRemove[i]; + NodeList.Remove(node); + SafeDetachNode(node); + } + } + finally + { + _suppressRecalculateLayout = false; + } + } + + Dictionary? byKey = null; + if (existing.Count > 0) + { + byKey = new Dictionary(existing.Count, keyComparer); + for (int i = 0; i < existing.Count; i++) + { + var tu = (TU)existing[i]; + var key = getKeyFromNode(tu); + byKey.TryAdd(key, tu); + } + } + + var desired = RentDesiredList(dataCount); + + _suppressRecalculateLayout = true; + try + { + for (int i = 0; i < dataCount; i++) + { + var data = dataList[i]; + var key = getKeyFromData(data); + + if (byKey != null && byKey.TryGetValue(key, out var existingNode)) + { + updateNode(existingNode, data); + desired.Add(existingNode); + byKey.Remove(key); + } + else + { + var newNode = createNodeMethod(data); + NodeList.Add(newNode); + newNode.AttachNode(this); + updateNode(newNode, data); + desired.Add(newNode); + structureChanged = true; + } + } + } + finally + { + _suppressRecalculateLayout = false; + } + + bool orderChanged = false; + if (!structureChanged && desired.Count > 0) + { + int tuIndex = 0; + for (int i = 0; i < NodeList.Count && tuIndex < desired.Count; i++) + { + if (NodeList[i] is TU) + { + if (!ReferenceEquals(NodeList[i], desired[tuIndex])) + { + orderChanged = true; + break; + } + tuIndex++; + } + } + if (tuIndex != desired.Count) + orderChanged = true; + } + + if (structureChanged || orderChanged) + { + int insertIndex = -1; + for (int i = 0; i < NodeList.Count; i++) + { + if (NodeList[i] is TU) + { + insertIndex = i; + break; + } + } + + if (insertIndex < 0) + insertIndex = NodeList.Count; + + for (int i = NodeList.Count - 1; i >= 0; i--) + { + if (NodeList[i] is TU) + NodeList.RemoveAt(i); + } + + if (insertIndex > NodeList.Count) + insertIndex = NodeList.Count; + + NodeList.InsertRange(insertIndex, desired); + } + + ReturnKeySet(desiredKeys); + ReturnExistingList(existing); + ReturnRemoveList(toRemove); + ReturnDesiredList(desired); + + if (structureChanged || orderChanged) + { + RecalculateLayout(); + } + + return structureChanged || orderChanged; + } + + public bool SyncWithListData( + IEnumerable dataList, + GetDataFromNode getDataFromNode, + CreateNewNode createNodeMethod) where TU : NodeBase + { + _suppressRecalculateLayout = true; + var anythingChanged = false; + try + { + var existing = RentExistingList(NodeList.Count); + for (int i = 0; i < NodeList.Count; i++) + { + if (NodeList[i] is TU tu) + existing.Add(tu); + } + + var dataSet = new HashSet(EqualityComparer.Default); + foreach (var d in dataList) + dataSet.Add(d); + + var represented = new HashSet(EqualityComparer.Default); + + for (int i = 0; i < existing.Count; i++) + { + var tu = (TU)existing[i]; + var nodeData = getDataFromNode(tu); + + if (nodeData is null || !dataSet.Contains(nodeData)) + { + NodeList.Remove(tu); + SafeDetachNode(tu); + anythingChanged = true; + continue; + } + + represented.Add(nodeData); + } + + foreach (var data in dataSet) + { + if (represented.Contains(data)) + continue; + + var newNode = createNodeMethod(data); + NodeList.Add(newNode); + newNode.AttachNode(this); + anythingChanged = true; + } + + ReturnExistingList(existing); + } + finally + { + _suppressRecalculateLayout = false; + } + + if (anythingChanged) + RecalculateLayout(); + + return anythingChanged; + } + + public void ReorderNodes(Comparison comparison) + { + NodeList.Sort(comparison); + RecalculateLayout(); + } + + public IDisposable DeferRecalculateLayout() + { + _deferRecalcDepth++; + return new RecalcDeferToken(this); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void EndDefer() + { + _deferRecalcDepth--; + if (_deferRecalcDepth == 0 && _pendingRecalc) + { + _pendingRecalc = false; + RecalculateLayout(); + } + } + + private readonly struct RecalcDeferToken(DeferrableLayoutListNode owner) : IDisposable + { + public void Dispose() => owner.EndDefer(); + } +} diff --git a/AetherBags/Nodes/Layout/HybridDirectionalFlexNode.cs b/AetherBags/Nodes/Layout/HybridDirectionalFlexNode.cs index fc9b49d..cc9cfe4 100644 --- a/AetherBags/Nodes/Layout/HybridDirectionalFlexNode.cs +++ b/AetherBags/Nodes/Layout/HybridDirectionalFlexNode.cs @@ -1,11 +1,10 @@ using KamiToolKit; -using KamiToolKit.Nodes; namespace AetherBags.Nodes.Layout; public class HybridDirectionalFlexNode : HybridDirectionalFlexNode { } -public class HybridDirectionalFlexNode : LayoutListNode where T : NodeBase +public class HybridDirectionalFlexNode : DeferrableLayoutListNode where T : NodeBase { public FlexGrowDirection GrowDirection { diff --git a/AetherBags/Nodes/Layout/HybridDirectionalStackNode.cs b/AetherBags/Nodes/Layout/HybridDirectionalStackNode.cs index df5138e..1aa6b81 100644 --- a/AetherBags/Nodes/Layout/HybridDirectionalStackNode.cs +++ b/AetherBags/Nodes/Layout/HybridDirectionalStackNode.cs @@ -1,9 +1,8 @@ using KamiToolKit; -using KamiToolKit.Nodes; namespace AetherBags.Nodes.Layout; -public class HybridDirectionalStackNode : LayoutListNode where T : NodeBase +public class HybridDirectionalStackNode : DeferrableLayoutListNode where T : NodeBase { public FlexGrowDirection GrowDirection { diff --git a/AetherBags/Nodes/Layout/WrappingGridNode.cs b/AetherBags/Nodes/Layout/WrappingGridNode.cs index 2a5d1d4..d116f21 100644 --- a/AetherBags/Nodes/Layout/WrappingGridNode.cs +++ b/AetherBags/Nodes/Layout/WrappingGridNode.cs @@ -1,5 +1,4 @@ using KamiToolKit; -using KamiToolKit.Nodes; using System; using System.Collections; using System.Collections.Generic; @@ -7,7 +6,7 @@ using System.Runtime.CompilerServices; namespace AetherBags.Nodes.Layout; -public sealed class WrappingGridNode : LayoutListNode where T : NodeBase +public sealed class WrappingGridNode : DeferrableLayoutListNode where T : NodeBase { public float HorizontalSpacing { get; set; } = 10f; public float VerticalSpacing { get; set; } = 10f; @@ -36,9 +35,6 @@ public sealed class WrappingGridNode : LayoutListNode where T : NodeBase private bool _lastuseStableInsert; private int _lastCompactLookahead; - private int _deferRecalcDepth; - private bool _pendingRecalc; - private int[] _orderScratch = Array.Empty(); private T? _hoistedNode; @@ -80,7 +76,7 @@ public sealed class WrappingGridNode : LayoutListNode where T : NodeBase { if (_pinned.Add(node)) { - RequestRecalculateLayout(); + RecalculateLayout(); return true; } return false; @@ -90,7 +86,7 @@ public sealed class WrappingGridNode : LayoutListNode where T : NodeBase { if (_pinned.Remove(node)) { - RequestRecalculateLayout(); + RecalculateLayout(); return true; } return false; @@ -100,7 +96,7 @@ public sealed class WrappingGridNode : LayoutListNode where T : NodeBase { if (_pinned.Count == 0) return; _pinned.Clear(); - RequestRecalculateLayout(); + RecalculateLayout(); } public bool IsPinned(T node) => _pinned.Contains(node); @@ -1013,34 +1009,6 @@ public sealed class WrappingGridNode : LayoutListNode where T : NodeBase _orderScratch = new int[newSize]; } - public IDisposable DeferRecalculateLayout() - { - _deferRecalcDepth++; - return new RecalcDeferToken(this); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void RequestRecalculateLayout() - { - if (_deferRecalcDepth > 0) - { - _pendingRecalc = true; - return; - } - - RecalculateLayout(); - } - - private void EndDefer() - { - _deferRecalcDepth--; - if (_deferRecalcDepth == 0 && _pendingRecalc) - { - _pendingRecalc = false; - RecalculateLayout(); - } - } - private sealed class RowsReadOnlyView : IReadOnlyList> { private readonly List> _rows; @@ -1068,11 +1036,4 @@ public sealed class WrappingGridNode : LayoutListNode where T : NodeBase [MethodImpl(MethodImplOptions.AggressiveInlining)] public int GetHashCode(TRef obj) => RuntimeHelpers.GetHashCode(obj); } - - - private readonly struct RecalcDeferToken(WrappingGridNode owner) : IDisposable - where TRef : NodeBase - { - public void Dispose() => owner.EndDefer(); - } }