Improves inventory refresh logic and fixes UI glitches

This commit is contained in:
Shawrkie Williams
2026-01-10 16:58:26 -05:00
parent 665d3b62ba
commit d7babea111
11 changed files with 823 additions and 137 deletions
@@ -14,6 +14,14 @@ using ValueType = FFXIVClientStructs.FFXIV.Component.GUI.ValueType;
namespace AetherBags.AddonLifecycles;
public static unsafe class DragDropState
{
/// <summary>
/// Returns true if the game's drag-drop manager is currently dragging.
/// </summary>
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();
}
+3 -5
View File
@@ -101,12 +101,7 @@ public unsafe class AddonInventoryWindow : InventoryAddonBase
private void OnLootedItemsChanged(IReadOnlyList<LootedItemInfo> lootedItems)
{
if (!IsOpen || !IsSetupComplete) return;
Services.Framework.RunOnTick(() =>
{
if (!IsOpen) return;
UpdateLootedCategory(lootedItems);
}, delayTicks: 1);
}
private void UpdateLootedCategory(IReadOnlyList<LootedItemInfo> 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;
+6 -3
View File
@@ -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<CategorizedInventory, InventoryCategoryNode, uint>(
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);
}
+93 -12
View File
@@ -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<InventoryType> StandardInventories => InventoryScanner.StandardInventories;
private const int BatchDelayMs = 300;
private readonly List<LootedItemInfo> _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<IReadOnlyList<LootedItemInfo>>? OnLootedItemsChanged;
public IReadOnlyList<LootedItemInfo> 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;
var key = (inventoryItem.ItemId, IsHq: inventoryItem.Flags.HasFlag(InventoryItem.ItemFlags.HighQuality));
if (_pendingChanges.TryGetValue(key, out var existing))
{
_pendingChanges[key] = (inventoryItem, existing.Quantity + changeAmount);
}
else
{
_pendingChanges[key] = (inventoryItem, changeAmount);
}
anyAdded = true;
}
if (anyAdded && _batchStartTick == 0)
{
_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,
*inventoryItem,
changeAmount));
hasChanges = true;
item,
quantity));
}
if (hasChanges)
{
_pendingChanges.Clear();
OnLootedItemsChanged?.Invoke(_lootedItems);
}
private static bool ShouldFilterItem(uint itemId)
{
if (!Services.DataManager.GetExcelSheet<Item>().TryGetRow(itemId, out var item))
return false;
if (item.ItemUICategory.RowId == 62)
return true;
return false;
}
}
@@ -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<InventoryCategoryNode, bool>? 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)
{
field = value;
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
? $"{value.Category.Name} ({value.Items.Count})"
: value.Category.Name;
? $"{data.Category.Name} ({data.Items.Count})"
: data.Category.Name;
_categoryNameTextNode.String = _fullHeaderText;
_categoryNameTextNode.TextColor = value.Category.Color;
_categoryNameTextNode.TextTooltip = value.Category.Description;
_categoryNameTextNode.TextColor = data.Category.Color;
_categoryNameTextNode.TextTooltip = data.Category.Description;
if (itemsChanged || categoryChanged)
{
using (_itemGridNode.DeferRecalculateLayout())
{
_itemGridNode.ItemsPerLine = itemsPerLine;
UpdateItemGrid();
}
}
else if (itemsPerLineChanged)
{
_itemGridNode.ItemsPerLine = itemsPerLine;
}
if (categoryChanged || itemsChanged || itemsPerLineChanged)
{
RecalculateSize();
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static ulong ComputeItemsHash(ReadOnlySpan<ItemInfo> 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<ItemInfo, InventoryDragDropNode, ulong>(
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;
@@ -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;
/// <summary>
/// A display-only item node for looted items. Not draggable, but shows tooltip and can be dismissed.
/// </summary>
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<LootedItemDisplayNode>? 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
{
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);
}
public LootedItemInfo LootedItem { get; private set; } = null!;
public void SetLootedItem(LootedItemInfo lootedItem)
public LootedItemInfo LootedItem
{
LootedItem = lootedItem;
var item = lootedItem.Item;
get;
set
{
bool needsCollisionUpdate = field is null && value is not null;
field = value;
var item = value.Item;
_iconNode.IconId = item.IconId;
_quantityTextNode.String = lootedItem.Quantity > 1 ? lootedItem.Quantity.ToString() : string.Empty;
}
_iconNode.ItemTooltip = item.ItemId;
_quantityTextNode.String = value.Quantity > 1 ? value.Quantity.ToString() : string.Empty;
private void OnMouseOver(AtkEventListener* thisPtr, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, AtkEventData* atkEventData)
if (needsCollisionUpdate)
{
var item = LootedItem.Item;
_collisionNode.ShowInventoryItemTooltip(item.Container, item.Slot);
var addon = RaptureAtkUnitManager.Instance()->GetAddonByNode(this);
if (addon is not null)
addon->UpdateCollisionNodeList(false);
}
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);
}
} = null!;
private void OnMouseClick(AtkEventListener* thisPtr, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, AtkEventData* atkEventData)
{
@@ -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<LootedItemInfo> _lootedItems = Array.Empty<LootedItemInfo>();
private int _lastItemCount;
private long _lastItemsHash;
private int _hoverRefs;
private bool _headerExpanded;
private float _baseHeaderWidth = 96f;
@@ -95,11 +99,41 @@ public class LootedItemsCategoryNode : InventoryCategoryNodeBase
public void UpdateLootedItems(IReadOnlyList<LootedItemInfo> lootedItems)
{
long newHash = ComputeItemsHash(lootedItems);
bool itemsChanged = lootedItems.Count != _lastItemCount || newHash != _lastItemsHash;
_lastItemCount = lootedItems.Count;
_lastItemsHash = newHash;
_lootedItems = lootedItems;
UpdateHeaderText();
if (itemsChanged)
{
using (_itemGridNode.DeferRecalculateLayout())
{
SyncItemGrid();
}
RecalculateSize();
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static long ComputeItemsHash(IReadOnlyList<LootedItemInfo> 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<LootedItemInfo, LootedItemDisplayNode, int>(
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)
@@ -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<NodeBase> NodeList = [];
private bool _suppressRecalculateLayout;
private int _deferRecalcDepth;
private bool _pendingRecalc;
/// <summary>
/// Hide and detach a node from the UI tree without disposing it.
/// Disposal happens later when KamiToolKit cleans up detached nodes.
/// </summary>
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<T> GetNodes<T>() where T : NodeBase
{
for (int i = 0; i < NodeList.Count; i++)
{
if (NodeList[i] is T t)
yield return t;
}
}
public IReadOnlyList<NodeBase> 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<NodeBase> InitialNodes
{
init => AddNode(value);
}
public void AddNode(IEnumerable<NodeBase> 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<in T, out TU>(T data) where TU : NodeBase;
public delegate T GetDataFromNode<out T, in TU>(TU node) where TU : NodeBase;
private List<NodeBase>? _existingScratch;
private List<NodeBase>? _desiredScratch;
private List<NodeBase>? _toRemoveScratch;
private HashSet<object>? _dataKeysScratch;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private List<NodeBase> RentExistingList(int capacity)
{
var list = _existingScratch ?? new List<NodeBase>(capacity);
list.Clear();
if (list.Capacity < capacity) list.Capacity = capacity;
_existingScratch = null;
return list;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void ReturnExistingList(List<NodeBase> list)
{
list.Clear();
_existingScratch = list;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private List<NodeBase> RentDesiredList(int capacity)
{
var list = _desiredScratch ?? new List<NodeBase>(capacity);
list.Clear();
if (list.Capacity < capacity) list.Capacity = capacity;
_desiredScratch = null;
return list;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void ReturnDesiredList(List<NodeBase> list)
{
list.Clear();
_desiredScratch = list;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private List<NodeBase> RentRemoveList(int capacity)
{
var list = _toRemoveScratch ?? new List<NodeBase>(capacity);
list.Clear();
if (list.Capacity < capacity) list.Capacity = capacity;
_toRemoveScratch = null;
return list;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void ReturnRemoveList(List<NodeBase> list)
{
list.Clear();
_toRemoveScratch = list;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private HashSet<object> RentKeySet(int capacity)
{
var set = _dataKeysScratch ?? new HashSet<object>(capacity);
set.Clear();
_dataKeysScratch = null;
return set;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void ReturnKeySet(HashSet<object> set)
{
set.Clear();
_dataKeysScratch = set;
}
public bool SyncWithListDataByKey<T, TU, TKey>(
IReadOnlyList<T> dataList,
Func<T, TKey> getKeyFromData,
Func<TU, TKey> getKeyFromNode,
Action<TU, T> updateNode,
CreateNewNode<T, TU> createNodeMethod,
IEqualityComparer<TKey>? keyComparer = null) where TU : NodeBase where TKey : notnull
{
keyComparer ??= EqualityComparer<TKey>.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<TKey, TU>? byKey = null;
if (existing.Count > 0)
{
byKey = new Dictionary<TKey, TU>(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<T, TU>(
IEnumerable<T> dataList,
GetDataFromNode<T?, TU> getDataFromNode,
CreateNewNode<T, TU> 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<T>(EqualityComparer<T>.Default);
foreach (var d in dataList)
dataSet.Add(d);
var represented = new HashSet<T>(EqualityComparer<T>.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<NodeBase> 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();
}
}
@@ -1,11 +1,10 @@
using KamiToolKit;
using KamiToolKit.Nodes;
namespace AetherBags.Nodes.Layout;
public class HybridDirectionalFlexNode : HybridDirectionalFlexNode<NodeBase> { }
public class HybridDirectionalFlexNode<T> : LayoutListNode where T : NodeBase
public class HybridDirectionalFlexNode<T> : DeferrableLayoutListNode where T : NodeBase
{
public FlexGrowDirection GrowDirection
{
@@ -1,9 +1,8 @@
using KamiToolKit;
using KamiToolKit.Nodes;
namespace AetherBags.Nodes.Layout;
public class HybridDirectionalStackNode<T> : LayoutListNode where T : NodeBase
public class HybridDirectionalStackNode<T> : DeferrableLayoutListNode where T : NodeBase
{
public FlexGrowDirection GrowDirection
{
+4 -43
View File
@@ -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<T> : LayoutListNode where T : NodeBase
public sealed class WrappingGridNode<T> : DeferrableLayoutListNode where T : NodeBase
{
public float HorizontalSpacing { get; set; } = 10f;
public float VerticalSpacing { get; set; } = 10f;
@@ -36,9 +35,6 @@ public sealed class WrappingGridNode<T> : LayoutListNode where T : NodeBase
private bool _lastuseStableInsert;
private int _lastCompactLookahead;
private int _deferRecalcDepth;
private bool _pendingRecalc;
private int[] _orderScratch = Array.Empty<int>();
private T? _hoistedNode;
@@ -80,7 +76,7 @@ public sealed class WrappingGridNode<T> : LayoutListNode where T : NodeBase
{
if (_pinned.Add(node))
{
RequestRecalculateLayout();
RecalculateLayout();
return true;
}
return false;
@@ -90,7 +86,7 @@ public sealed class WrappingGridNode<T> : LayoutListNode where T : NodeBase
{
if (_pinned.Remove(node))
{
RequestRecalculateLayout();
RecalculateLayout();
return true;
}
return false;
@@ -100,7 +96,7 @@ public sealed class WrappingGridNode<T> : 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<T> : LayoutListNode where T : NodeBase
_orderScratch = new int[newSize];
}
public IDisposable DeferRecalculateLayout()
{
_deferRecalcDepth++;
return new RecalcDeferToken<T>(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<IReadOnlyList<NodeBase>>
{
private readonly List<List<NodeBase>> _rows;
@@ -1068,11 +1036,4 @@ public sealed class WrappingGridNode<T> : LayoutListNode where T : NodeBase
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public int GetHashCode(TRef obj) => RuntimeHelpers.GetHashCode(obj);
}
private readonly struct RecalcDeferToken<TRef>(WrappingGridNode<TRef> owner) : IDisposable
where TRef : NodeBase
{
public void Dispose() => owner.EndDefer();
}
}