From ced0e5ef22aa82450a633595fbdb68ec7104d382 Mon Sep 17 00:00:00 2001 From: Shawrkie Williams Date: Sat, 20 Dec 2025 10:49:45 -0500 Subject: [PATCH] Layout + Search Optimizations --- AetherBags/Inventory/InventoryState.cs | 198 +++++++++++++---- AetherBags/Inventory/ItemInfo.cs | 90 +++++--- AetherBags/Nodes/WrappingGridNode.cs | 280 ++++++++++++++++++++++++- 3 files changed, 485 insertions(+), 83 deletions(-) diff --git a/AetherBags/Inventory/InventoryState.cs b/AetherBags/Inventory/InventoryState.cs index f158d9c..31f075f 100644 --- a/AetherBags/Inventory/InventoryState.cs +++ b/AetherBags/Inventory/InventoryState.cs @@ -1,16 +1,18 @@ -using System.Collections.Generic; -using System.Linq; using AetherBags.Currency; using Dalamud.Game.Inventory; using FFXIVClientStructs.FFXIV.Client.Game; -using FFXIVClientStructs.FFXIV.Client.UI.Agent; using Lumina.Excel.Sheets; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; namespace AetherBags.Inventory; public static unsafe class InventoryState { - public static List StandardInventories => [ + public static readonly InventoryType[] StandardInventories = + [ InventoryType.Inventory1, InventoryType.Inventory2, InventoryType.Inventory3, @@ -33,61 +35,151 @@ public static unsafe class InventoryState InventoryType.ArmorySoulCrystal, ]; - public static bool Contains(this List inventoryTypes, GameInventoryType type) + private static readonly InventoryType[] BagInventories = + [ + InventoryType.Inventory1, + InventoryType.Inventory2, + InventoryType.Inventory3, + InventoryType.Inventory4, + ]; + + 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) { - var items = string.IsNullOrEmpty(filterString) + List items = string.IsNullOrEmpty(filterString) ? GetInventoryItems() : GetInventoryItems(filterString, invert); - return items - .GroupBy(GetItemUiCategoryKey) - .OrderBy(g => g.Key) - .Select(g => + 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)) { - var category = GetCategoryInfoForKey(g.Key, g.FirstOrDefault()); - var list = g.OrderByDescending(i => i.ItemCount).ToList(); - return new CategorizedInventory(category, list); - }) - .ToList(); + 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() { - List inventories = [ InventoryType.Inventory1, InventoryType.Inventory2, InventoryType.Inventory3, InventoryType.Inventory4 ]; - List items = []; + public static List GetInventoryItems() + { + var dict = new Dictionary(capacity: 128); - foreach (var inventory in inventories) { - var container = InventoryManager.Instance()->GetInventoryContainer(inventory); + InventoryManager* mgr = InventoryManager.Instance(); + if (mgr == null) + return new List(0); - for (var index = 0; index < container->Size; ++index) { - ref var item = ref container->Items[index]; - if (item.ItemId is 0) continue; + for (int invIndex = 0; invIndex < BagInventories.Length; invIndex++) + { + var container = mgr->GetInventoryContainer(BagInventories[invIndex]); + if (container == null) + continue; - items.Add(item); + int size = container->Size; + for (int slot = 0; slot < size; slot++) + { + ref var item = ref container->Items[slot]; + uint id = item.ItemId; + if (id == 0) + continue; + + int qty = item.Quantity; + + if (dict.TryGetValue(id, out AggregatedItem agg)) + { + agg.Total += qty; + dict[id] = agg; + } + else + { + dict.Add(id, new AggregatedItem { First = item, Total = qty }); + } } } - List itemInfos = []; - itemInfos.AddRange(from itemGroups in items.GroupBy(item => item.ItemId) - where itemGroups.Key is not 0 - let item = itemGroups.First() - let itemCount = itemGroups.Sum(duplicateItem => duplicateItem.Quantity) - select new ItemInfo { - Item = item, ItemCount = itemCount, - }); + if (dict.Count == 0) + return new List(0); - return itemInfos; + var list = new List(dict.Count); + foreach (var kvp in dict) + { + AggregatedItem agg = kvp.Value; + + list.Add(new ItemInfo + { + Item = agg.First, + ItemCount = agg.Total, + }); + } + + return list; } public static List GetInventoryItems(string filterString, bool invert = false) - => GetInventoryItems().Where(item => item.IsRegexMatch(filterString) != invert).ToList(); + { + List all = GetInventoryItems(); + if (all.Count == 0) + return all; - private static uint GetItemUiCategoryKey(ItemInfo info) - => info.UiCategory.RowId; + var filtered = new List(all.Count); + var re = new Regex(filterString, RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + for (int i = 0; i < all.Count; i++) + { + ItemInfo info = all[i]; - private static CategoryInfo GetCategoryInfoForKey(uint key, ItemInfo? sample) + bool isMatch = info.IsRegexMatch(re); + if (isMatch != invert) + filtered.Add(info); + } + + return filtered; + } + + private static CategoryInfo GetCategoryInfoForKeyCached(uint key, ItemInfo sample) + { + if (CategoryInfoCache.TryGetValue(key, out var cached)) + return cached; + + CategoryInfo info = GetCategoryInfoForKeySlow(key, sample); + CategoryInfoCache[key] = info; + return info; + } + + private static CategoryInfo GetCategoryInfoForKeySlow(uint key, ItemInfo sample) { if (key == 0) { @@ -98,8 +190,9 @@ public static unsafe class InventoryState }; } - var uiCat = sample?.UiCategory.Value; - var name = uiCat?.Name.ToString(); + var uiCat = sample.UiCategory.Value; + string? name = uiCat.Name.ToString(); + if (string.IsNullOrWhiteSpace(name)) name = $"Category\\ {key}"; @@ -124,4 +217,29 @@ public static unsafe class InventoryState IconId = Services.DataManager.GetExcelSheet().GetRow(itemId).Icon }; } -} \ No newline at end of file + + private struct AggregatedItem + { + public InventoryItem First; + public int Total; + } + + private sealed class ItemCountDescComparer : IComparer + { + public static readonly ItemCountDescComparer Instance = new(); + + public int Compare(ItemInfo x, ItemInfo y) + { + if (x.ItemCount > y.ItemCount) return -1; + if (x.ItemCount < y.ItemCount) return 1; + return 0; + } + } + + private sealed class CategoryBucket + { + public uint Key; + public CategoryInfo Category = null!; + public List Items = null!; + } +} diff --git a/AetherBags/Inventory/ItemInfo.cs b/AetherBags/Inventory/ItemInfo.cs index 1ef6e50..5be6f0b 100644 --- a/AetherBags/Inventory/ItemInfo.cs +++ b/AetherBags/Inventory/ItemInfo.cs @@ -1,61 +1,87 @@ -using System; -using System.Numerics; -using System.Text.RegularExpressions; using AetherBags.Extensions; using FFXIVClientStructs.FFXIV.Client.Game; using Lumina.Excel; using Lumina.Excel.Sheets; +using System; +using System.Numerics; +using System.Text.RegularExpressions; namespace AetherBags.Inventory; -public class ItemInfo : IEquatable { +public sealed class ItemInfo : IEquatable +{ public required InventoryItem Item { get; set; } public required int ItemCount { get; set; } - private Item ItemData => Services.DataManager.GetExcelSheet().GetRow(Item.ItemId); + private static ExcelSheet? s_itemSheet; + private static ExcelSheet ItemSheet => s_itemSheet ??= Services.DataManager.GetExcelSheet(); - public Vector4 RarityColor => ItemData.RarityColor; + private bool _rowLoaded; + private Item _row; - public uint IconId => ItemData.Icon; + private string? _name; + private string? _description; - public string Name => ItemData.Name.ToString(); + private ref readonly Item Row + { + get + { + if (!_rowLoaded) + { + _row = ItemSheet.GetRow(Item.ItemId); + _rowLoaded = true; + } + return ref _row; + } + } - public int Level => ItemData.LevelEquip; + public Vector4 RarityColor => Row.RarityColor; + public uint IconId => Row.Icon; - public int ItemLevel => (int) ItemData.LevelItem.RowId; + public string Name => _name ??= Row.Name.ToString(); - public int Rarity => ItemData.Rarity; + public int Level => Row.LevelEquip; + public int ItemLevel => (int)Row.LevelItem.RowId; + public int Rarity => Row.Rarity; - public RowRef UiCategory => ItemData.ItemUICategory; + public RowRef UiCategory => Row.ItemUICategory; - private string Description => ItemData.Description.ToString(); + private string Description => _description ??= Row.Description.ToString(); - public bool IsRegexMatch(string searchTerms) { - const RegexOptions regexOptions = RegexOptions.CultureInvariant | RegexOptions.IgnoreCase; + public bool IsRegexMatch(string searchTerms) + { + if (string.IsNullOrEmpty(searchTerms)) + return true; - if (Regex.IsMatch(Name, searchTerms, regexOptions)) return true; - if (Regex.IsMatch(Description, searchTerms, regexOptions)) return true; - if (Regex.IsMatch(Level.ToString(), searchTerms, regexOptions)) return true; - if (Regex.IsMatch(ItemLevel.ToString(), searchTerms, regexOptions)) return true; + var re = new Regex(searchTerms, RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + + if (re.IsMatch(Name)) return true; + + if (re.IsMatch(Description)) return true; + + if (re.IsMatch(Level.ToString())) return true; + if (re.IsMatch(ItemLevel.ToString())) return true; return false; } - public bool Equals(ItemInfo? other) { - if (other is null) return false; - if (ReferenceEquals(this, other)) return true; - return Item.ItemId.Equals(other.Item.ItemId) && ItemCount == other.ItemCount; + public bool IsRegexMatch(Regex re) + { + if (re.IsMatch(Name)) return true; + if (re.IsMatch(Description)) return true; + + if (re.IsMatch(Level.ToString())) return true; + if (re.IsMatch(ItemLevel.ToString())) return true; + + return false; } - public override bool Equals(object? obj) { - if (obj is null) return false; - if (ReferenceEquals(this, obj)) return true; - if (obj.GetType() != GetType()) return false; - return Equals((ItemInfo) obj); - } + public bool Equals(ItemInfo? other) + => other is not null && Item.ItemId == other.Item.ItemId && ItemCount == other.ItemCount; + + public override bool Equals(object? obj) + => obj is ItemInfo other && Equals(other); public override int GetHashCode() - // ReSharper disable NonReadonlyMemberInGetHashCode => HashCode.Combine(Item.ItemId, ItemCount); - // ReSharper restore NonReadonlyMemberInGetHashCode -} \ No newline at end of file +} diff --git a/AetherBags/Nodes/WrappingGridNode.cs b/AetherBags/Nodes/WrappingGridNode.cs index f0e0efd..5c6147d 100644 --- a/AetherBags/Nodes/WrappingGridNode.cs +++ b/AetherBags/Nodes/WrappingGridNode.cs @@ -25,6 +25,13 @@ public sealed class WrappingGridNode : LayoutListNode where T : NodeBase private readonly IReadOnlyList> _rowsView; + private float _lastAvailableWidth = float.NaN; + private float _lastStartX = float.NaN; + private float _lastHSpace = float.NaN; + private float _lastVSpace = float.NaN; + private float _lastTopPadding = float.NaN; + private float _lastBottomPadding = float.NaN; + public WrappingGridNode() { _rowsView = new RowsReadOnlyView(_rows); @@ -37,17 +44,254 @@ public sealed class WrappingGridNode : LayoutListNode where T : NodeBase protected override void InternalRecalculateLayout() { - RecycleAllRows(); - _rowIndex.Clear(); - int count = NodeList.Count; if (count == 0) { + RecycleAllRows(); + _rowIndex.Clear(); _requiredHeight = 0f; _requiredHeightDirty = false; + RememberLayoutParams(); return; } + if (_rows.Count != 0 && TryUpdateLayoutWithoutReflowOrTailReflow(count)) + { + _requiredHeightDirty = true; + RememberLayoutParams(); + return; + } + + FullReflow(count); + _requiredHeightDirty = true; + RememberLayoutParams(); + } + + private bool TryUpdateLayoutWithoutReflowOrTailReflow(int count) + { + if (!LayoutParamsMatchLast()) + return false; + + int mismatchRow = FindFirstMismatchRow(count, out int mismatchNodeIndex); + + if (mismatchRow < 0) + { + RepositionExistingRows(); + return true; + } + + TailReflowFrom(mismatchRow, mismatchNodeIndex, count); + return true; + } + + private int FindFirstMismatchRow(int count, out int mismatchNodeIndex) + { + float availableWidth = Width; + float hSpace = HorizontalSpacing; + float startX = FirstItemSpacing; + + int rowIdx = 0; + int nodeIdx = 0; + + while (nodeIdx < count) + { + if (rowIdx >= _rows.Count) + { + mismatchNodeIndex = nodeIdx; + return rowIdx; + } + + List existingRow = _rows[rowIdx]; + int existingRowCount = existingRow.Count; + + if (existingRowCount == 0) + { + mismatchNodeIndex = nodeIdx; + return rowIdx; + } + + int predictedCount = 0; + float currentX = startX; + + while (nodeIdx + predictedCount < count) + { + NodeBase node = NodeList[nodeIdx + predictedCount]; + float w = node.Width; + + if (predictedCount != 0 && (currentX + w) > availableWidth) + break; + + predictedCount++; + currentX += w + hSpace; + } + + if (predictedCount != existingRowCount) + { + mismatchNodeIndex = nodeIdx; + return rowIdx; + } + + for (int j = 0; j < existingRowCount; j++) + { + if (!ReferenceEquals(existingRow[j], NodeList[nodeIdx + j])) + { + mismatchNodeIndex = nodeIdx; + return rowIdx; + } + } + + nodeIdx += existingRowCount; + rowIdx++; + } + + if (rowIdx < _rows.Count) + { + mismatchNodeIndex = nodeIdx; + return rowIdx; + } + + mismatchNodeIndex = -1; + return -1; + } + + private void RepositionExistingRows() + { + _rowIndex.Clear(); + _rowIndex.EnsureCapacity(NodeList.Count); + + float hSpace = HorizontalSpacing; + float vSpace = VerticalSpacing; + float startX = FirstItemSpacing; + + float y = TopPadding; + + for (int rowIdx = 0; rowIdx < _rows.Count; rowIdx++) + { + List row = _rows[rowIdx]; + float x = startX; + float rowHeight = 0f; + + for (int j = 0; j < row.Count; j++) + { + NodeBase node = row[j]; + + node.X = x; + node.Y = y; + + AdjustNode(node); + + float h = node.Height; + if (h > rowHeight) rowHeight = h; + + _rowIndex[node] = rowIdx; + + x += node.Width + hSpace; + } + + y += rowHeight + vSpace; + } + } + + private void TailReflowFrom(int startRowIndex, int startNodeIndex, int count) + { + _rowIndex.Clear(); + _rowIndex.EnsureCapacity(count); + + float availableWidth = Width; + float hSpace = HorizontalSpacing; + float vSpace = VerticalSpacing; + float startX = FirstItemSpacing; + + float y = TopPadding; + + if ((uint)startRowIndex > (uint)_rows.Count) + startRowIndex = _rows.Count; + + for (int rowIdx = 0; rowIdx < startRowIndex; rowIdx++) + { + List row = _rows[rowIdx]; + float x = startX; + float rowHeight = 0f; + + for (int j = 0; j < row.Count; j++) + { + NodeBase node = row[j]; + + node.X = x; + node.Y = y; + + AdjustNode(node); + + float h = node.Height; + if (h > rowHeight) rowHeight = h; + + _rowIndex[node] = rowIdx; + + x += node.Width + hSpace; + } + + y += rowHeight + vSpace; + } + + for (int i = _rows.Count - 1; i >= startRowIndex; i--) + { + List row = _rows[i]; + row.Clear(); + _rowPool.Push(row); + _rows.RemoveAt(i); + } + + int currentRowIndex = startRowIndex; + float xCursor = startX; + float rowHeightTail = 0f; + + List currentRow = RentRowList(capacityHint: 8); + + for (int i = startNodeIndex; i < count; i++) + { + NodeBase node = NodeList[i]; + float w = node.Width; + + if (currentRow.Count != 0 && (xCursor + w) > availableWidth) + { + _rows.Add(currentRow); + currentRowIndex++; + + y += rowHeightTail + vSpace; + xCursor = startX; + rowHeightTail = 0f; + + currentRow = RentRowList(capacityHint: 8); + } + + node.X = xCursor; + node.Y = y; + + AdjustNode(node); + + float h = node.Height; + if (h > rowHeightTail) rowHeightTail = h; + + currentRow.Add(node); + _rowIndex[node] = currentRowIndex; + + xCursor += w + hSpace; + } + + if (currentRow.Count != 0) + { + _rows.Add(currentRow); + } + else + { + RecycleRow(currentRow); + } + } + + private void FullReflow(int count) + { + RecycleAllRows(); + _rowIndex.Clear(); _rowIndex.EnsureCapacity(count); float availableWidth = Width; @@ -65,7 +309,6 @@ public sealed class WrappingGridNode : LayoutListNode where T : NodeBase for (int i = 0; i < count; i++) { NodeBase node = NodeList[i]; - float nodeWidth = node.Width; if (currentRow.Count != 0 && (currentX + nodeWidth) > availableWidth) @@ -102,8 +345,6 @@ public sealed class WrappingGridNode : LayoutListNode where T : NodeBase { RecycleRow(currentRow); } - - _requiredHeightDirty = true; } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -121,7 +362,6 @@ public sealed class WrappingGridNode : LayoutListNode where T : NodeBase if (bottom > maxBottom) maxBottom = bottom; } - maxBottom += BottomPadding; _requiredHeight = maxBottom; @@ -160,6 +400,27 @@ public sealed class WrappingGridNode : LayoutListNode where T : NodeBase _rowPool.Push(row); } + private bool LayoutParamsMatchLast() + { + return + _lastAvailableWidth == Width && + _lastStartX == FirstItemSpacing && + _lastHSpace == HorizontalSpacing && + _lastVSpace == VerticalSpacing && + _lastTopPadding == TopPadding && + _lastBottomPadding == BottomPadding; + } + + private void RememberLayoutParams() + { + _lastAvailableWidth = Width; + _lastStartX = FirstItemSpacing; + _lastHSpace = HorizontalSpacing; + _lastVSpace = VerticalSpacing; + _lastTopPadding = TopPadding; + _lastBottomPadding = BottomPadding; + } + private sealed class RowsReadOnlyView : IReadOnlyList> { private readonly List> _rows; @@ -174,10 +435,7 @@ public sealed class WrappingGridNode : LayoutListNode where T : NodeBase yield return _rows[i]; } - IEnumerator IEnumerable.GetEnumerator() - { - return GetEnumerator(); - } + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); } private sealed class ReferenceEqualityComparer : IEqualityComparer where TRef : class