diff --git a/AetherBags/Addons/AddonInventoryWindow.cs b/AetherBags/Addons/AddonInventoryWindow.cs index 2f67fd4..92a455c 100644 --- a/AetherBags/Addons/AddonInventoryWindow.cs +++ b/AetherBags/Addons/AddonInventoryWindow.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Numerics; using AetherBags.Extensions; using AetherBags.Inventory; @@ -11,7 +10,6 @@ using FFXIVClientStructs.FFXIV.Client.UI.Agent; using FFXIVClientStructs.FFXIV.Component.GUI; using KamiToolKit; using KamiToolKit.Classes; -using Lumina.Data.Parsing.Uld; namespace AetherBags.Addons; @@ -38,6 +36,9 @@ public class AddonInventoryWindow : NativeAddon private const float FooterHeight = 28f; private const float FooterTopSpacing = 4f; + private bool _refreshQueued; + private bool _refreshAutosizeQueued; + protected override unsafe void OnSetup(AtkUnitBase* addon) { _categoriesNode = new WrappingGridNode @@ -57,15 +58,17 @@ public class AddonInventoryWindow : NativeAddon _searchInputNode = new TextInputWithHintNode { Position = headerSize / 2.0f - size / 2.0f + new Vector2(25.0f, 10.0f), + Size = size, - OnInputReceived = _ => RefreshCategories(false), + + OnInputReceived = _ => RefreshCategoriesCore(autosize: false), }; _searchInputNode.AttachNode(this); _footerNode = new InventoryFooterNode { Size = ContentSize with { Y = FooterHeight }, - SlotAmountText = InventoryState.GetEmptyItemSlotsString() + SlotAmountText = InventoryState.GetEmptyItemSlotsString(), }; _footerNode.AttachNode(this); @@ -74,50 +77,71 @@ public class AddonInventoryWindow : NativeAddon Services.AddonLifecycle.RegisterListener(AddonEvent.PostRequestedUpdate, "Inventory", OnInventoryUpdate); addon->SubscribeAtkArrayData(1, (int)NumberArrayType.Inventory); - RefreshCategories(); + InventoryState.RefreshFromGame(); + + RefreshCategoriesCore(autosize: true); + base.OnSetup(addon); } + protected override unsafe void OnUpdate(AtkUnitBase* addon) { + if (_refreshQueued) + { + bool doAutosize = _refreshAutosizeQueued; + _refreshQueued = false; + _refreshAutosizeQueued = false; + + RefreshCategoriesCore(doAutosize); + } + base.OnUpdate(addon); } private void OnInventoryUpdate(AddonEvent type, AddonArgs args) { - RefreshCategories(); + InventoryState.RefreshFromGame(); + + RefreshCategoriesCore(autosize: true); } protected override unsafe void OnRequestedUpdate(AtkUnitBase* addon, NumberArrayData** numberArrayData, StringArrayData** stringArrayData) { base.OnRequestedUpdate(addon, numberArrayData, stringArrayData); - RefreshCategories(); + + InventoryState.RefreshFromGame(); + + RefreshCategoriesCore(autosize: true); } - private void RefreshCategories(bool autosize = true) + private void RefreshCategoriesCore(bool autosize) { _footerNode.SlotAmountText = InventoryState.GetEmptyItemSlotsString(); - var categories = InventoryState.GetInventoryItemCategories(_searchInputNode.SearchString.ExtractText()); + string filter = _searchInputNode.SearchString.ExtractText(); + IReadOnlyList categories = InventoryState.GetInventoryItemCategories(filter); float maxContentWidth = MaxWindowWidth - (ContentStartPosition.X * 2); int maxItemsPerLine = CalculateOptimalItemsPerLine(maxContentWidth); - _categoriesNode.SyncWithListData( - categories, - node => node.CategorizedInventory, - data => new InventoryCategoryNode + _categoriesNode.SyncWithListDataByKey( + dataList: categories, + getKeyFromData: c => c.Key, + getKeyFromNode: n => n.CategorizedInventory.Key, + updateNode: (node, data) => { - Size = ContentSize with { Y = 120 }, - CategorizedInventory = data, - ItemsPerLine = Math.Min(data.Items.Count, maxItemsPerLine) + node.CategorizedInventory = data; + node.ItemsPerLine = Math.Min(data.Items.Count, maxItemsPerLine); + }, + createNodeMethod: _ => + { + return new InventoryCategoryNode + { + Size = ContentSize with { Y = 120 }, + }; }); - foreach (InventoryCategoryNode node in _categoriesNode.GetNodes()) - { - node.ItemsPerLine = Math.Min(node.CategorizedInventory.Items.Count, maxItemsPerLine); - } - WireHoverHandlers(); if (autosize) AutoSizeWindow(); @@ -130,11 +154,12 @@ public class AddonInventoryWindow : NativeAddon private void WireHoverHandlers() { - List categoryNodes = _categoriesNode.GetNodes().ToList(); + var nodes = _categoriesNode.Nodes; - for (int i = 0; i < categoryNodes.Count; i++) + for (int i = 0; i < nodes.Count; i++) { - InventoryCategoryNode node = categoryNodes[i]; + if (nodes[i] is not InventoryCategoryNode node) + continue; if (!_hoverSubscribed.Add(node)) continue; @@ -148,7 +173,7 @@ public class AddonInventoryWindow : NativeAddon private int CalculateOptimalItemsPerLine(float availableWidth) { - return Math.Clamp((int)Math.Floor((availableWidth + ItemPadding) / (ItemSize + ItemPadding)), 1, 15); + return Math.Clamp((int)MathF.Floor((availableWidth + ItemPadding) / (ItemSize + ItemPadding)), 1, 15); } private void LayoutContent() @@ -170,15 +195,28 @@ public class AddonInventoryWindow : NativeAddon private void AutoSizeWindow() { - List childNodes = _categoriesNode.GetNodes().ToList(); - if (childNodes.Count == 0) + var nodes = _categoriesNode.Nodes; + + float maxChildWidth = 0f; + int childCount = 0; + + for (int i = 0; i < nodes.Count; i++) { - ResizeWindow(MinWindowWidth, MinWindowHeight); + if (nodes[i] is not InventoryCategoryNode cat) + continue; + + childCount++; + float w = cat.Width; + if (w > maxChildWidth) maxChildWidth = w; + } + + if (childCount == 0) + { + ResizeWindow(MinWindowWidth, MinWindowHeight, recalcLayout: true); return; } - float requiredWidth = childNodes.Max(node => node.Width); - requiredWidth += ContentStartPosition.X * 2; + float requiredWidth = maxChildWidth + (ContentStartPosition.X * 2); float finalWidth = Math.Clamp(requiredWidth, MinWindowWidth, MaxWindowWidth); float contentWidth = finalWidth - (ContentStartPosition.X * 2); @@ -194,27 +232,32 @@ public class AddonInventoryWindow : NativeAddon float requiredContentHeight = requiredGridHeight + FooterTopSpacing + FooterHeight; float requiredWindowHeight = requiredContentHeight + ContentStartPosition.Y + ContentStartPosition.X; - float finalHeight = Math.Clamp(requiredWindowHeight, MinWindowHeight, MaxWindowHeight); - ResizeWindow(finalWidth, finalHeight); + ResizeWindow(finalWidth, finalHeight, recalcLayout: false); + } + + private void ResizeWindow(float width, float height, bool recalcLayout) + { + SetWindowSize(width, height); + LayoutContent(); + + if (recalcLayout) + _categoriesNode.RecalculateLayout(); } private void ResizeWindow(float width, float height) - { - SetWindowSize(width, height); - - LayoutContent(); - - _categoriesNode.RecalculateLayout(); - } + => ResizeWindow(width, height, recalcLayout: true); protected override unsafe void OnFinalize(AtkUnitBase* addon) { - base.OnFinalize(addon); Services.AddonLifecycle.UnregisterListener(OnInventoryUpdate); addon->UnsubscribeAtkArrayData(1, (int)NumberArrayType.Inventory); _hoverSubscribed.Clear(); + _refreshQueued = false; + _refreshAutosizeQueued = false; + + base.OnFinalize(addon); } } diff --git a/AetherBags/Inventory/CategorizedInventory.cs b/AetherBags/Inventory/CategorizedInventory.cs index d08e992..6f5a511 100644 --- a/AetherBags/Inventory/CategorizedInventory.cs +++ b/AetherBags/Inventory/CategorizedInventory.cs @@ -2,4 +2,4 @@ using System.Collections.Generic; namespace AetherBags.Inventory; -public readonly record struct CategorizedInventory(CategoryInfo Category, List Items); \ No newline at end of file +public readonly record struct CategorizedInventory(uint Key, CategoryInfo Category, List Items); \ No newline at end of file diff --git a/AetherBags/Inventory/InventoryState.cs b/AetherBags/Inventory/InventoryState.cs index 31f075f..575a9ef 100644 --- a/AetherBags/Inventory/InventoryState.cs +++ b/AetherBags/Inventory/InventoryState.cs @@ -43,65 +43,33 @@ public static unsafe class InventoryState InventoryType.Inventory4, ]; + private static readonly Dictionary CategoryInfoCache = new(capacity: 256); + + private static readonly Dictionary AggByItemId = new(capacity: 512); + private static readonly Dictionary ItemInfoByItemId = new(capacity: 512); + + private static readonly Dictionary BucketsByKey = new(capacity: 256); + private static readonly List SortedCategoryKeys = new(capacity: 256); + + private static readonly List AllCategories = new(capacity: 256); + + private static readonly List FilteredCategories = new(capacity: 256); + + private static readonly List RemoveKeysScratch = new(capacity: 256); + public static bool Contains(this IReadOnlyCollection inventoryTypes, GameInventoryType type) => inventoryTypes.Contains((InventoryType)type); - private static readonly Dictionary CategoryInfoCache = new(capacity: 256); - - public static List GetInventoryItemCategories(string filterString = "", bool invert = false) + public static void RefreshFromGame() { - List items = string.IsNullOrEmpty(filterString) - ? GetInventoryItems() - : GetInventoryItems(filterString, invert); - - if (items.Count == 0) - return new List(0); - - var buckets = new Dictionary(capacity: Math.Min(128, items.Count)); - - for (int i = 0; i < items.Count; i++) - { - ItemInfo info = items[i]; - uint catKey = info.UiCategory.RowId; - - if (!buckets.TryGetValue(catKey, out CategoryBucket? bucket)) - { - bucket = new CategoryBucket - { - Key = catKey, - Category = GetCategoryInfoForKeyCached(catKey, info), - Items = new List(capacity: 16), - }; - buckets.Add(catKey, bucket); - } - - bucket.Items.Add(info); - } - - uint[] keys = new uint[buckets.Count]; - int k = 0; - foreach (var key in buckets.Keys) - keys[k++] = key; - Array.Sort(keys); - - var result = new List(keys.Length); - for (int i = 0; i < keys.Length; i++) - { - CategoryBucket bucket = buckets[keys[i]]; - bucket.Items.Sort(ItemCountDescComparer.Instance); - result.Add(new CategorizedInventory(bucket.Category, bucket.Items)); - } - - return result; - } - - public static List GetInventoryItems() - { - var dict = new Dictionary(capacity: 128); - InventoryManager* mgr = InventoryManager.Instance(); if (mgr == null) - return new List(0); + { + ClearAll(); + return; + } + + AggByItemId.Clear(); for (int invIndex = 0; invIndex < BagInventories.Length; invIndex++) { @@ -119,54 +87,195 @@ public static unsafe class InventoryState int qty = item.Quantity; - if (dict.TryGetValue(id, out AggregatedItem agg)) + if (AggByItemId.TryGetValue(id, out AggregatedItem agg)) { agg.Total += qty; - dict[id] = agg; + AggByItemId[id] = agg; } else { - dict.Add(id, new AggregatedItem { First = item, Total = qty }); + AggByItemId.Add(id, new AggregatedItem { First = item, Total = qty }); } } } - if (dict.Count == 0) - return new List(0); - - var list = new List(dict.Count); - foreach (var kvp in dict) + foreach (var kvp in BucketsByKey) { + CategoryBucket b = kvp.Value; + b.Used = false; + b.Items.Clear(); + b.FilteredItems.Clear(); + } + + foreach (var kvp in AggByItemId) + { + uint itemId = kvp.Key; AggregatedItem agg = kvp.Value; - list.Add(new ItemInfo + if (!ItemInfoByItemId.TryGetValue(itemId, out ItemInfo? info)) { - Item = agg.First, - ItemCount = agg.Total, - }); + info = new ItemInfo + { + Item = agg.First, + ItemCount = agg.Total, + }; + ItemInfoByItemId.Add(itemId, info); + } + else + { + info.Item = agg.First; + info.ItemCount = agg.Total; + } + + uint catKey = info.UiCategory.RowId; + + if (!BucketsByKey.TryGetValue(catKey, out CategoryBucket? bucket)) + { + bucket = new CategoryBucket + { + Key = catKey, + Category = GetCategoryInfoForKeyCached(catKey, info), + Items = new List(capacity: 16), + FilteredItems = new List(capacity: 16), + Used = true, + }; + BucketsByKey.Add(catKey, bucket); + } + else + { + bucket.Used = true; + } + + bucket.Items.Add(info); } - return list; + if (ItemInfoByItemId.Count != AggByItemId.Count) + { + RemoveKeysScratch.Clear(); + + foreach (var kvp in ItemInfoByItemId) + { + uint itemId = kvp.Key; + if (!AggByItemId.ContainsKey(itemId)) + RemoveKeysScratch.Add(itemId); + } + + for (int i = 0; i < RemoveKeysScratch.Count; i++) + ItemInfoByItemId.Remove(RemoveKeysScratch[i]); + } + + SortedCategoryKeys.Clear(); + + foreach (var kvp in BucketsByKey) + { + CategoryBucket bucket = kvp.Value; + if (!bucket.Used) + continue; + + bucket.Items.Sort(ItemCountDescComparer.Instance); + SortedCategoryKeys.Add(bucket.Key); + } + + SortedCategoryKeys.Sort(); + + AllCategories.Clear(); + AllCategories.Capacity = Math.Max(AllCategories.Capacity, SortedCategoryKeys.Count); + + for (int i = 0; i < SortedCategoryKeys.Count; i++) + { + uint key = SortedCategoryKeys[i]; + CategoryBucket bucket = BucketsByKey[key]; + AllCategories.Add(new CategorizedInventory(bucket.Key, bucket.Category, bucket.Items)); + } } - public static List GetInventoryItems(string filterString, bool invert = false) + public static IReadOnlyList GetInventoryItemCategories(string filterString = "", bool invert = false) { - List all = GetInventoryItems(); - if (all.Count == 0) - return all; + if (string.IsNullOrEmpty(filterString)) + return AllCategories; - var filtered = new List(all.Count); - var re = new Regex(filterString, RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); - for (int i = 0; i < all.Count; i++) + Regex? re = null; + bool regexValid = true; + + try { - ItemInfo info = all[i]; - - bool isMatch = info.IsRegexMatch(re); - if (isMatch != invert) - filtered.Add(info); + re = new Regex(filterString, RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + } + catch + { + regexValid = false; } - return filtered; + FilteredCategories.Clear(); + + for (int i = 0; i < AllCategories.Count; i++) + { + CategorizedInventory cat = AllCategories[i]; + CategoryBucket bucket = BucketsByKey[cat.Key]; + + var filtered = bucket.FilteredItems; + filtered.Clear(); + + var src = bucket.Items; + for (int j = 0; j < src.Count; j++) + { + ItemInfo info = src[j]; + + bool isMatch; + if (regexValid) + { + isMatch = info.IsRegexMatch(re!); + } + else + { + isMatch = info.Name.Contains(filterString, StringComparison.OrdinalIgnoreCase) + || info.DescriptionContains(filterString); + } + + if (isMatch != invert) + filtered.Add(info); + } + + if (filtered.Count != 0) + FilteredCategories.Add(new CategorizedInventory(bucket.Key, bucket.Category, filtered)); + } + + return FilteredCategories; + } + + public static string GetEmptyItemSlotsString() + { + uint empty = InventoryManager.Instance()->GetEmptySlotsInBag(); + uint used = 140 - empty; + return $"{used}/140"; + } + + public static CurrencyInfo GetCurrencyInfo(uint itemId) + { + return new CurrencyInfo + { + Amount = InventoryManager.Instance()->GetInventoryItemCount(1), + ItemId = itemId, + IconId = Services.DataManager.GetExcelSheet().GetRow(itemId).Icon + }; + } + + private static void ClearAll() + { + AggByItemId.Clear(); + ItemInfoByItemId.Clear(); + + foreach (var kvp in BucketsByKey) + { + kvp.Value.Items.Clear(); + kvp.Value.FilteredItems.Clear(); + kvp.Value.Used = false; + } + + SortedCategoryKeys.Clear(); + AllCategories.Clear(); + FilteredCategories.Clear(); + RemoveKeysScratch.Clear(); } private static CategoryInfo GetCategoryInfoForKeyCached(uint key, ItemInfo sample) @@ -202,22 +311,6 @@ public static unsafe class InventoryState }; } - private static uint GetEmptyItemSlots() => InventoryManager.Instance()->GetEmptySlotsInBag(); - - private static uint GetUsedItemSlots() => 140 - GetEmptyItemSlots(); - - public static string GetEmptyItemSlotsString() => $"{GetUsedItemSlots()}/140"; - - public static CurrencyInfo GetCurrencyInfo(uint itemId) - { - return new CurrencyInfo - { - Amount = InventoryManager.Instance()->GetInventoryItemCount(1), - ItemId = itemId, - IconId = Services.DataManager.GetExcelSheet().GetRow(itemId).Icon - }; - } - private struct AggregatedItem { public InventoryItem First; @@ -230,8 +323,10 @@ public static unsafe class InventoryState public int Compare(ItemInfo x, ItemInfo y) { - if (x.ItemCount > y.ItemCount) return -1; - if (x.ItemCount < y.ItemCount) return 1; + int a = x.ItemCount; + int b = y.ItemCount; + if (a > b) return -1; + if (a < b) return 1; return 0; } } @@ -241,5 +336,7 @@ public static unsafe class InventoryState public uint Key; public CategoryInfo Category = null!; public List Items = null!; + public List FilteredItems = null!; + public bool Used; } } diff --git a/AetherBags/Inventory/ItemInfo.cs b/AetherBags/Inventory/ItemInfo.cs index 5be6f0b..99978f5 100644 --- a/AetherBags/Inventory/ItemInfo.cs +++ b/AetherBags/Inventory/ItemInfo.cs @@ -76,6 +76,9 @@ public sealed class ItemInfo : IEquatable return false; } + public bool DescriptionContains(string value) + => Description.Contains(value, StringComparison.OrdinalIgnoreCase); + public bool Equals(ItemInfo? other) => other is not null && Item.ItemId == other.Item.ItemId && ItemCount == other.ItemCount; diff --git a/AetherBags/Nodes/InventoryCategoryNode.cs b/AetherBags/Nodes/InventoryCategoryNode.cs index 144e312..1e24bf4 100644 --- a/AetherBags/Nodes/InventoryCategoryNode.cs +++ b/AetherBags/Nodes/InventoryCategoryNode.cs @@ -62,7 +62,7 @@ public class InventoryCategoryNode : SimpleComponentNode _itemGridNode.AttachNode(this); } - public required CategorizedInventory CategorizedInventory + public CategorizedInventory CategorizedInventory { get; set diff --git a/KamiToolKit b/KamiToolKit index 44ac1e0..aa27815 160000 --- a/KamiToolKit +++ b/KamiToolKit @@ -1 +1 @@ -Subproject commit 44ac1e0c3ae2bf6fb81870ced7c52dd7fb4e38c1 +Subproject commit aa278153f335a368971b7ae90041d29de14f3289