From 37b9d85313c3a7b8f47bd305a8468481dd710e74 Mon Sep 17 00:00:00 2001 From: Shawrkie Williams Date: Sun, 25 Jan 2026 04:06:39 -0500 Subject: [PATCH 1/3] Improves inventory loading performance Implements deferred item population for inventory categories, which significantly improves the initial loading time of inventory windows, especially with a large number of items. This change introduces a mechanism to populate inventory items in batches over multiple frames, preventing the UI from blocking during the initial load. It also correctly disposes of the looted category node when the addon is finalized, preventing potential memory leaks. --- AetherBags/Addons/AddonInventoryWindow.cs | 2 + AetherBags/Addons/InventoryAddonBase.cs | 83 +++++++++++++++++-- .../Nodes/Inventory/InventoryCategoryNode.cs | 44 +++++++--- 3 files changed, 112 insertions(+), 17 deletions(-) diff --git a/AetherBags/Addons/AddonInventoryWindow.cs b/AetherBags/Addons/AddonInventoryWindow.cs index af9eff0..5d1b426 100644 --- a/AetherBags/Addons/AddonInventoryWindow.cs +++ b/AetherBags/Addons/AddonInventoryWindow.cs @@ -168,6 +168,8 @@ public unsafe class AddonInventoryWindow : InventoryAddonBase addon->UnsubscribeAtkArrayData(1, (int)NumberArrayType.Inventory); + _lootedCategoryNode?.Dispose(); + IsSetupComplete = false; base.OnFinalize(addon); } diff --git a/AetherBags/Addons/InventoryAddonBase.cs b/AetherBags/Addons/InventoryAddonBase.cs index df7ed7a..7296f7a 100644 --- a/AetherBags/Addons/InventoryAddonBase.cs +++ b/AetherBags/Addons/InventoryAddonBase.cs @@ -52,6 +52,9 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow protected bool RefreshQueued; protected bool RefreshAutosizeQueued; protected bool IsSetupComplete; + private bool _deferredPopulationInProgress; + private bool _initialPopulationComplete; + private const int ItemsPerFrame = 70; protected abstract InventoryStateBase InventoryState { get; } @@ -154,17 +157,18 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow float maxContentWidth = CategoriesNode.Width > 0 ? CategoriesNode.Width : ContentSize.X; int maxItemsPerLine = CalculateOptimalItemsPerLine(maxContentWidth); + bool deferItems = !_deferredPopulationInProgress && !_initialPopulationComplete; + CategoriesNode.SyncWithListDataByKey( dataList: categories, getKeyFromData: categorizedInventory => categorizedInventory.Key, getKeyFromNode: node => node.CategorizedInventory.Key, updateNode: (node, data) => { - node.MaxWidth = maxContentWidth; - node.SetCategoryData(data, Math.Min(data.Items.Count, maxItemsPerLine)); - node.RefreshNodeVisuals(); + node.SetCategoryData(data, Math.Min(data.Items.Count, maxItemsPerLine), deferItemCreation: deferItems); + if (!deferItems) node.RefreshNodeVisuals(); }, - createNodeMethod: _ => CreateCategoryNode(maxContentWidth)); + createNodeMethod: _ => CreateCategoryNode()); if (HasPinning) { @@ -181,6 +185,72 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow LayoutContent(); CategoriesNode.RecalculateLayout(); } + + if (deferItems && !_deferredPopulationInProgress) + { + StartDeferredItemPopulation(); + } + else if (!deferItems && !_initialPopulationComplete) + { + _initialPopulationComplete = true; + } + } + + private void StartDeferredItemPopulation() + { + _deferredPopulationInProgress = true; + Services.Framework.RunOnTick(PopulateCategoryBatch, delayTicks: 1); + } + + private void PopulateCategoryBatch() + { + if (!IsOpen) + { + _deferredPopulationInProgress = false; + return; + } + + int itemsPopulated = 0; + using (CategoriesNode.DeferRecalculateLayout()) + { + foreach (var node in CategoriesNode.Nodes) + { + if (node is InventoryCategoryNode categoryNode && categoryNode.NeedsItemPopulation) + { + int categoryItemCount = categoryNode.CategorizedInventory.Items.Count; + + if (itemsPopulated > 0 && itemsPopulated + categoryItemCount > ItemsPerFrame) + break; + + categoryNode.PopulateItems(); + categoryNode.RefreshNodeVisuals(); + itemsPopulated += categoryItemCount; + + if (itemsPopulated >= ItemsPerFrame) + break; + } + } + } + + bool hasMore = false; + foreach (var node in CategoriesNode.Nodes) + { + if (node is InventoryCategoryNode categoryNode && categoryNode.NeedsItemPopulation) + { + hasMore = true; + break; + } + } + + if (hasMore) + { + Services.Framework.RunOnTick(PopulateCategoryBatch); + } + else + { + _deferredPopulationInProgress = false; + _initialPopulationComplete = true; + } } protected readonly struct HeaderLayout @@ -231,12 +301,11 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow BackgroundDropTarget.AttachNode(this); } - protected virtual InventoryCategoryNode CreateCategoryNode(float? maxWidth = null) + protected virtual InventoryCategoryNode CreateCategoryNode() { return new InventoryCategoryNode { Size = ContentSize with { Y = 120 }, - MaxWidth = maxWidth, OnRefreshRequested = ManualRefresh, OnDragEnd = () => InventoryOrchestrator.RefreshAll(updateMaps: true), }; @@ -461,6 +530,8 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow HoverSubscribed.Clear(); RefreshQueued = false; RefreshAutosizeQueued = false; + _deferredPopulationInProgress = false; + _initialPopulationComplete = false; base.OnFinalize(addon); } diff --git a/AetherBags/Nodes/Inventory/InventoryCategoryNode.cs b/AetherBags/Nodes/Inventory/InventoryCategoryNode.cs index 0a26458..b5f3340 100644 --- a/AetherBags/Nodes/Inventory/InventoryCategoryNode.cs +++ b/AetherBags/Nodes/Inventory/InventoryCategoryNode.cs @@ -25,7 +25,8 @@ public class InventoryCategoryNode : InventoryCategoryNodeBase private readonly TextNode _categoryNameTextNode; private readonly HybridDirectionalFlexNode _itemGridNode; - private const float FallbackItemSize = 46; + private const float ExpectedItemWidth = 42; + private const float ExpectedItemHeight = 46; private const float HeaderHeight = 16; private const float MinWidth = 40; @@ -41,9 +42,11 @@ public class InventoryCategoryNode : InventoryCategoryNodeBase private int _lastItemCount; private ulong _lastItemsHash; private int _lastItemsPerLine; - private float? _lastMaxWidth; + private bool _itemsNeedPopulation; public event Action? HeaderHoverChanged; + + public bool NeedsItemPopulation => _itemsNeedPopulation; public Action? OnRefreshRequested { get; set; } public Action? OnDragEnd { get; set; } @@ -86,11 +89,10 @@ public class InventoryCategoryNode : InventoryCategoryNodeBase set => SetCategoryData(value, _itemGridNode.ItemsPerLine); } - public void SetCategoryData(CategorizedInventory data, int itemsPerLine) + public void SetCategoryData(CategorizedInventory data, int itemsPerLine, bool deferItemCreation = false) { bool categoryChanged = data.Key != _lastCategoryKey; bool itemsPerLineChanged = itemsPerLine != _lastItemsPerLine; - bool maxWidthChanged = _maxWidth != _lastMaxWidth; ulong itemsHash = ComputeItemsHash(CollectionsMarshal.AsSpan(data.Items)); bool itemsChanged = data.Items.Count != _lastItemCount || itemsHash != _lastItemsHash; @@ -99,7 +101,6 @@ public class InventoryCategoryNode : InventoryCategoryNodeBase _lastItemCount = data.Items.Count; _lastItemsHash = itemsHash; _lastItemsPerLine = itemsPerLine; - _lastMaxWidth = _maxWidth; _categorizedInventory = data; @@ -113,10 +114,19 @@ public class InventoryCategoryNode : InventoryCategoryNodeBase if (itemsChanged || categoryChanged) { - using (_itemGridNode.DeferRecalculateLayout()) + _itemGridNode.ItemsPerLine = itemsPerLine; + + if (deferItemCreation) { - _itemGridNode.ItemsPerLine = itemsPerLine; - UpdateItemGrid(); + _itemsNeedPopulation = true; + } + else + { + using (_itemGridNode.DeferRecalculateLayout()) + { + UpdateItemGrid(); + } + _itemsNeedPopulation = false; } } else if (itemsPerLineChanged) @@ -124,12 +134,24 @@ public class InventoryCategoryNode : InventoryCategoryNodeBase _itemGridNode.ItemsPerLine = itemsPerLine; } - if (categoryChanged || itemsChanged || itemsPerLineChanged || maxWidthChanged) + if (categoryChanged || itemsChanged || itemsPerLineChanged) { RecalculateSize(); } } + public void PopulateItems() + { + if (!_itemsNeedPopulation) + return; + + using (_itemGridNode.DeferRecalculateLayout()) + { + UpdateItemGrid(); + } + _itemsNeedPopulation = false; + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static ulong ComputeItemsHash(ReadOnlySpan items) { @@ -238,8 +260,8 @@ public class InventoryCategoryNode : InventoryCategoryNodeBase { int itemCount = CategorizedInventory.Items.Count; - float cellW = _itemGridNode.Nodes.Count > 0 ? _itemGridNode.Nodes[0].Width : FallbackItemSize; - float cellH = _itemGridNode.Nodes.Count > 0 ? _itemGridNode.Nodes[0].Height : FallbackItemSize; + float cellW = ExpectedItemWidth; + float cellH = ExpectedItemHeight; float hPad = _itemGridNode.HorizontalPadding; float vPad = _itemGridNode.VerticalPadding; From 968a952a9ae1c59de3cb8f7bef0bdf2a1cae3a4c Mon Sep 17 00:00:00 2001 From: Shawrkie Williams Date: Wed, 4 Feb 2026 16:11:19 -0500 Subject: [PATCH 2/3] Update 'KamiToolKit' submodule --- KamiToolKit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/KamiToolKit b/KamiToolKit index 811154c..7720ab0 160000 --- a/KamiToolKit +++ b/KamiToolKit @@ -1 +1 @@ -Subproject commit 811154c8f882d1dfbb4fa3457982ef802fbff35a +Subproject commit 7720ab0741e350e9b9d699a35b360ec7878e3785 From 5c141dba7263ff80a67a1dca1404bc98ef49c584 Mon Sep 17 00:00:00 2001 From: Shawrkie Williams Date: Wed, 4 Feb 2026 16:26:38 -0500 Subject: [PATCH 3/3] Calculation updates, fixes, virtualization, new plugin ipc --- AetherBags/Addons/AddonInventoryWindow.cs | 33 +- AetherBags/Addons/AddonRetainerWindow.cs | 19 +- AetherBags/Addons/AddonSaddleBagWindow.cs | 18 +- AetherBags/Addons/InventoryAddonBase.cs | 235 ++++++++++++-- AetherBags/Addons/ItemContextMenuHandler.cs | 48 +++ AetherBags/Configuration/GeneralSettings.cs | 1 + AetherBags/Currency/CurrencyState.cs | 15 +- .../IPC/AetherBagsAPI/AetherBagsAPIImpl.cs | 96 ++++++ .../AetherBagsAPI/AetherBagsIPCProvider.cs | 83 +++++ .../IPC/AetherBagsAPI/IAetherBagsAPI.cs | 26 ++ AetherBags/IPC/AllaganToolsIPC.cs | 115 +++++++ AetherBags/IPC/BisBuddyIPC.cs | 151 +++++++++ .../ExternalCategoryManager.cs | 297 ++++++++++++++++++ .../IExternalItemSource.cs | 103 ++++++ AetherBags/IPC/IPCService.cs | 37 +++ .../Categories/CategoryBucketManager.cs | 176 +++++++---- .../Categories/UserCategoryMatcher.cs | 106 +++---- .../Inventory/Context/HighlightState.cs | 17 + AetherBags/Inventory/Items/ItemInfo.cs | 28 +- .../Inventory/State/InventoryStateBase.cs | 71 +++-- AetherBags/Monitoring/InventoryMonitor.cs | 19 +- AetherBags/Monitoring/LootedItemsTracker.cs | 33 +- .../CategoryGeneralConfigurationNode.cs | 7 +- .../Category/CategoryScrollingAreaNode.cs | 2 + .../Category/ExperimentalConfigurationNode.cs | 50 +++ .../Nodes/Inventory/InventoryCategoryNode.cs | 143 +++++---- .../Inventory/InventoryCategoryNodeBase.cs | 4 + .../Nodes/Inventory/InventoryDragDropNode.cs | 220 +++++++++++++ .../Nodes/Inventory/InventoryFooterNode.cs | 3 +- .../Nodes/Inventory/LootedItemDisplayNode.cs | 25 +- .../Inventory/LootedItemsCategoryNode.cs | 61 +++- .../Nodes/Layout/DeferrableLayoutListNode.cs | 115 ++++++- AetherBags/Nodes/Layout/SharedNodePool.cs | 107 +++++++ .../Nodes/Layout/VirtualizationState.cs | 146 +++++++++ AetherBags/Nodes/Layout/WrappingGridNode.cs | 19 +- AetherBags/Plugin.cs | 8 + AetherBags/System.cs | 2 + 37 files changed, 2334 insertions(+), 305 deletions(-) create mode 100644 AetherBags/Addons/ItemContextMenuHandler.cs create mode 100644 AetherBags/IPC/AetherBagsAPI/AetherBagsAPIImpl.cs create mode 100644 AetherBags/IPC/AetherBagsAPI/AetherBagsIPCProvider.cs create mode 100644 AetherBags/IPC/AetherBagsAPI/IAetherBagsAPI.cs create mode 100644 AetherBags/IPC/ExternalCategorySystem/ExternalCategoryManager.cs create mode 100644 AetherBags/IPC/ExternalCategorySystem/IExternalItemSource.cs create mode 100644 AetherBags/Nodes/Configuration/Category/ExperimentalConfigurationNode.cs create mode 100644 AetherBags/Nodes/Layout/SharedNodePool.cs create mode 100644 AetherBags/Nodes/Layout/VirtualizationState.cs diff --git a/AetherBags/Addons/AddonInventoryWindow.cs b/AetherBags/Addons/AddonInventoryWindow.cs index 5d1b426..af0d6bf 100644 --- a/AetherBags/Addons/AddonInventoryWindow.cs +++ b/AetherBags/Addons/AddonInventoryWindow.cs @@ -25,16 +25,20 @@ public unsafe class AddonInventoryWindow : InventoryAddonBase { InitializeBackgroundDropTarget(); - CategoriesNode = new WrappingGridNode + ScrollableCategories = new ScrollingAreaNode> { Position = ContentStartPosition, Size = ContentSize, - HorizontalSpacing = CategorySpacing, - VerticalSpacing = CategorySpacing, - TopPadding = 4.0f, - BottomPadding = 4.0f, + ContentHeight = 0f, + AutoHideScrollBar = true, }; - CategoriesNode.AttachNode(this); + ScrollableCategories.AttachNode(this); + + CategoriesNode = ScrollableCategories.ContentNode; + CategoriesNode.HorizontalSpacing = CategorySpacing; + CategoriesNode.VerticalSpacing = CategorySpacing; + CategoriesNode.TopPadding = 4.0f; + CategoriesNode.BottomPadding = 4.0f; _lootedCategoryNode = new LootedItemsCategoryNode { @@ -127,6 +131,7 @@ public unsafe class AddonInventoryWindow : InventoryAddonBase CategoriesNode.RemoveNode(_lootedCategoryNode); } + CategoriesNode.InvalidateLayout(); AutoSizeWindow(); } } @@ -134,6 +139,7 @@ public unsafe class AddonInventoryWindow : InventoryAddonBase private void OnDismissLootedItem(int index) { System.LootedItemsTracker.RemoveByIndex(index); + System.LootedItemsTracker.FlushPendingChanges(); } private void OnClearAllLootedItems() @@ -148,6 +154,21 @@ public unsafe class AddonInventoryWindow : InventoryAddonBase FooterNode.RefreshCurrencies(); } + protected override void UpdateHeaderLayout() + { + base.UpdateHeaderLayout(); + + AtkUnitBase* addon = this; + if (addon == null) return; + + var header = CalculateHeaderLayout(addon); + + if (_notificationNode != null) + { + _notificationNode.Size = new Vector2(header.HeaderWidth, 28f); + } + } + public void SetNotification(InventoryNotificationInfo info) { Services.Framework.RunOnTick(() => diff --git a/AetherBags/Addons/AddonRetainerWindow.cs b/AetherBags/Addons/AddonRetainerWindow.cs index 62dc25e..fc0f5a4 100644 --- a/AetherBags/Addons/AddonRetainerWindow.cs +++ b/AetherBags/Addons/AddonRetainerWindow.cs @@ -27,7 +27,7 @@ public unsafe class AddonRetainerWindow : InventoryAddonBase private readonly Vector3 _tintColor = new(8f / 255f, -8f / 255f, -4f / 255f); - protected override float MinWindowWidth => 400; + protected override float MinWindowWidth => 500; protected override float MaxWindowWidth => 700; private readonly string[] _retainerAddonNames = { "InventoryRetainer", "InventoryRetainerLarge" }; @@ -38,16 +38,20 @@ public unsafe class AddonRetainerWindow : InventoryAddonBase WindowNode?.AddColor = _tintColor; - CategoriesNode = new WrappingGridNode + ScrollableCategories = new ScrollingAreaNode> { Position = ContentStartPosition, Size = ContentSize, - HorizontalSpacing = CategorySpacing, - VerticalSpacing = CategorySpacing, - TopPadding = 4.0f, - BottomPadding = 4.0f, + ContentHeight = 0f, + AutoHideScrollBar = true, }; - CategoriesNode.AttachNode(this); + ScrollableCategories.AttachNode(this); + + CategoriesNode = ScrollableCategories.ContentNode; + CategoriesNode.HorizontalSpacing = CategorySpacing; + CategoriesNode.VerticalSpacing = CategorySpacing; + CategoriesNode.TopPadding = 4.0f; + CategoriesNode.BottomPadding = 4.0f; var header = CalculateHeaderLayout(addon); @@ -90,7 +94,6 @@ public unsafe class AddonRetainerWindow : InventoryAddonBase }; _entrustDuplicatesButton.AttachNode(this); - // Slot counter _slotCounterNode = new TextNode { Position = new Vector2(Size.X - 10, 0), diff --git a/AetherBags/Addons/AddonSaddleBagWindow.cs b/AetherBags/Addons/AddonSaddleBagWindow.cs index 4f00a6b..81faf12 100644 --- a/AetherBags/Addons/AddonSaddleBagWindow.cs +++ b/AetherBags/Addons/AddonSaddleBagWindow.cs @@ -22,7 +22,7 @@ public unsafe class AddonSaddleBagWindow : InventoryAddonBase private readonly Vector3 _tintColor = new (-16f / 255f, -4f / 255f, 8f / 255f); - protected override float MinWindowWidth => 400; + protected override float MinWindowWidth => 500; protected override float MaxWindowWidth => 600; protected override void OnSetup(AtkUnitBase* addon) @@ -31,16 +31,20 @@ public unsafe class AddonSaddleBagWindow : InventoryAddonBase WindowNode?.AddColor = _tintColor; - CategoriesNode = new WrappingGridNode + ScrollableCategories = new ScrollingAreaNode> { Position = ContentStartPosition, Size = ContentSize, - HorizontalSpacing = CategorySpacing, - VerticalSpacing = CategorySpacing, - TopPadding = 4.0f, - BottomPadding = 4.0f, + ContentHeight = 0f, + AutoHideScrollBar = true, }; - CategoriesNode.AttachNode(this); + ScrollableCategories.AttachNode(this); + + CategoriesNode = ScrollableCategories.ContentNode; + CategoriesNode.HorizontalSpacing = CategorySpacing; + CategoriesNode.VerticalSpacing = CategorySpacing; + CategoriesNode.TopPadding = 4.0f; + CategoriesNode.BottomPadding = 4.0f; var header = CalculateHeaderLayout(addon); diff --git a/AetherBags/Addons/InventoryAddonBase.cs b/AetherBags/Addons/InventoryAddonBase.cs index 7296f7a..49b971b 100644 --- a/AetherBags/Addons/InventoryAddonBase.cs +++ b/AetherBags/Addons/InventoryAddonBase.cs @@ -29,6 +29,7 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow protected readonly HashSet HoverSubscribed = new(); protected DragDropNode BackgroundDropTarget = null!; + protected ScrollingAreaNode> ScrollableCategories = null!; protected WrappingGridNode CategoriesNode = null!; protected TextInputWithButtonNode SearchInputNode = null!; protected InventoryFooterNode FooterNode = null!; @@ -37,6 +38,18 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow internal ContextMenu ContextMenu = null!; + protected readonly SharedNodePool SharedItemNodePool = new( + maxSize: 256, + factory: null, + resetAction: node => node.ResetForReuse()); + + protected readonly SharedNodePool SharedCategoryNodePool = new( + maxSize: 32, + factory: null, + resetAction: node => node.ResetForReuse()); + + protected readonly VirtualizationState CategoryVirtualization = new() { BufferSize = 200f }; + protected virtual float MinWindowWidth => 600; protected virtual float MaxWindowWidth => 800; protected virtual float MinWindowHeight => 200; @@ -47,14 +60,16 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow protected const float ItemPadding = 5; protected const float FooterHeight = 28f; protected const float FooterTopSpacing = 4f; - protected const float SettingsButtonOffset = 48f; + protected const float SettingsButtonOffset = 62f; + protected const float ScrollBarWidth = 16f; + protected const float ContentHeightOffset = 4f; protected bool RefreshQueued; protected bool RefreshAutosizeQueued; protected bool IsSetupComplete; private bool _deferredPopulationInProgress; private bool _initialPopulationComplete; - private const int ItemsPerFrame = 70; + private const int ItemsPerFrame = 50; protected abstract InventoryStateBase InventoryState { get; } @@ -64,6 +79,7 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow private readonly HashSet _searchMatchScratch = new(); private bool _isRefreshing; + private string _lastSearchText = string.Empty; private int _requestedUpdateCount; private int _refreshFromLifecycleCount; @@ -75,6 +91,13 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow public InventoryStats GetStats() => InventoryState.GetStats(); + public IReadOnlyList? GetVisibleCategories() + { + if (!IsSetupComplete) return null; + string filter = GetSearchText(); + return InventoryState.GetCategories(filter); + } + public virtual void SetSearchText(string searchText) { Services.Framework.RunOnTick(() => @@ -112,6 +135,12 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow string searchText = SearchInputNode.SearchString.ExtractText(); bool isSearching = !string.IsNullOrWhiteSpace(searchText); + if (searchText != _lastSearchText) + { + _lastSearchText = searchText; + System.AetherBagsAPI?.API.RaiseSearchChanged(searchText); + } + if (config.SearchMode == SearchMode.Highlight && isSearching) { _searchMatchScratch.Clear(); @@ -168,7 +197,9 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow node.SetCategoryData(data, Math.Min(data.Items.Count, maxItemsPerLine), deferItemCreation: deferItems); if (!deferItems) node.RefreshNodeVisuals(); }, - createNodeMethod: _ => CreateCategoryNode()); + createNodeMethod: _ => CreateCategoryNode(), + resetNodeForReuse: ResetCategoryNodeForReuse, + externalPool: SharedCategoryNodePool); if (HasPinning) { @@ -178,6 +209,8 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow WireHoverHandlers(); + CategoriesNode.InvalidateLayout(); + if (autosize) AutoSizeWindow(); else @@ -194,6 +227,8 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow { _initialPopulationComplete = true; } + + System.AetherBagsAPI?.API.RaiseCategoriesRefreshed(); } private void StartDeferredItemPopulation() @@ -210,13 +245,43 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow return; } + UpdateCategoryVisibility(); + int itemsPopulated = 0; using (CategoriesNode.DeferRecalculateLayout()) { - foreach (var node in CategoriesNode.Nodes) + var nodes = CategoriesNode.Nodes; + for (int i = 0; i < nodes.Count; i++) { - if (node is InventoryCategoryNode categoryNode && categoryNode.NeedsItemPopulation) + if (nodes[i] is not InventoryCategoryNode categoryNode || !categoryNode.NeedsItemPopulation) + continue; + + if (!CategoryVirtualization.IsVisible(i)) + continue; + + int categoryItemCount = categoryNode.CategorizedInventory.Items.Count; + + if (itemsPopulated > 0 && itemsPopulated + categoryItemCount > ItemsPerFrame) + break; + + categoryNode.PopulateItems(); + categoryNode.RefreshNodeVisuals(); + itemsPopulated += categoryItemCount; + + if (itemsPopulated >= ItemsPerFrame) + break; + } + + if (itemsPopulated < ItemsPerFrame) + { + for (int i = 0; i < nodes.Count; i++) { + if (nodes[i] is not InventoryCategoryNode categoryNode || !categoryNode.NeedsItemPopulation) + continue; + + if (CategoryVirtualization.IsVisible(i)) + continue; + int categoryItemCount = categoryNode.CategorizedInventory.Items.Count; if (itemsPopulated > 0 && itemsPopulated + categoryItemCount > ItemsPerFrame) @@ -266,11 +331,38 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow var header = addon->WindowHeaderCollisionNode; float headerW = header->Width; - float settingsX = headerW - 62f; float itemY = header->Y + (header->Height - 28f) * 0.5f; - float searchWidth = headerW * 0.45f; - float searchX = (headerW - searchWidth) * 0.5f; + // Reserve space for close button (~50px) and settings button (~48px + gap) + const float closeButtonReserve = 50f; + const float settingsButtonWidth = 28f; + const float minGap = 16f; + const float minSearchWidth = 150f; + const float maxSearchWidth = 350f; + + // Calculate max available width for search bar + // Layout from right: [closeButton 50px] [settings 28px] [gap 16px] [searchBar] [gap 16px] [leftContent] + float rightReserve = closeButtonReserve + settingsButtonWidth + minGap; + float leftReserve = 220f; // Space for title (e.g. "Chocobo Saddlebag" is ~200px) + float availableForSearch = headerW - rightReserve - leftReserve; + + // Search bar width: prefer 45% of header, but clamp to available space and min/max + float desiredSearchWidth = headerW * 0.45f; + float searchWidth = Math.Clamp(desiredSearchWidth, minSearchWidth, Math.Min(maxSearchWidth, availableForSearch)); + + // Center the search bar, but ensure it doesn't extend past the safe right boundary + float maxSearchRight = headerW - rightReserve; + float centeredSearchX = (headerW - searchWidth) * 0.5f; + float searchRight = centeredSearchX + searchWidth; + + // If centered position would overlap with right elements, shift left + float searchX = searchRight > maxSearchRight + ? maxSearchRight - searchWidth + : centeredSearchX; + + // Ensure search bar doesn't go past left reserve + if (searchX < leftReserve) + searchX = leftReserve; return new HeaderLayout { @@ -303,12 +395,25 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow protected virtual InventoryCategoryNode CreateCategoryNode() { - return new InventoryCategoryNode + var node = SharedCategoryNodePool.TryRent(); + if (node == null) { - Size = ContentSize with { Y = 120 }, - OnRefreshRequested = ManualRefresh, - OnDragEnd = () => InventoryOrchestrator.RefreshAll(updateMaps: true), - }; + node = new InventoryCategoryNode + { + Size = ContentSize with { Y = 120 }, + SharedItemPool = SharedItemNodePool, + }; + } + + node.OnRefreshRequested = ManualRefresh; + node.OnDragEnd = () => InventoryOrchestrator.RefreshAll(updateMaps: true); + node.SharedItemPool = SharedItemNodePool; + return node; + } + + private static void ResetCategoryNodeForReuse(InventoryCategoryNode node) + { + node.ResetForReuse(); } private void OnBackgroundPayloadAccepted(DragDropNode node, DragDropPayload acceptedPayload) @@ -385,17 +490,20 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow float gridH = contentSize.Y - (HasFooter ? FooterHeight + FooterTopSpacing : 0); if (gridH < 0) gridH = 0; - CategoriesNode.Position = contentPos; - CategoriesNode.Size = new Vector2(contentSize.X, gridH); + ScrollableCategories.Position = contentPos; + ScrollableCategories.Size = new Vector2(contentSize.X, gridH); - UpdateCategoryMaxWidths(contentSize.X); + float categoriesWidth = contentSize.X - ScrollBarWidth; + CategoriesNode.Width = categoriesWidth; + + UpdateCategoryMaxWidths(categoriesWidth); } private void UpdateCategoryMaxWidths(float maxWidth) { foreach (var node in CategoriesNode.Nodes) { - if (node is InventoryCategoryNode categoryNode && categoryNode.MaxWidth != maxWidth) + if (node is InventoryCategoryNodeBase categoryNode && categoryNode.MaxWidth != maxWidth) { categoryNode.MaxWidth = maxWidth; categoryNode.RecalculateSize(); @@ -412,7 +520,7 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow for (int i = 0; i < nodes.Count; i++) { - if (nodes[i] is not InventoryCategoryNode cat) + if (nodes[i] is not InventoryCategoryNodeBase cat) continue; childCount++; @@ -423,36 +531,68 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow if (childCount == 0) { ResizeWindow(MinWindowWidth, MinWindowHeight, recalcLayout: true); + UpdateScrollParameters(); return; } - float requiredWidth = maxChildWidth + (ContentStartPosition.X * 2); + float footerSpace = HasFooter || HasSlotCounter ? FooterHeight + FooterTopSpacing : 0; + + float requiredWidth = maxChildWidth + ScrollBarWidth + (ContentStartPosition.X * 2); float finalWidth = Math.Clamp(requiredWidth, MinWindowWidth, MaxWindowWidth); if (SettingsButtonNode != null) { - SettingsButtonNode.X = finalWidth - 62f; + SettingsButtonNode.X = finalWidth - SettingsButtonOffset; } float contentWidth = finalWidth - (ContentStartPosition.X * 2); + float categoriesWidth = contentWidth - ScrollBarWidth; - float footerSpace = HasFooter || HasSlotCounter ? FooterHeight + FooterTopSpacing : 0; - float gridBudget = Math.Max(0f, MaxWindowHeight - footerSpace); - - CategoriesNode.Position = ContentStartPosition; - CategoriesNode.Size = new Vector2(contentWidth, gridBudget); - - UpdateCategoryMaxWidths(contentWidth); - + CategoriesNode.Width = categoriesWidth; + UpdateCategoryMaxWidths(categoriesWidth); CategoriesNode.RecalculateLayout(); float requiredGridHeight = CategoriesNode.GetRequiredHeight(); - float requiredContentHeight = requiredGridHeight + footerSpace; - float requiredWindowHeight = requiredContentHeight + ContentStartPosition.Y + ContentStartPosition.X; + float requiredContentHeight = requiredGridHeight + footerSpace; + float requiredWindowHeight = requiredContentHeight + ContentStartPosition.Y + ContentStartPosition.X + ContentHeightOffset; float finalHeight = Math.Clamp(requiredWindowHeight, MinWindowHeight, MaxWindowHeight); ResizeWindow(finalWidth, finalHeight, recalcLayout: false); + + UpdateScrollParameters(); + } + + protected void UpdateScrollParameters() + { + if (ScrollableCategories == null) return; + + float requiredHeight = CategoriesNode.GetRequiredHeight(); + ScrollableCategories.ContentHeight = requiredHeight; + + CategoryVirtualization.ViewportHeight = ScrollableCategories.Size.Y; + UpdateCategoryVisibility(); + } + + private void OnScrollValueChanged(int scrollPosition) + { + CategoryVirtualization.ScrollPosition = scrollPosition; + } + + private void UpdateCategoryVisibility() + { + var nodes = CategoriesNode.Nodes; + CategoryVirtualization.SetItemCount(nodes.Count); + + for (int i = 0; i < nodes.Count; i++) + { + if (nodes[i] is InventoryCategoryNodeBase cat) + { + CategoryVirtualization.SetItemLayout(i, cat.Y, cat.Height); + } + } + + CategoryVirtualization.UpdateVisibility(); } protected void ResizeWindow(float width, float height, bool recalcLayout) @@ -464,10 +604,32 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow BackgroundDropTarget.Size = ContentSize; } + UpdateHeaderLayout(); LayoutContent(); if (recalcLayout) CategoriesNode.RecalculateLayout(); + + UpdateScrollParameters(); + } + + protected virtual void UpdateHeaderLayout() + { + AtkUnitBase* addon = this; + if (addon == null) return; + + var header = CalculateHeaderLayout(addon); + + if (SearchInputNode != null) + { + SearchInputNode.Position = header.SearchPosition; + SearchInputNode.Size = header.SearchSize; + } + + if (SettingsButtonNode != null) + { + SettingsButtonNode.Position = new Vector2(header.HeaderWidth - SettingsButtonOffset, header.HeaderY); + } } protected void ResizeWindow(float width, float height) @@ -507,6 +669,13 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow { ContextMenu = new ContextMenu(); + System.AetherBagsAPI?.API.RaiseInventoryOpened(); + + if (ScrollableCategories != null) + { + ScrollableCategories.ScrollBarNode.OnValueChanged = OnScrollValueChanged; + } + base.OnSetup(addon); } @@ -526,6 +695,8 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow protected override void OnFinalize(AtkUnitBase* addon) { + System.AetherBagsAPI?.API.RaiseInventoryClosed(); + ContextMenu?.Dispose(); HoverSubscribed.Clear(); RefreshQueued = false; @@ -533,6 +704,10 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow _deferredPopulationInProgress = false; _initialPopulationComplete = false; + SharedItemNodePool.Clear(); + SharedCategoryNodePool.Clear(); + CategoryVirtualization.ClearLayout(); + base.OnFinalize(addon); } } \ No newline at end of file diff --git a/AetherBags/Addons/ItemContextMenuHandler.cs b/AetherBags/Addons/ItemContextMenuHandler.cs new file mode 100644 index 0000000..f98ce17 --- /dev/null +++ b/AetherBags/Addons/ItemContextMenuHandler.cs @@ -0,0 +1,48 @@ +using AetherBags.Inventory.Items; +using AetherBags.IPC.ExternalCategorySystem; +using KamiToolKit.ContextMenu; + +namespace AetherBags.Addons; + +public static class ItemContextMenuHandler +{ + private static ContextMenu? _itemMenu; + + public static void Initialize() + { + _itemMenu = new ContextMenu(); + } + + public static void Dispose() + { + _itemMenu?.Dispose(); + _itemMenu = null; + } + + public static bool TryShowExternalMenu(ItemInfo item) + { + if (_itemMenu == null) return false; + if (!System.Config.General.UseUnifiedExternalCategories) return false; + + var entries = ExternalCategoryManager.GetContextMenuEntries(item.Item.ItemId); + if (entries == null || entries.Count == 0) return false; + + _itemMenu.Clear(); + + var context = new ContextMenuContext( + item.Item.ItemId, + (int)item.Item.Container, + item.Item.Slot + ); + + foreach (var entry in entries) + { + var capturedEntry = entry; + var capturedContext = context; + _itemMenu.AddItem(entry.Label, () => capturedEntry.OnClick(capturedContext)); + } + + _itemMenu.Open(); + return true; + } +} diff --git a/AetherBags/Configuration/GeneralSettings.cs b/AetherBags/Configuration/GeneralSettings.cs index a68e97c..b3d5e40 100644 --- a/AetherBags/Configuration/GeneralSettings.cs +++ b/AetherBags/Configuration/GeneralSettings.cs @@ -19,6 +19,7 @@ public class GeneralSettings public bool HideGameRetainer { get; set; } = false; public bool ShowCategoryItemCount { get; set; } = false; public bool LinkItemEnabled { get; set; } = false; + public bool UseUnifiedExternalCategories { get; set; } = false; } public enum InventoryStackMode : byte diff --git a/AetherBags/Currency/CurrencyState.cs b/AetherBags/Currency/CurrencyState.cs index 40242b0..338853d 100644 --- a/AetherBags/Currency/CurrencyState.cs +++ b/AetherBags/Currency/CurrencyState.cs @@ -2,7 +2,7 @@ using FFXIVClientStructs.FFXIV.Client.Game; using Lumina.Excel.Sheets; using System; using System.Collections.Generic; -using System.Linq; +using System.Runtime.InteropServices; namespace AetherBags.Currency; @@ -16,6 +16,7 @@ public static unsafe class CurrencyState private static readonly Dictionary CurrencyItemByCurrencyIdCache = new(capacity: 32); private static readonly Dictionary CurrencyStaticByItemIdCache = new(capacity: 64); + private static readonly List CurrencyInfoScratch = new(capacity: 8); private static uint? _cachedLimitedTomestoneItemId; private static uint? _cachedNonLimitedTomestoneItemId; @@ -29,6 +30,12 @@ public static unsafe class CurrencyState } public static IReadOnlyList GetCurrencyInfoList(uint[] currencyIds) + => GetCurrencyInfoListCore(currencyIds.AsSpan()); + + public static IReadOnlyList GetCurrencyInfoList(List currencyIds) + => GetCurrencyInfoListCore(CollectionsMarshal.AsSpan(currencyIds)); + + private static IReadOnlyList GetCurrencyInfoListCore(ReadOnlySpan currencyIds) { if (currencyIds.Length == 0) return Array.Empty(); @@ -37,7 +44,7 @@ public static unsafe class CurrencyState if (inventoryManager == null) return Array.Empty(); - List currencyInfoList = new List(currencyIds.Length); + CurrencyInfoScratch.Clear(); for (int i = 0; i < currencyIds.Length; i++) { @@ -57,7 +64,7 @@ public static unsafe class CurrencyState isCapped = weeklyAcquired >= weeklyLimit; } - currencyInfoList.Add(new CurrencyInfo + CurrencyInfoScratch.Add(new CurrencyInfo { Amount = amount, MaxAmount = staticInfo.MaxAmount, @@ -68,7 +75,7 @@ public static unsafe class CurrencyState }); } - return currencyInfoList; + return CurrencyInfoScratch; } public static (uint Limited, uint NonLimited) GetCurrentTomestoneIds() diff --git a/AetherBags/IPC/AetherBagsAPI/AetherBagsAPIImpl.cs b/AetherBags/IPC/AetherBagsAPI/AetherBagsAPIImpl.cs new file mode 100644 index 0000000..0c840a8 --- /dev/null +++ b/AetherBags/IPC/AetherBagsAPI/AetherBagsAPIImpl.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AetherBags.IPC.ExternalCategorySystem; + +namespace AetherBags.IPC.AetherBagsAPI; + +public class AetherBagsAPIImpl : IAetherBagsAPI +{ + public event Action? OnItemHovered; + public event Action? OnItemUnhovered; + public event Action? OnItemClicked; + public event Action? OnSearchChanged; + public event Action? OnInventoryOpened; + public event Action? OnInventoryClosed; + public event Action? OnCategoriesRefreshed; + + public bool IsInventoryOpen => System.AddonInventoryWindow?.IsOpen ?? false; + + public IReadOnlyList GetVisibleItemIds() + { + var window = System.AddonInventoryWindow; + if (window == null || !window.IsOpen) return Array.Empty(); + + var categories = window.GetVisibleCategories(); + if (categories == null) return Array.Empty(); + + var result = new List(); + foreach (var category in categories) + { + foreach (var item in category.Items) + { + result.Add(item.Item.ItemId); + } + } + return result; + } + + public IReadOnlyList GetItemsInCategory(uint categoryKey) + { + var window = System.AddonInventoryWindow; + if (window == null || !window.IsOpen) return Array.Empty(); + + var categories = window.GetVisibleCategories(); + if (categories == null) return Array.Empty(); + + var category = categories.FirstOrDefault(c => c.Key == categoryKey); + if (category.Items == null) return Array.Empty(); + + return category.Items.Select(i => i.Item.ItemId).ToList(); + } + + public bool IsItemVisible(uint itemId) + { + var window = System.AddonInventoryWindow; + if (window == null || !window.IsOpen) return false; + + var categories = window.GetVisibleCategories(); + if (categories == null) return false; + + foreach (var category in categories) + { + if (category.Items.Any(i => i.Item.ItemId == itemId)) + return true; + } + return false; + } + + public string GetCurrentSearchFilter() + { + return System.AddonInventoryWindow?.GetSearchText() ?? string.Empty; + } + + public void RegisterSource(IExternalItemSource source) + { + ExternalCategoryManager.RegisterSource(source); + } + + public void UnregisterSource(string sourceName) + { + ExternalCategoryManager.UnregisterSource(sourceName); + } + + public IReadOnlyList GetRegisteredSourceNames() + { + return ExternalCategoryManager.RegisteredSources.Select(s => s.SourceName).ToList(); + } + + public void RaiseItemHovered(uint itemId) => OnItemHovered?.Invoke(itemId); + public void RaiseItemUnhovered(uint itemId) => OnItemUnhovered?.Invoke(itemId); + public void RaiseItemClicked(uint itemId) => OnItemClicked?.Invoke(itemId); + public void RaiseSearchChanged(string search) => OnSearchChanged?.Invoke(search); + public void RaiseInventoryOpened() => OnInventoryOpened?.Invoke(); + public void RaiseInventoryClosed() => OnInventoryClosed?.Invoke(); + public void RaiseCategoriesRefreshed() => OnCategoriesRefreshed?.Invoke(); +} diff --git a/AetherBags/IPC/AetherBagsAPI/AetherBagsIPCProvider.cs b/AetherBags/IPC/AetherBagsAPI/AetherBagsIPCProvider.cs new file mode 100644 index 0000000..4b3dbbe --- /dev/null +++ b/AetherBags/IPC/AetherBagsAPI/AetherBagsIPCProvider.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using Dalamud.Plugin.Ipc; + +namespace AetherBags.IPC.AetherBagsAPI; + +public class AetherBagsIPCProvider : IDisposable +{ + private const string IpcPrefix = "AetherBags."; + + private readonly AetherBagsAPIImpl _api; + + private readonly ICallGateProvider _isInventoryOpen; + private readonly ICallGateProvider> _getVisibleItemIds; + private readonly ICallGateProvider> _getItemsInCategory; + private readonly ICallGateProvider _isItemVisible; + private readonly ICallGateProvider _getSearchFilter; + private readonly ICallGateProvider> _getRegisteredSources; + + private readonly ICallGateProvider _onItemHovered; + private readonly ICallGateProvider _onItemUnhovered; + private readonly ICallGateProvider _onItemClicked; + private readonly ICallGateProvider _onSearchChanged; + private readonly ICallGateProvider _onInventoryOpened; + private readonly ICallGateProvider _onInventoryClosed; + private readonly ICallGateProvider _onCategoriesRefreshed; + + public AetherBagsAPIImpl API => _api; + + public AetherBagsIPCProvider() + { + _api = new AetherBagsAPIImpl(); + + _isInventoryOpen = Services.PluginInterface.GetIpcProvider($"{IpcPrefix}IsInventoryOpen"); + _getVisibleItemIds = Services.PluginInterface.GetIpcProvider>($"{IpcPrefix}GetVisibleItemIds"); + _getItemsInCategory = Services.PluginInterface.GetIpcProvider>($"{IpcPrefix}GetItemsInCategory"); + _isItemVisible = Services.PluginInterface.GetIpcProvider($"{IpcPrefix}IsItemVisible"); + _getSearchFilter = Services.PluginInterface.GetIpcProvider($"{IpcPrefix}GetSearchFilter"); + _getRegisteredSources = Services.PluginInterface.GetIpcProvider>($"{IpcPrefix}GetRegisteredSources"); + + _onItemHovered = Services.PluginInterface.GetIpcProvider($"{IpcPrefix}OnItemHovered"); + _onItemUnhovered = Services.PluginInterface.GetIpcProvider($"{IpcPrefix}OnItemUnhovered"); + _onItemClicked = Services.PluginInterface.GetIpcProvider($"{IpcPrefix}OnItemClicked"); + _onSearchChanged = Services.PluginInterface.GetIpcProvider($"{IpcPrefix}OnSearchChanged"); + _onInventoryOpened = Services.PluginInterface.GetIpcProvider($"{IpcPrefix}OnInventoryOpened"); + _onInventoryClosed = Services.PluginInterface.GetIpcProvider($"{IpcPrefix}OnInventoryClosed"); + _onCategoriesRefreshed = Services.PluginInterface.GetIpcProvider($"{IpcPrefix}OnCategoriesRefreshed"); + + RegisterFunctions(); + SubscribeEvents(); + } + + private void RegisterFunctions() + { + _isInventoryOpen.RegisterFunc(() => _api.IsInventoryOpen); + _getVisibleItemIds.RegisterFunc(() => new List(_api.GetVisibleItemIds())); + _getItemsInCategory.RegisterFunc(key => new List(_api.GetItemsInCategory(key))); + _isItemVisible.RegisterFunc(itemId => _api.IsItemVisible(itemId)); + _getSearchFilter.RegisterFunc(() => _api.GetCurrentSearchFilter()); + _getRegisteredSources.RegisterFunc(() => new List(_api.GetRegisteredSourceNames())); + } + + private void SubscribeEvents() + { + _api.OnItemHovered += itemId => _onItemHovered.SendMessage(itemId); + _api.OnItemUnhovered += itemId => _onItemUnhovered.SendMessage(itemId); + _api.OnItemClicked += itemId => _onItemClicked.SendMessage(itemId); + _api.OnSearchChanged += search => _onSearchChanged.SendMessage(search); + _api.OnInventoryOpened += () => _onInventoryOpened.SendMessage(); + _api.OnInventoryClosed += () => _onInventoryClosed.SendMessage(); + _api.OnCategoriesRefreshed += () => _onCategoriesRefreshed.SendMessage(); + } + + public void Dispose() + { + _isInventoryOpen.UnregisterFunc(); + _getVisibleItemIds.UnregisterFunc(); + _getItemsInCategory.UnregisterFunc(); + _isItemVisible.UnregisterFunc(); + _getSearchFilter.UnregisterFunc(); + _getRegisteredSources.UnregisterFunc(); + } +} diff --git a/AetherBags/IPC/AetherBagsAPI/IAetherBagsAPI.cs b/AetherBags/IPC/AetherBagsAPI/IAetherBagsAPI.cs new file mode 100644 index 0000000..5d9e9ad --- /dev/null +++ b/AetherBags/IPC/AetherBagsAPI/IAetherBagsAPI.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using AetherBags.IPC.ExternalCategorySystem; + +namespace AetherBags.IPC.AetherBagsAPI; + +public interface IAetherBagsAPI +{ + IReadOnlyList GetVisibleItemIds(); + IReadOnlyList GetItemsInCategory(uint categoryKey); + bool IsItemVisible(uint itemId); + string GetCurrentSearchFilter(); + bool IsInventoryOpen { get; } + + event Action? OnItemHovered; + event Action? OnItemUnhovered; + event Action? OnItemClicked; + event Action? OnSearchChanged; + event Action? OnInventoryOpened; + event Action? OnInventoryClosed; + event Action? OnCategoriesRefreshed; + + void RegisterSource(IExternalItemSource source); + void UnregisterSource(string sourceName); + IReadOnlyList GetRegisteredSourceNames(); +} diff --git a/AetherBags/IPC/AllaganToolsIPC.cs b/AetherBags/IPC/AllaganToolsIPC.cs index c534b99..9937259 100644 --- a/AetherBags/IPC/AllaganToolsIPC.cs +++ b/AetherBags/IPC/AllaganToolsIPC.cs @@ -1,8 +1,12 @@ using System; using System.Collections.Generic; +using System.Numerics; using AetherBags.Inventory; +using AetherBags.Inventory.Categories; using AetherBags.Inventory.Context; +using AetherBags.IPC.ExternalCategorySystem; using Dalamud.Plugin.Ipc; +using KamiToolKit.Classes; namespace AetherBags.IPC; @@ -188,8 +192,119 @@ public class AllaganToolsIPC : IDisposable InventoryOrchestrator.RefreshHighlights(); } + private AllaganToolsSource? _source; + + public void EnableExternalCategorySupport() + { + if (_source != null) return; + + _source = new AllaganToolsSource(this); + ExternalCategoryManager.RegisterSource(_source); + } + + public void DisableExternalCategorySupport() + { + if (_source == null) return; + + ExternalCategoryManager.UnregisterSource(_source.SourceName); + _source = null; + } + public void Dispose() { + DisableExternalCategorySupport(); _initialized?.Unsubscribe(OnAllaganInitialized); } + + private sealed class AllaganToolsSource : IExternalItemSource + { + private readonly AllaganToolsIPC _ipc; + private int _version; + + public string SourceName => "AllaganTools"; + public string DisplayName => "Allagan Tools"; + public int Priority => 50; + public bool IsReady => _ipc.IsReady; + public int Version => _version; + public event Action? OnDataChanged; + + public SourceCapabilities Capabilities => + SourceCapabilities.Categories | + SourceCapabilities.SearchTags; + + public ConflictBehavior ConflictBehavior => ConflictBehavior.Defer; + + public AllaganToolsSource(AllaganToolsIPC ipc) + { + _ipc = ipc; + _ipc.OnFiltersRefreshed += OnIpcRefreshed; + } + + private void OnIpcRefreshed() + { + _version++; + OnDataChanged?.Invoke(); + } + + public IReadOnlyDictionary? GetCategoryAssignments() + { + if (_ipc.CachedFilterItems.Count == 0) return null; + + var result = new Dictionary(); + int filterIndex = 0; + + foreach (var (filterKey, filterName) in _ipc.CachedSearchFilters) + { + if (!_ipc.CachedFilterItems.TryGetValue(filterKey, out var itemIds)) + { + filterIndex++; + continue; + } + + uint categoryKey = CategoryBucketManager.MakeAllaganFilterKey(filterIndex); + + foreach (var itemId in itemIds.Keys) + { + result.TryAdd(itemId, new ExternalCategoryAssignment( + CategoryKey: categoryKey, + CategoryName: $"[AT] {filterName}", + CategoryDescription: $"Allagan Tools filter: {filterName}", + CategoryColor: ColorHelper.GetColor(32), + ItemOverlayColor: null, + SubPriority: filterIndex + )); + } + + filterIndex++; + } + + return result; + } + + public IReadOnlyDictionary? GetItemDecorations() => null; + + public IReadOnlyList? GetContextMenuEntries(uint itemId) => null; + + public IReadOnlyDictionary? GetSearchTags() + { + if (_ipc.ItemToFilters.Count == 0) return null; + + var result = new Dictionary(); + foreach (var (itemId, filterKeys) in _ipc.ItemToFilters) + { + var tags = new List(filterKeys.Count + 1) { "at", "allagantools" }; + foreach (var key in filterKeys) + { + if (_ipc.CachedSearchFilters.TryGetValue(key, out var name)) + { + tags.Add(name.ToLowerInvariant()); + } + } + result[itemId] = tags.ToArray(); + } + return result; + } + + public IReadOnlyList? GetItemRelationships(uint itemId) => null; + } } \ No newline at end of file diff --git a/AetherBags/IPC/BisBuddyIPC.cs b/AetherBags/IPC/BisBuddyIPC.cs index 4d5f5a1..6ec8cb6 100644 --- a/AetherBags/IPC/BisBuddyIPC.cs +++ b/AetherBags/IPC/BisBuddyIPC.cs @@ -2,8 +2,11 @@ using System; using System.Collections.Generic; using System.Numerics; using AetherBags.Inventory; +using AetherBags.Inventory.Categories; using AetherBags.Inventory.Context; +using AetherBags.IPC.ExternalCategorySystem; using Dalamud.Plugin.Ipc; +using KamiToolKit.Classes; namespace AetherBags.IPC; @@ -190,9 +193,157 @@ public class BisBuddyIPC : IDisposable public Vector4? GetItemColor(uint itemId) => GetBisItem(itemId)?.Color; + private BisBuddySource? _source; + + public void EnableExternalCategorySupport() + { + if (_source != null) return; + + _source = new BisBuddySource(this); + ExternalCategoryManager.RegisterSource(_source); + } + + public void DisableExternalCategorySupport() + { + if (_source == null) return; + + ExternalCategoryManager.UnregisterSource(_source.SourceName); + _source = null; + } + public void Dispose() { + DisableExternalCategorySupport(); _initialized?.Unsubscribe(OnBisBuddyInitialized); _inventoryHighlightItemsChanged?.Unsubscribe(OnInventoryHighlightItemsChanged); } + + private sealed class BisBuddySource : IExternalItemSource + { + private readonly BisBuddyIPC _ipc; + private int _version; + + public string SourceName => "BisBuddy"; + public string DisplayName => "Best in Slot"; + public int Priority => 100; + public bool IsReady => _ipc.IsReady; + public int Version => _version; + public event Action? OnDataChanged; + + public SourceCapabilities Capabilities => + SourceCapabilities.Categories | + SourceCapabilities.ItemColors | + SourceCapabilities.SearchTags | + SourceCapabilities.Relationships; + + public ConflictBehavior ConflictBehavior => ConflictBehavior.Replace; + + public BisBuddySource(BisBuddyIPC ipc) + { + _ipc = ipc; + _ipc.OnItemsRefreshed += OnIpcRefreshed; + } + + private void OnIpcRefreshed() + { + _version++; + OnDataChanged?.Invoke(); + } + + public IReadOnlyDictionary? GetCategoryAssignments() + { + var items = _ipc.ItemLookup; + if (items.Count == 0) return null; + + var result = new Dictionary(); + + var colorGroups = new Dictionary>(); + foreach (var (itemId, entry) in items) + { + if (!colorGroups.TryGetValue(entry.Color, out var list)) + { + list = new List<(uint, BisItemEntry)>(); + colorGroups[entry.Color] = list; + } + list.Add((itemId, entry)); + } + + uint subKey = 0; + foreach (var (color, groupItems) in colorGroups) + { + uint categoryKey = CategoryBucketManager.MakeBisBuddyKey() | subKey++; + + foreach (var (itemId, entry) in groupItems) + { + result[itemId] = new ExternalCategoryAssignment( + CategoryKey: categoryKey, + CategoryName: "[BiS] Gearset", + CategoryDescription: "Items needed for Best in Slot", + CategoryColor: color, + ItemOverlayColor: new Vector3(color.X, color.Y, color.Z), + SubPriority: 0 + ); + } + } + + return result; + } + + public IReadOnlyDictionary? GetItemDecorations() + { + var items = _ipc.ItemLookup; + if (items.Count == 0) return null; + + var result = new Dictionary(); + foreach (var (itemId, entry) in items) + { + result[itemId] = new ItemDecoration + { + OverlayColor = new Vector3(entry.Color.X, entry.Color.Y, entry.Color.Z), + }; + } + return result; + } + + public IReadOnlyList? GetContextMenuEntries(uint itemId) => null; + + public IReadOnlyDictionary? GetSearchTags() + { + var items = _ipc.ItemLookup; + if (items.Count == 0) return null; + + var result = new Dictionary(); + foreach (var itemId in items.Keys) + { + result[itemId] = new[] { "bis", "bestinslot", "gearset" }; + } + return result; + } + + public IReadOnlyList? GetItemRelationships(uint itemId) + { + if (!_ipc.ItemLookup.TryGetValue(itemId, out var entry)) return null; + + var sameSetItems = new List(); + foreach (var (otherId, otherEntry) in _ipc.ItemLookup) + { + if (otherId != itemId && otherEntry.Color == entry.Color) + { + sameSetItems.Add(otherId); + } + } + + if (sameSetItems.Count == 0) return null; + + return new[] + { + new ItemRelationship( + Type: RelationshipType.SameSet, + RelatedItemIds: sameSetItems.ToArray(), + GroupLabel: "Same Gearset", + HighlightColor: new Vector3(entry.Color.X, entry.Color.Y, entry.Color.Z) + ) + }; + } + } } \ No newline at end of file diff --git a/AetherBags/IPC/ExternalCategorySystem/ExternalCategoryManager.cs b/AetherBags/IPC/ExternalCategorySystem/ExternalCategoryManager.cs new file mode 100644 index 0000000..00d9ac2 --- /dev/null +++ b/AetherBags/IPC/ExternalCategorySystem/ExternalCategoryManager.cs @@ -0,0 +1,297 @@ +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using System.Runtime.InteropServices; +using AetherBags.Inventory.Categories; +using AetherBags.Inventory.Items; + +namespace AetherBags.IPC.ExternalCategorySystem; + +public static class ExternalCategoryManager +{ + private static readonly List Sources = new(); + private static readonly Dictionary CategoryCache = new(); + private static readonly Dictionary DecorationCache = new(); + private static readonly Dictionary> SearchTagCache = new(); + private static int _lastCombinedVersion; + + public static IReadOnlyList RegisteredSources => Sources; + + public static void RegisterSource(IExternalItemSource source) + { + if (Sources.Any(s => s.SourceName == source.SourceName)) + return; + + Sources.Add(source); + Sources.Sort((a, b) => b.Priority.CompareTo(a.Priority)); + source.OnDataChanged += InvalidateCache; + InvalidateCache(); + + Services.Logger.Information($"Registered external category source: {source.SourceName}"); + } + + public static void UnregisterSource(string sourceName) + { + var source = Sources.FirstOrDefault(s => s.SourceName == sourceName); + if (source == null) return; + + source.OnDataChanged -= InvalidateCache; + Sources.Remove(source); + InvalidateCache(); + + Services.Logger.Information($"Unregistered external category source: {sourceName}"); + } + + public static void InvalidateCache() + { + _lastCombinedVersion = -1; + CategoryCache.Clear(); + DecorationCache.Clear(); + SearchTagCache.Clear(); + } + + private static int ComputeCombinedVersion() + { + int version = 0; + foreach (var source in Sources) + version = unchecked(version * 31 + source.Version); + return version; + } + + public static void RebuildCacheIfNeeded() + { + int currentVersion = ComputeCombinedVersion(); + if (currentVersion == _lastCombinedVersion && CategoryCache.Count > 0) + return; + + _lastCombinedVersion = currentVersion; + CategoryCache.Clear(); + DecorationCache.Clear(); + SearchTagCache.Clear(); + + foreach (var source in Sources) + { + if (!source.IsReady) continue; + + if (source.Capabilities.HasFlag(SourceCapabilities.Categories)) + { + var categories = source.GetCategoryAssignments(); + if (categories != null) + { + foreach (var (itemId, assignment) in categories) + { + CategoryCache.TryAdd(itemId, assignment); + } + } + } + + if (source.Capabilities.HasFlag(SourceCapabilities.ItemColors) || + source.Capabilities.HasFlag(SourceCapabilities.Badges)) + { + var decorations = source.GetItemDecorations(); + if (decorations != null) + { + foreach (var (itemId, decoration) in decorations) + { + if (DecorationCache.TryGetValue(itemId, out var existing)) + { + DecorationCache[itemId] = MergeDecorations(existing, decoration, source.ConflictBehavior); + } + else + { + DecorationCache[itemId] = decoration; + } + } + } + } + + if (source.Capabilities.HasFlag(SourceCapabilities.SearchTags)) + { + var searchTags = source.GetSearchTags(); + if (searchTags != null) + { + foreach (var (itemId, tags) in searchTags) + { + if (!SearchTagCache.TryGetValue(itemId, out var existingTags)) + { + existingTags = new List(tags.Length); + SearchTagCache[itemId] = existingTags; + } + existingTags.AddRange(tags); + } + } + } + } + } + + private static ItemDecoration MergeDecorations(ItemDecoration existing, ItemDecoration incoming, ConflictBehavior behavior) + { + return behavior switch + { + ConflictBehavior.Replace => incoming, + ConflictBehavior.Defer => existing, + ConflictBehavior.Merge => new ItemDecoration + { + OverlayColor = incoming.OverlayColor ?? existing.OverlayColor, + Opacity = incoming.Opacity ?? existing.Opacity, + Badge = incoming.Badge ?? existing.Badge, + Border = incoming.Border != BorderStyle.None ? incoming.Border : existing.Border, + TooltipLine = CombineTooltips(existing.TooltipLine, incoming.TooltipLine), + }, + _ => incoming + }; + } + + private static string? CombineTooltips(string? a, string? b) + { + if (string.IsNullOrEmpty(a)) return b; + if (string.IsNullOrEmpty(b)) return a; + return $"{a}\n{b}"; + } + + public static void BucketItems( + Dictionary itemInfoByKey, + Dictionary bucketsByKey, + HashSet claimedKeys) + { + RebuildCacheIfNeeded(); + + if (CategoryCache.Count == 0) return; + + foreach (var (itemKey, item) in itemInfoByKey) + { + if (claimedKeys.Contains(itemKey)) continue; + + if (!CategoryCache.TryGetValue(item.Item.ItemId, out var assignment)) + continue; + + ref var bucketRef = ref CollectionsMarshal.GetValueRefOrAddDefault(bucketsByKey, assignment.CategoryKey, out bool exists); + + if (!exists) + { + bucketRef = new CategoryBucket + { + Key = assignment.CategoryKey, + Category = new CategoryInfo + { + Name = assignment.CategoryName, + Description = assignment.CategoryDescription ?? string.Empty, + Color = assignment.CategoryColor, + }, + Items = new List(16), + FilteredItems = new List(16), + Used = true, + }; + } + else + { + bucketRef!.Used = true; + bucketRef.Category.Name = assignment.CategoryName; + bucketRef.Category.Description = assignment.CategoryDescription ?? string.Empty; + bucketRef.Category.Color = assignment.CategoryColor; + } + + bucketRef!.Items.Add(item); + claimedKeys.Add(itemKey); + } + } + + public static ItemDecoration? GetDecoration(uint itemId) + { + RebuildCacheIfNeeded(); + return DecorationCache.TryGetValue(itemId, out var dec) ? dec : null; + } + + public static Vector3? GetItemOverlayColor(uint itemId) + { + if (CategoryCache.TryGetValue(itemId, out var assignment)) + return assignment.ItemOverlayColor; + + if (DecorationCache.TryGetValue(itemId, out var decoration)) + return decoration.OverlayColor; + + return null; + } + + public static List? GetContextMenuEntries(uint itemId) + { + List? result = null; + + foreach (var source in Sources) + { + if (!source.IsReady) continue; + if (!source.Capabilities.HasFlag(SourceCapabilities.ContextMenu)) continue; + + var entries = source.GetContextMenuEntries(itemId); + if (entries == null || entries.Count == 0) continue; + + foreach (var entry in entries) + { + if (entry.IsVisible != null && !entry.IsVisible(itemId)) continue; + + result ??= new List(4); + result.Add(entry); + } + } + + result?.Sort((a, b) => a.Order.CompareTo(b.Order)); + return result; + } + + public static IReadOnlyList? GetSearchTags(uint itemId) + { + RebuildCacheIfNeeded(); + return SearchTagCache.TryGetValue(itemId, out var tags) ? tags : null; + } + + public static bool MatchesSearchTag(uint itemId, string searchText) + { + RebuildCacheIfNeeded(); + if (!SearchTagCache.TryGetValue(itemId, out var tags)) return false; + + foreach (var tag in tags) + { + if (tag.Contains(searchText, global::System.StringComparison.OrdinalIgnoreCase)) + return true; + } + return false; + } + + public static List? GetItemRelationships(uint itemId) + { + List? result = null; + + foreach (var source in Sources) + { + if (!source.IsReady) continue; + if (!source.Capabilities.HasFlag(SourceCapabilities.Relationships)) continue; + + var relationships = source.GetItemRelationships(itemId); + if (relationships == null || relationships.Count == 0) continue; + + result ??= new List(4); + result.AddRange(relationships); + } + + return result; + } + + public static HashSet? GetRelatedItemIds(uint itemId, RelationshipType? filterType = null) + { + var relationships = GetItemRelationships(itemId); + if (relationships == null || relationships.Count == 0) return null; + + var result = new HashSet(); + foreach (var rel in relationships) + { + if (filterType.HasValue && rel.Type != filterType.Value) continue; + + foreach (var relatedId in rel.RelatedItemIds) + { + result.Add(relatedId); + } + } + + return result.Count > 0 ? result : null; + } +} diff --git a/AetherBags/IPC/ExternalCategorySystem/IExternalItemSource.cs b/AetherBags/IPC/ExternalCategorySystem/IExternalItemSource.cs new file mode 100644 index 0000000..5c509b6 --- /dev/null +++ b/AetherBags/IPC/ExternalCategorySystem/IExternalItemSource.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; +using System.Numerics; + +namespace AetherBags.IPC.ExternalCategorySystem; + +public interface IExternalItemSource +{ + string SourceName { get; } + string DisplayName { get; } + int Priority { get; } + bool IsReady { get; } + + int Version { get; } + event Action? OnDataChanged; + + SourceCapabilities Capabilities { get; } + ConflictBehavior ConflictBehavior { get; } + + IReadOnlyDictionary? GetCategoryAssignments(); + IReadOnlyDictionary? GetItemDecorations(); + IReadOnlyList? GetContextMenuEntries(uint itemId); + IReadOnlyDictionary? GetSearchTags(); + IReadOnlyList? GetItemRelationships(uint itemId); +} + +[Flags] +public enum SourceCapabilities +{ + None = 0, + Categories = 1, + ItemColors = 2, + Badges = 4, + ContextMenu = 8, + SearchTags = 16, + Relationships = 32, + Tooltips = 64 +} + +public enum ConflictBehavior +{ + Replace, + Merge, + Defer +} + +public readonly record struct ExternalCategoryAssignment( + uint CategoryKey, + string CategoryName, + string? CategoryDescription, + Vector4 CategoryColor, + Vector3? ItemOverlayColor, + int SubPriority +); + +public record struct ItemDecoration +{ + public Vector3? OverlayColor { get; init; } + public float? Opacity { get; init; } + public BadgeInfo? Badge { get; init; } + public BorderStyle Border { get; init; } + public string? TooltipLine { get; init; } +} + +public record struct BadgeInfo( + uint IconId, + BadgePosition Position, + Vector4? TintColor +); + +public enum BadgePosition { TopLeft, TopRight, BottomLeft, BottomRight } +public enum BorderStyle { None, Solid, Glow, Pulse } + +public record struct ContextMenuEntry( + string Label, + uint? IconId, + Action OnClick, + int Order, + Func? IsVisible = null +); + +public record struct ContextMenuContext( + uint ItemId, + int Container, + int Slot +); + +public record struct ItemRelationship( + RelationshipType Type, + uint[] RelatedItemIds, + string? GroupLabel, + Vector3? HighlightColor +); + +public enum RelationshipType +{ + SameSet, + Upgrades, + UpgradedFrom, + CraftedFrom, + CraftsInto, + Alternative +} diff --git a/AetherBags/IPC/IPCService.cs b/AetherBags/IPC/IPCService.cs index a35d3a6..47cb258 100644 --- a/AetherBags/IPC/IPCService.cs +++ b/AetherBags/IPC/IPCService.cs @@ -1,4 +1,5 @@ using System; +using AetherBags.Configuration; namespace AetherBags.IPC; @@ -8,6 +9,42 @@ public class IPCService : IDisposable public WotsItIPC WotsIt { get; } = new(); public BisBuddyIPC BisBuddy { get; } = new(); + private bool _unifiedEnabled; + + public void UpdateUnifiedCategorySupport(bool enabled) + { + _unifiedEnabled = enabled; + RefreshExternalSources(); + } + + public void RefreshExternalSources() + { + var config = System.Config?.Categories; + if (config == null) return; + + bool categoriesEnabled = config.CategoriesEnabled; + + bool allaganShouldBeActive = _unifiedEnabled && + categoriesEnabled && + config.AllaganToolsCategoriesEnabled && + config.AllaganToolsFilterMode == PluginFilterMode.Categorize; + + if (allaganShouldBeActive) + AllaganTools.EnableExternalCategorySupport(); + else + AllaganTools.DisableExternalCategorySupport(); + + bool bisBuddyShouldBeActive = _unifiedEnabled && + categoriesEnabled && + config.BisBuddyEnabled && + config.BisBuddyMode == PluginFilterMode.Categorize; + + if (bisBuddyShouldBeActive) + BisBuddy.EnableExternalCategorySupport(); + else + BisBuddy.DisableExternalCategorySupport(); + } + public void Dispose() { AllaganTools.Dispose(); diff --git a/AetherBags/Inventory/Categories/CategoryBucketManager.cs b/AetherBags/Inventory/Categories/CategoryBucketManager.cs index 227593b..a28202f 100644 --- a/AetherBags/Inventory/Categories/CategoryBucketManager.cs +++ b/AetherBags/Inventory/Categories/CategoryBucketManager.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using AetherBags.Configuration; using AetherBags.Inventory.Items; using KamiToolKit.Classes; @@ -61,32 +63,24 @@ public static class CategoryBucketManager { sortedScratch.Clear(); sortedScratch.AddRange(userCategories); - sortedScratch.Sort((left, right) => - { - int priority = left.Priority.CompareTo(right.Priority); - if (priority != 0) return priority; + sortedScratch.Sort(UserCategoryComparer.Instance); - int order = left.Order.CompareTo(right.Order); - if (order != 0) return order; - - return string.Compare(left.Id, right.Id, StringComparison.OrdinalIgnoreCase); - }); + var activeBuckets = new (uint key, CategoryBucket bucket, UserCategoryDefinition def)[sortedScratch.Count]; + int activeCount = 0; for (int i = 0; i < sortedScratch.Count; i++) { UserCategoryDefinition category = sortedScratch[i]; - if (!category.Enabled) - continue; - - if (UserCategoryMatcher.IsCatchAll(category)) + if (!category.Enabled || UserCategoryMatcher.IsCatchAll(category)) continue; uint bucketKey = MakeUserCategoryKey(category.Order); + ref var bucketRef = ref CollectionsMarshal.GetValueRefOrAddDefault(bucketsByKey, bucketKey, out bool exists); - if (!bucketsByKey.TryGetValue(bucketKey, out CategoryBucket? bucket)) + if (!exists) { - bucket = new CategoryBucket + bucketRef = new CategoryBucket { Key = bucketKey, Category = new CategoryInfo @@ -100,34 +94,63 @@ public static class CategoryBucketManager FilteredItems = new List(capacity: 16), Used = true, }; - bucketsByKey.Add(bucketKey, bucket); } else { - bucket.Used = true; - bucket.Category.Name = category.Name; - bucket.Category.Description = category.Description; - bucket.Category.Color = category.Color; - bucket.Category.IsPinned = category.Pinned; + bucketRef!.Used = true; + bucketRef.Category.Name = category.Name; + bucketRef.Category.Description = category.Description; + bucketRef.Category.Color = category.Color; + bucketRef.Category.IsPinned = category.Pinned; } - foreach (var itemKvp in itemInfoByKey) + activeBuckets[activeCount++] = (bucketKey, bucketRef!, category); + } + + foreach (var itemKvp in itemInfoByKey) + { + ulong itemKey = itemKvp.Key; + if (claimedKeys.Contains(itemKey)) + continue; + + ItemInfo item = itemKvp.Value; + + for (int i = 0; i < activeCount; i++) { - ulong itemKey = itemKvp.Key; - ItemInfo item = itemKvp.Value; - - if (claimedKeys.Contains(itemKey)) - continue; - - if (UserCategoryMatcher.Matches(item, category)) + ref var entry = ref activeBuckets[i]; + if (UserCategoryMatcher.Matches(item, entry.def)) { - bucket.Items.Add(item); + entry.bucket.Items.Add(item); claimedKeys.Add(itemKey); + break; } } + } - if (bucket.Items.Count == 0) - bucket.Used = false; + for (int i = 0; i < activeCount; i++) + { + ref var entry = ref activeBuckets[i]; + if (entry.bucket.Items.Count == 0) + entry.bucket.Used = false; + } + } + + private sealed class UserCategoryComparer : IComparer + { + public static readonly UserCategoryComparer Instance = new(); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int Compare(UserCategoryDefinition? left, UserCategoryDefinition? right) + { + if (left is null || right is null) return 0; + + int priority = left.Priority.CompareTo(right.Priority); + if (priority != 0) return priority; + + int order = left.Order.CompareTo(right.Order); + if (order != 0) return order; + + return string.Compare(left.Id, right.Id, StringComparison.OrdinalIgnoreCase); } } @@ -147,9 +170,11 @@ public static class CategoryBucketManager uint categoryKey = info.UiCategory.RowId; - if (!bucketsByKey.TryGetValue(categoryKey, out CategoryBucket? bucket)) + ref var bucketRef = ref CollectionsMarshal.GetValueRefOrAddDefault(bucketsByKey, categoryKey, out bool exists); + + if (!exists) { - bucket = new CategoryBucket + bucketRef = new CategoryBucket { Key = categoryKey, Category = GetCategoryInfoCached(categoryKey, info), @@ -157,14 +182,13 @@ public static class CategoryBucketManager FilteredItems = new List(capacity: 16), Used = true, }; - bucketsByKey.Add(categoryKey, bucket); } else { - bucket.Used = true; + bucketRef!.Used = true; } - bucket.Items.Add(info); + bucketRef!.Items.Add(info); } } @@ -178,61 +202,76 @@ public static class CategoryBucketManager if (!System.IPC.AllaganTools.IsReady) return; var filters = System.IPC.AllaganTools.CachedSearchFilters; - var filterItems = System.IPC.AllaganTools.CachedFilterItems; + var itemToFilters = System.IPC.AllaganTools.ItemToFilters; + if (filters.Count == 0 || itemToFilters.Count == 0) return; + + var filterKeyToIndex = new Dictionary(filters.Count); int index = 0; + foreach (var filterKey in filters.Keys) + { + filterKeyToIndex[filterKey] = index++; + } + + index = 0; foreach (var (filterKey, filterName) in filters) { - if (!filterItems. TryGetValue(filterKey, out var itemIds)) - { - index++; - continue; - } - uint bucketKey = MakeAllaganFilterKey(index); + ref var bucketRef = ref CollectionsMarshal.GetValueRefOrAddDefault(bucketsByKey, bucketKey, out bool exists); - if (!bucketsByKey.TryGetValue(bucketKey, out CategoryBucket? bucket)) + if (!exists) { - bucket = new CategoryBucket + bucketRef = new CategoryBucket { Key = bucketKey, Category = new CategoryInfo { Name = $"[AT] {filterName}", - Description = $"Allagan Tools filter: {filterName}", + Description = $"Allagan Tools filter: {filterName}", Color = ColorHelper.GetColor(32), }, Items = new List(capacity: 16), FilteredItems = new List(capacity: 16), Used = true, }; - bucketsByKey. Add(bucketKey, bucket); } else { - bucket.Used = true; - bucket.Category.Name = $"[AT] {filterName}"; + bucketRef!.Used = true; + bucketRef.Category.Name = $"[AT] {filterName}"; } - foreach (var itemKvp in itemInfoByKey) + index++; + } + + foreach (var itemKvp in itemInfoByKey) + { + ulong itemKey = itemKvp.Key; + if (claimedKeys.Contains(itemKey)) + continue; + + ItemInfo item = itemKvp.Value; + + if (!itemToFilters.TryGetValue(item.Item.ItemId, out var filterKeys)) + continue; + + if (filterKeys.Count > 0 && filterKeyToIndex.TryGetValue(filterKeys[0], out int filterIndex)) { - ulong itemKey = itemKvp.Key; - ItemInfo item = itemKvp.Value; - - if (claimedKeys.Contains(itemKey)) - continue; - - if (itemIds.ContainsKey(item.Item.ItemId)) + uint bucketKey = MakeAllaganFilterKey(filterIndex); + if (bucketsByKey.TryGetValue(bucketKey, out var bucket)) { bucket.Items.Add(item); claimedKeys.Add(itemKey); } } + } - if (bucket.Items.Count == 0) + index = 0; + foreach (var _ in filters) + { + uint bucketKey = MakeAllaganFilterKey(index++); + if (bucketsByKey.TryGetValue(bucketKey, out var bucket) && bucket.Items.Count == 0) bucket.Used = false; - - index++; } } @@ -250,9 +289,11 @@ public static class CategoryBucketManager uint bucketKey = MakeBisBuddyKey(); - if (!bucketsByKey.TryGetValue(bucketKey, out CategoryBucket? bucket)) + ref var bucketRef = ref CollectionsMarshal.GetValueRefOrAddDefault(bucketsByKey, bucketKey, out bool exists); + + if (!exists) { - bucket = new CategoryBucket + bucketRef = new CategoryBucket { Key = bucketKey, Category = new CategoryInfo @@ -265,21 +306,22 @@ public static class CategoryBucketManager FilteredItems = new List(capacity: 16), Used = true, }; - bucketsByKey.Add(bucketKey, bucket); } else { - bucket.Used = true; + bucketRef!.Used = true; } + var bucket = bucketRef!; + foreach (var itemKvp in itemInfoByKey) { ulong itemKey = itemKvp.Key; - ItemInfo item = itemKvp.Value; - if (claimedKeys.Contains(itemKey)) continue; + ItemInfo item = itemKvp.Value; + if (bisItems.ContainsKey(item.Item.ItemId)) { bucket.Items.Add(item); diff --git a/AetherBags/Inventory/Categories/UserCategoryMatcher.cs b/AetherBags/Inventory/Categories/UserCategoryMatcher.cs index ea33e63..ea1db6b 100644 --- a/AetherBags/Inventory/Categories/UserCategoryMatcher.cs +++ b/AetherBags/Inventory/Categories/UserCategoryMatcher.cs @@ -1,4 +1,5 @@ using System; +using System.Runtime.CompilerServices; using AetherBags.Configuration; using AetherBags.Helpers; using AetherBags.Inventory.Items; @@ -7,51 +8,20 @@ namespace AetherBags.Inventory.Categories; internal static class UserCategoryMatcher { + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool Matches(ItemInfo item, UserCategoryDefinition userCategory) { var rules = userCategory.Rules; - bool hasIdentificationFilters = rules.AllowedItemIds.Count > 0 || rules.AllowedItemNamePatterns.Count > 0; - - if (hasIdentificationFilters) - { - bool matchesAnyIdentification = false; - - if (rules.AllowedItemIds.Count > 0 && rules.AllowedItemIds.Contains(item.Item.ItemId)) - { - matchesAnyIdentification = true; - } - - if (!matchesAnyIdentification && rules.AllowedItemNamePatterns.Count > 0) - { - for (int i = 0; i < rules.AllowedItemNamePatterns.Count; i++) - { - string pattern = rules.AllowedItemNamePatterns[i]; - if (string.IsNullOrWhiteSpace(pattern)) - continue; - - var regex = RegexCache.GetOrCreate(pattern); - if (regex != null && regex.IsMatch(item.Name)) - { - matchesAnyIdentification = true; - break; - } - } - } - - if (!matchesAnyIdentification) - return false; - } - - if (rules.AllowedUiCategoryIds.Count > 0) - { - uint uiCategoryId = item.UiCategory.RowId; - if (!rules.AllowedUiCategoryIds.Contains(uiCategoryId)) - return false; - } - - if (rules.AllowedRarities.Count > 0 && !rules.AllowedRarities.Contains(item.Rarity)) - return false; + if (!MatchesToggle(rules.Untradable, item.IsUntradable)) return false; + if (!MatchesToggle(rules.Unique, item.IsUnique)) return false; + if (!MatchesToggle(rules.Collectable, item.IsCollectable)) return false; + if (!MatchesToggle(rules.Dyeable, item.IsDyeable)) return false; + if (!MatchesToggle(rules.HighQuality, item.IsHq)) return false; + if (!MatchesToggle(rules.Repairable, item.IsRepairable)) return false; + if (!MatchesToggle(rules.Desynthesizable, item.IsDesynthesizable)) return false; + if (!MatchesToggle(rules.Glamourable, item.IsGlamourable)) return false; + if (!MatchesToggle(rules.FullySpiritbonded, item.IsSpiritbonded)) return false; if (rules.Level.Enabled && !InRange(item.Level, rules.Level.Min, rules.Level.Max)) return false; @@ -62,19 +32,40 @@ internal static class UserCategoryMatcher if (rules.VendorPrice.Enabled && !InRange(item.VendorPrice, rules.VendorPrice.Min, rules.VendorPrice.Max)) return false; - if (!MatchesToggle(rules.Untradable, item.IsUntradable)) return false; - if (!MatchesToggle(rules.Unique, item.IsUnique)) return false; - if (!MatchesToggle(rules.Collectable, item.IsCollectable)) return false; - if (!MatchesToggle(rules.Dyeable, item.IsDyeable)) return false; - if (!MatchesToggle(rules.Repairable, item.IsRepairable)) return false; - if (!MatchesToggle(rules.HighQuality, item.IsHq)) return false; - if (!MatchesToggle(rules.Desynthesizable, item.IsDesynthesizable)) return false; - if (!MatchesToggle(rules.Glamourable, item.IsGlamourable)) return false; - if (!MatchesToggle(rules.FullySpiritbonded, item.IsSpiritbonded)) return false; + if (rules.AllowedRarities.Count > 0 && !rules.AllowedRarities.Contains(item.Rarity)) + return false; + + if (rules.AllowedUiCategoryIds.Count > 0 && !rules.AllowedUiCategoryIds.Contains(item.UiCategory.RowId)) + return false; + + bool hasIdentificationFilters = rules.AllowedItemIds.Count > 0 || rules.AllowedItemNamePatterns.Count > 0; + + if (hasIdentificationFilters) + { + if (rules.AllowedItemIds.Count > 0 && rules.AllowedItemIds.Contains(item.Item.ItemId)) + return true; + + if (rules.AllowedItemNamePatterns.Count > 0) + { + for (int i = 0; i < rules.AllowedItemNamePatterns.Count; i++) + { + string pattern = rules.AllowedItemNamePatterns[i]; + if (string.IsNullOrWhiteSpace(pattern)) + continue; + + var regex = RegexCache.GetOrCreate(pattern); + if (regex != null && regex.IsMatch(item.Name)) + return true; + } + } + + return false; + } return true; } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static bool InRange(T value, T min, T max) where T : struct, IComparable => value.CompareTo(min) >= 0 && value.CompareTo(max) <= 0; @@ -112,12 +103,13 @@ internal static class UserCategoryMatcher return true; } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static bool MatchesToggle(StateFilter filter, bool itemHasProperty) - => filter.ToggleState switch - { - ToggleFilterState.Ignored => true, - ToggleFilterState.Allow => itemHasProperty, - ToggleFilterState.Disallow => !itemHasProperty, - _ => true - }; + { + var state = filter.ToggleState; + if (state == ToggleFilterState.Ignored) return true; + if (state == ToggleFilterState.Allow) return itemHasProperty; + if (state == ToggleFilterState.Disallow) return !itemHasProperty; + return true; + } } \ No newline at end of file diff --git a/AetherBags/Inventory/Context/HighlightState.cs b/AetherBags/Inventory/Context/HighlightState.cs index 00dd134..8a7b243 100644 --- a/AetherBags/Inventory/Context/HighlightState.cs +++ b/AetherBags/Inventory/Context/HighlightState.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Numerics; +using System.Runtime.CompilerServices; namespace AetherBags.Inventory.Context; @@ -8,6 +9,7 @@ public enum HighlightSource Search, AllaganTools, BiSBuddy, + Relationship, } public record HighlightEntry(uint ItemId, Vector3 Color); @@ -40,6 +42,7 @@ public static class HighlightState _version++; } + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool IsInActiveFilters(uint itemId) { if (Filters.Count == 0) return true; @@ -48,6 +51,7 @@ public static class HighlightState return false; } + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static HighlightEntry? GetHighlightEntry(uint itemId) { EnsureCacheValid(); @@ -88,6 +92,7 @@ public static class HighlightState _version++; } + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static Vector3? GetLabelColor(uint itemId) => GetHighlightEntry(itemId)?.Color; @@ -168,4 +173,16 @@ public static class HighlightState PerItemLabels.Remove(source); InvalidateCache(); } + + public static void SetRelationshipHighlight(HashSet? relatedItemIds, Vector3? color) + { + if (relatedItemIds == null || relatedItemIds.Count == 0) + { + ClearLabel(HighlightSource.Relationship); + return; + } + + var highlightColor = color ?? new Vector3(0.3f, 0.6f, 0.9f); + SetLabel(HighlightSource.Relationship, relatedItemIds, highlightColor); + } } \ No newline at end of file diff --git a/AetherBags/Inventory/Items/ItemInfo.cs b/AetherBags/Inventory/Items/ItemInfo.cs index 1a7d822..2f45dca 100644 --- a/AetherBags/Inventory/Items/ItemInfo.cs +++ b/AetherBags/Inventory/Items/ItemInfo.cs @@ -1,8 +1,10 @@ using System; using System.Numerics; +using System.Runtime.CompilerServices; using System.Text.RegularExpressions; using AetherBags.Helpers; using AetherBags.Inventory.Context; +using AetherBags.IPC.ExternalCategorySystem; using FFXIVClientStructs.FFXIV.Client.Game; using Lumina.Excel; using Lumina.Excel.Sheets; @@ -30,6 +32,7 @@ public sealed class ItemInfo : IEquatable private int _cachedHighlightVersion = -1; private float _cachedVisualAlpha; private Vector3 _cachedHighlightColor; + private bool _cachedIsRelationshipHighlighted; private ref readonly Item Row { @@ -117,6 +120,15 @@ public sealed class ItemInfo : IEquatable } } + public bool IsRelationshipHighlighted + { + get + { + EnsureVisualStateCached(); + return _cachedIsRelationshipHighlighted; + } + } + private void EnsureVisualStateCached() { int currentVersion = HighlightState.Version; @@ -127,6 +139,10 @@ public sealed class ItemInfo : IEquatable _cachedHighlightColor = System.Config.Categories.BisBuddyEnabled ? HighlightState.GetLabelColor(Item.ItemId) ?? Vector3.Zero : Vector3.Zero; + + var entry = HighlightState.GetHighlightEntry(Item.ItemId); + _cachedIsRelationshipHighlighted = entry != null; + _cachedHighlightVersion = currentVersion; } @@ -160,6 +176,7 @@ public sealed class ItemInfo : IEquatable public bool IsMainInventory => InventoryPage >= 0; + [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool IsRegexMatch(string searchTerms) { if (string.IsNullOrEmpty(searchTerms)) @@ -171,22 +188,23 @@ public sealed class ItemInfo : IEquatable if (re.IsMatch(Name)) return true; - if (re.IsMatch(Description)) return true; - if (re.IsMatch(LevelString)) return true; if (re.IsMatch(ItemLevelString)) return true; + if (ExternalCategoryManager.MatchesSearchTag(Item.ItemId, searchTerms)) return true; + + if (re.IsMatch(Description)) return true; + return false; } + [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool IsRegexMatch(Regex re) { if (re.IsMatch(Name)) return true; - if (re.IsMatch(Description)) return true; - if (re.IsMatch(LevelString)) return true; if (re.IsMatch(ItemLevelString)) return true; - + if (re.IsMatch(Description)) return true; return false; } diff --git a/AetherBags/Inventory/State/InventoryStateBase.cs b/AetherBags/Inventory/State/InventoryStateBase.cs index 215ce69..c35afc6 100644 --- a/AetherBags/Inventory/State/InventoryStateBase.cs +++ b/AetherBags/Inventory/State/InventoryStateBase.cs @@ -5,6 +5,7 @@ using AetherBags.Inventory.Categories; using AetherBags.Inventory.Context; using AetherBags.Inventory.Items; using AetherBags.Inventory.Scanning; +using AetherBags.IPC.ExternalCategorySystem; using FFXIVClientStructs.FFXIV.Client.Game; namespace AetherBags.Inventory.State; @@ -87,38 +88,57 @@ public abstract class InventoryStateBase ); } - if (allaganCategoriesEnabled) + bool useUnified = config.General.UseUnifiedExternalCategories; + + if (useUnified) { - if (config.Categories.AllaganToolsFilterMode == PluginFilterMode.Categorize) + ExternalCategoryManager.BucketItems(ItemInfoByKey, BucketsByKey, ClaimedKeys); + + if (allaganCategoriesEnabled && config.Categories.AllaganToolsFilterMode == PluginFilterMode.Highlight) + UpdateAllaganHighlight(HighlightState.SelectedAllaganToolsFilterKey); + else + HighlightState.ClearFilter(HighlightSource.AllaganTools); + + if (bisCategoriesEnabled && config.Categories.BisBuddyMode == PluginFilterMode.Highlight) + UpdateBisBuddyHighlight(HighlightState.SelectedBisBuddyFilterKey); + else + HighlightState.ClearFilter(HighlightSource.BiSBuddy); + } + else + { + if (allaganCategoriesEnabled) + { + if (config.Categories.AllaganToolsFilterMode == PluginFilterMode.Categorize) + { + CategoryBucketManager.BucketByAllaganFilters(ItemInfoByKey, BucketsByKey, ClaimedKeys, true); + HighlightState.ClearFilter(HighlightSource.AllaganTools); + } + else + { + UpdateAllaganHighlight(HighlightState.SelectedAllaganToolsFilterKey); + } + } + else { - CategoryBucketManager.BucketByAllaganFilters(ItemInfoByKey, BucketsByKey, ClaimedKeys, true); HighlightState.ClearFilter(HighlightSource.AllaganTools); } + + if (bisCategoriesEnabled) + { + if (config.Categories.BisBuddyMode == PluginFilterMode.Categorize) + { + CategoryBucketManager.BucketByBisBuddyItems(ItemInfoByKey, BucketsByKey, ClaimedKeys, true); + HighlightState.ClearFilter(HighlightSource.BiSBuddy); + } + else + { + UpdateBisBuddyHighlight(HighlightState.SelectedBisBuddyFilterKey); + } + } else { - UpdateAllaganHighlight(HighlightState.SelectedAllaganToolsFilterKey); - } - } - else - { - HighlightState.ClearFilter(HighlightSource.AllaganTools); - } - - if (bisCategoriesEnabled) - { - if (config.Categories.BisBuddyMode == PluginFilterMode.Categorize) - { - CategoryBucketManager.BucketByBisBuddyItems(ItemInfoByKey, BucketsByKey, ClaimedKeys, true); HighlightState.ClearFilter(HighlightSource.BiSBuddy); } - else - { - UpdateBisBuddyHighlight(HighlightState.SelectedBisBuddyFilterKey); - } - } - else - { - HighlightState.ClearFilter(HighlightSource.BiSBuddy); } if (gameCategoriesEnabled) @@ -205,6 +225,9 @@ public abstract class InventoryStateBase public static IReadOnlyList GetCurrencyInfoList(uint[] currencyIds) => CurrencyState.GetCurrencyInfoList(currencyIds); + public static IReadOnlyList GetCurrencyInfoList(List currencyIds) + => CurrencyState.GetCurrencyInfoList(currencyIds); + public static void InvalidateCurrencyCaches() => CurrencyState.InvalidateCaches(); diff --git a/AetherBags/Monitoring/InventoryMonitor.cs b/AetherBags/Monitoring/InventoryMonitor.cs index fb8523b..aaf0d68 100644 --- a/AetherBags/Monitoring/InventoryMonitor.cs +++ b/AetherBags/Monitoring/InventoryMonitor.cs @@ -40,6 +40,7 @@ public class InventoryMonitor : IDisposable Services.AddonLifecycle.RegisterListener(AddonEvent.PreFinalize, saddle, OnPreFinalize); Services.AddonLifecycle.RegisterListener(AddonEvent.PreFinalize, retainer, OnPreFinalize); + Services.AddonLifecycle.RegisterListener(AddonEvent.PreFinalize, bags, OnInventoryPreFinalize); // PreRefresh Handlers Services.AddonLifecycle.RegisterListener(AddonEvent.PreRefresh, bags, InventoryPreRefreshHandler); @@ -65,6 +66,11 @@ public class InventoryMonitor : IDisposable OpenInventories(args.AddonName); } + private void OnInventoryPreFinalize(AddonEvent type, AddonArgs args) + { + System.AddonInventoryWindow.Close(); + } + private unsafe void OpenInventories(string name) { GeneralSettings config = System.Config.General; @@ -168,22 +174,27 @@ public class InventoryMonitor : IDisposable if (atkValues.Length < 7) return; - AtkValue* value1 = (AtkValue*)atkValues[1].Address; AtkValue* value5 = (AtkValue*)atkValues[5].Address; AtkValue* value6 = (AtkValue*)atkValues[6].Address; if (value5->Type != ValueType.ManagedString || value6->Type != ValueType.ManagedString) return; - int openTitleId = value1->Int; ReadOnlySeString title = value5->String.AsReadOnlySeString(); ReadOnlySeString upperTitle = value6->String.AsReadOnlySeString(); System.AddonInventoryWindow.SetNotification(new InventoryNotificationInfo(title, upperTitle)); - if (config.HideGameInventory) refreshArgs.AtkValueCount = 0; + if (config.HideGameInventory) + { + refreshArgs.AtkValueCount = 0; + } + if (config.OpenWithGameInventory) { + AtkValue* value1 = (AtkValue*)atkValues[1].Address; + int openTitleId = value1->Int; + if (openTitleId == 0) { System.AddonInventoryWindow.Toggle(); @@ -234,6 +245,6 @@ public class InventoryMonitor : IDisposable public void Dispose() { Services.GameInventory.InventoryChangedRaw -= OnInventoryChangedRaw; - Services.AddonLifecycle.UnregisterListener(OnPostSetup, OnPreFinalize, OnInventoryUpdate, OnSaddleBagUpdate, OnRetainerInventoryUpdate); + Services.AddonLifecycle.UnregisterListener(OnPostSetup, OnPreFinalize, OnInventoryUpdate, OnSaddleBagUpdate, OnRetainerInventoryUpdate, OnInventoryPreFinalize, InventoryPreRefreshHandler); } } \ No newline at end of file diff --git a/AetherBags/Monitoring/LootedItemsTracker.cs b/AetherBags/Monitoring/LootedItemsTracker.cs index a7603cd..7454b96 100644 --- a/AetherBags/Monitoring/LootedItemsTracker.cs +++ b/AetherBags/Monitoring/LootedItemsTracker.cs @@ -25,6 +25,7 @@ public sealed unsafe class LootedItemsTracker : IDisposable private bool _isEnabled; private long _batchStartTick; private bool _hasPendingRemoval; + private int _nextIndex; public event Action>? OnLootedItemsChanged; @@ -32,8 +33,6 @@ public sealed unsafe class LootedItemsTracker : IDisposable public bool HasPendingChanges => _pendingChanges.Count > 0 || _hasPendingRemoval; - private int GetNextIndex() => _lootedItems.Count > 0 ? _lootedItems.Max(x => x.Index) + 1 : 0; - public void Enable() { if (_isEnabled) return; @@ -43,6 +42,7 @@ public sealed unsafe class LootedItemsTracker : IDisposable _pendingChanges.Clear(); _batchStartTick = 0; _hasPendingRemoval = false; + _nextIndex = 0; Services.GameInventory.InventoryChangedRaw += OnInventoryChangedRaw; Services.Framework.Update += OnFrameworkUpdate; } @@ -58,12 +58,14 @@ public sealed unsafe class LootedItemsTracker : IDisposable _pendingChanges.Clear(); _batchStartTick = 0; _hasPendingRemoval = false; + _nextIndex = 0; } public void Clear() { _lootedItems.Clear(); _hasPendingRemoval = true; + _nextIndex = 0; } public void RemoveByIndex(int index) @@ -100,9 +102,7 @@ public sealed unsafe class LootedItemsTracker : IDisposable foreach (var ((itemId, isHq), (item, delta)) in _pendingChanges) { - int existingIndex = _lootedItems.FindIndex(x => - x.Item.ItemId == itemId && - x.Item.Flags.HasFlag(InventoryItem.ItemFlags.HighQuality) == isHq); + int existingIndex = FindExistingItemIndex(itemId, isHq); if (existingIndex >= 0) { @@ -116,13 +116,27 @@ public sealed unsafe class LootedItemsTracker : IDisposable } else if (delta > 0) { - _lootedItems.Add(new LootedItemInfo(GetNextIndex(), item, delta)); + _lootedItems.Add(new LootedItemInfo(_nextIndex++, item, delta)); } } _pendingChanges.Clear(); } + private int FindExistingItemIndex(uint itemId, bool isHq) + { + for (int i = 0; i < _lootedItems.Count; i++) + { + var info = _lootedItems[i]; + if (info.Item.ItemId == itemId && + info.Item.Flags.HasFlag(InventoryItem.ItemFlags.HighQuality) == isHq) + { + return i; + } + } + return -1; + } + private void OnInventoryChangedRaw(IReadOnlyCollection events) { if (!_isEnabled || !Services.ClientState.IsLoggedIn) return; @@ -156,7 +170,12 @@ public sealed unsafe class LootedItemsTracker : IDisposable if (_pendingChanges.TryGetValue(key, out var existing)) { - _pendingChanges[key] = (existing.Item, existing.Quantity + changeAmount); + InventoryItem itemStruct = existing.Item; + if (changeAmount > 0 && itemStruct.ItemId == 0) + { + itemStruct = *(InventoryItem*)eventData.Item.Address; + } + _pendingChanges[key] = (itemStruct, existing.Quantity + changeAmount); } else { diff --git a/AetherBags/Nodes/Configuration/Category/CategoryGeneralConfigurationNode.cs b/AetherBags/Nodes/Configuration/Category/CategoryGeneralConfigurationNode.cs index 3f0fd58..34f0f53 100644 --- a/AetherBags/Nodes/Configuration/Category/CategoryGeneralConfigurationNode.cs +++ b/AetherBags/Nodes/Configuration/Category/CategoryGeneralConfigurationNode.cs @@ -41,6 +41,7 @@ public sealed class CategoryGeneralConfigurationNode : TabbedVerticalListNode OnClick = isChecked => { config.CategoriesEnabled = isChecked; + System.IPC?.RefreshExternalSources(); RefreshInventory(); } }; @@ -92,8 +93,9 @@ public sealed class CategoryGeneralConfigurationNode : TabbedVerticalListNode { config.BisBuddyMode = selected; if (selected == PluginFilterMode.Categorize) - HighlightState.ClearFilter(HighlightSource.AllaganTools); + HighlightState.ClearFilter(HighlightSource.BiSBuddy); + System.IPC?.RefreshExternalSources(); RefreshInventory(); } }; @@ -110,6 +112,7 @@ public sealed class CategoryGeneralConfigurationNode : TabbedVerticalListNode config.BisBuddyEnabled = isChecked; if (bbModeDropdown != null) bbModeDropdown.IsEnabled = isChecked; if (isChecked) System.IPC.BisBuddy?.RefreshItems(); + System.IPC?.RefreshExternalSources(); RefreshInventory(); } }; @@ -134,6 +137,7 @@ public sealed class CategoryGeneralConfigurationNode : TabbedVerticalListNode HighlightState.ClearFilter(HighlightSource.AllaganTools); } + System.IPC?.RefreshExternalSources(); RefreshInventory(); } }; @@ -153,6 +157,7 @@ public sealed class CategoryGeneralConfigurationNode : TabbedVerticalListNode config.AllaganToolsCategoriesEnabled = isChecked; if (atModeDropdown != null) atModeDropdown.IsEnabled = isChecked; if (isChecked) System.IPC?.AllaganTools?.RefreshFilters(); + System.IPC?.RefreshExternalSources(); RefreshInventory(); } }; diff --git a/AetherBags/Nodes/Configuration/Category/CategoryScrollingAreaNode.cs b/AetherBags/Nodes/Configuration/Category/CategoryScrollingAreaNode.cs index 9366a00..e37ad02 100644 --- a/AetherBags/Nodes/Configuration/Category/CategoryScrollingAreaNode.cs +++ b/AetherBags/Nodes/Configuration/Category/CategoryScrollingAreaNode.cs @@ -14,6 +14,8 @@ public sealed class CategoryScrollingAreaNode : ScrollingListNode AddNode(new CategoryGeneralConfigurationNode()); + AddNode(new ExperimentalConfigurationNode()); + var categoryConfigurationButtonNode = new TextButtonNode { Size = new Vector2(300, 28), diff --git a/AetherBags/Nodes/Configuration/Category/ExperimentalConfigurationNode.cs b/AetherBags/Nodes/Configuration/Category/ExperimentalConfigurationNode.cs new file mode 100644 index 0000000..ff0e00f --- /dev/null +++ b/AetherBags/Nodes/Configuration/Category/ExperimentalConfigurationNode.cs @@ -0,0 +1,50 @@ +using AetherBags.Configuration; +using AetherBags.Inventory; +using KamiToolKit.Nodes; + +namespace AetherBags.Nodes.Configuration.Category; + +internal class ExperimentalConfigurationNode : TabbedVerticalListNode +{ + public ExperimentalConfigurationNode() + { + GeneralSettings config = System.Config.General; + + var titleNode = new CategoryTextNode + { + Height = 18, + String = "Experimental", + }; + AddNode(titleNode); + + AddTab(1); + + var externalCategoryCheckbox = new CheckboxNode + { + Height = 18, + IsVisible = true, + String = "External Category Support", + IsChecked = config.UseUnifiedExternalCategories, + TextTooltip = "EXPERIMENTAL - Use at your own risk. This feature is not fully tested.\n\n" + + "Enables enhanced integration with external plugins like " + + "Allagan Tools and BisBuddy.\n\n" + + "Features:\n" + + "- Search by plugin tags (e.g. search 'bis' to find BiS items)\n" + + "- Relationship highlighting: hover an item to see related items\n" + + " (same gear set, upgrades, crafting materials)\n" + + "- Item badges showing plugin status icons\n" + + "- Custom borders and visual effects (glow, pulse)\n" + + "- Additional right-click menu options from plugins\n" + + "- Extra tooltip information from plugins\n\n" + + "When disabled, external plugins still provide categories and " + + "basic highlighting, but without these enhanced features.", + OnClick = isChecked => + { + config.UseUnifiedExternalCategories = isChecked; + System.IPC?.UpdateUnifiedCategorySupport(isChecked); + InventoryOrchestrator.RefreshAll(updateMaps: true); + } + }; + AddNode(externalCategoryCheckbox); + } +} diff --git a/AetherBags/Nodes/Inventory/InventoryCategoryNode.cs b/AetherBags/Nodes/Inventory/InventoryCategoryNode.cs index b5f3340..7b98096 100644 --- a/AetherBags/Nodes/Inventory/InventoryCategoryNode.cs +++ b/AetherBags/Nodes/Inventory/InventoryCategoryNode.cs @@ -50,6 +50,8 @@ public class InventoryCategoryNode : InventoryCategoryNodeBase public Action? OnRefreshRequested { get; set; } public Action? OnDragEnd { get; set; } + public SharedNodePool? SharedItemPool { get; set; } + public InventoryCategoryNode() { _categoryNameTextNode = new TextNode @@ -186,7 +188,7 @@ public class InventoryCategoryNode : InventoryCategoryNodeBase } } - public float? MaxWidth + public override float? MaxWidth { get => _maxWidth; set => _maxWidth = value; @@ -256,7 +258,7 @@ public class InventoryCategoryNode : InventoryCategoryNodeBase } } - public void RecalculateSize() + public override void RecalculateSize() { int itemCount = CategorizedInventory.Items.Count; @@ -320,31 +322,55 @@ public class InventoryCategoryNode : InventoryCategoryNodeBase getKeyFromData: item => item.Key, getKeyFromNode: node => node.ItemInfo?.Key ?? 0, updateNode: UpdateInventoryDragDropNode, - createNodeMethod: CreateInventoryDragDropNode); + createNodeMethod: CreateInventoryDragDropNode, + resetNodeForReuse: ResetDragDropNodeForReuse, + externalPool: SharedItemPool); } 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; - } + node.ItemInfo = data; + ApplyItemDataToNode(node, data); + } + private static void ResetDragDropNodeForReuse(InventoryDragDropNode node) + { + node.ResetForReuse(); + } + + private unsafe InventoryDragDropNode CreateInventoryDragDropNode(ItemInfo data) + { + var node = new InventoryDragDropNode + { + Size = new Vector2(42, 46), + IsVisible = true, + AcceptedType = DragDropType.Item, + IsClickable = true, + OnDiscard = OnNodeDiscard, + OnEnd = _ => OnDragEnd?.Invoke(), + OnPayloadAccepted = OnNodePayloadAccepted, + OnRollOver = OnNodeRollOver, + OnRollOut = OnNodeRollOut, + ItemInfo = data + }; + + ApplyItemDataToNode(node, data); + return node; + } + + private void ApplyItemDataToNode(InventoryDragDropNode node, ItemInfo data) + { 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.IconNode.IconExtras.AntsNode.IsVisible = data.IsRelationshipHighlighted; node.Payload = new DragDropPayload { Type = DragDropType.Item, @@ -354,52 +380,31 @@ public class InventoryCategoryNode : InventoryCategoryNodeBase }; } - private unsafe InventoryDragDropNode CreateInventoryDragDropNode(ItemInfo data) + private void OnNodeDiscard(DragDropNode n) { - InventoryItem item = data.Item; - InventoryMappedLocation visualLocation = data.VisualLocation; + if (n is not InventoryDragDropNode node) return; + OnDiscard(n, node.ItemInfo); + } - var visualInvType = InventoryType.GetInventoryTypeFromContainerId(visualLocation.Container); - int absoluteIndex = visualInvType.GetInventoryStartIndex + visualLocation.Slot; + private void OnNodePayloadAccepted(DragDropNode n, DragDropPayload acceptedPayload) + { + if (n is not InventoryDragDropNode node) return; + OnPayloadAccepted(n, acceptedPayload, node.ItemInfo); + } - DragDropPayload nodePayload = new DragDropPayload - { - // Int1 is always the container ID, for Item DragDrop Int2 is only used as a fallback - // ReferenceIndex is the absolute index that's actually used - Type = DragDropType.Item, - Int1 = visualLocation.Container, - Int2 = visualLocation.Slot, - ReferenceIndex = (short)absoluteIndex - }; + private unsafe void OnNodeRollOver(DragDropNode n) + { + if (n is not InventoryDragDropNode node) return; + BeginHeaderHover(); + var item = node.ItemInfo.Item; + n.ShowInventoryItemTooltip(item.Container, item.Slot); + } - return new InventoryDragDropNode - { - Size = new Vector2(42, 46), - Alpha = data.VisualAlpha, - AddColor = data.HighlightOverlayColor, - IsDraggable = !data.IsSlotBlocked, - IsVisible = true, - IconId = item.IconId, - AcceptedType = DragDropType.Item, - Payload = nodePayload, - IsClickable = true, - OnDiscard = node => OnDiscard(node, data), - OnEnd = _ => OnDragEnd?.Invoke(), - OnPayloadAccepted = (node, acceptedPayload) => OnPayloadAccepted(node, acceptedPayload, data), - OnRollOver = node => - { - BeginHeaderHover(); - node.ShowInventoryItemTooltip(item.Container, item.Slot); - }, - OnRollOut = node => - { - EndHeaderHover(); - - ushort addonId = RaptureAtkUnitManager.Instance()->GetAddonByNode(node)->Id; - AtkStage.Instance()->TooltipManager.HideTooltip(addonId); - }, - ItemInfo = data - }; + private unsafe void OnNodeRollOut(DragDropNode n) + { + EndHeaderHover(); + ushort addonId = RaptureAtkUnitManager.Instance()->GetAddonByNode(n)->Id; + AtkStage.Instance()->TooltipManager.HideTooltip(addonId); } public void RefreshNodeVisuals() @@ -414,6 +419,7 @@ public class InventoryCategoryNode : InventoryCategoryNodeBase float newAlpha = info.VisualAlpha; Vector3 newColor = info.HighlightOverlayColor; bool newDraggable = !info.IsSlotBlocked; + bool newAntsVisible = info.IsRelationshipHighlighted; if (!NearlyEqual(itemNode.Alpha, newAlpha)) itemNode.Alpha = newAlpha; @@ -423,6 +429,9 @@ public class InventoryCategoryNode : InventoryCategoryNodeBase if (itemNode.IsDraggable != newDraggable) itemNode.IsDraggable = newDraggable; + + if (itemNode.IconNode.IconExtras.AntsNode.IsVisible != newAntsVisible) + itemNode.IconNode.IconExtras.AntsNode.IsVisible = newAntsVisible; } } @@ -476,4 +485,30 @@ public class InventoryCategoryNode : InventoryCategoryNodeBase Services.Logger.Error(ex, "[OnPayload] Error handling payload acceptance"); } } + + public void ResetForReuse() + { + _lastCategoryKey = 0; + _lastItemCount = 0; + _lastItemsHash = 0; + _lastItemsPerLine = 0; + _itemsNeedPopulation = false; + + _hoverRefs = 0; + _headerSuppressed = false; + _headerExpanded = false; + _fullHeaderText = string.Empty; + + _fixedWidth = null; + _maxWidth = null; + + _categoryNameTextNode.String = string.Empty; + _categoryNameTextNode.TextTooltip = string.Empty; + _categoryNameTextNode.IsVisible = true; + + using (_itemGridNode.DeferRecalculateLayout()) + { + _itemGridNode.Clear(); + } + } } diff --git a/AetherBags/Nodes/Inventory/InventoryCategoryNodeBase.cs b/AetherBags/Nodes/Inventory/InventoryCategoryNodeBase.cs index b295c9e..ad57edb 100644 --- a/AetherBags/Nodes/Inventory/InventoryCategoryNodeBase.cs +++ b/AetherBags/Nodes/Inventory/InventoryCategoryNodeBase.cs @@ -17,4 +17,8 @@ public abstract class InventoryCategoryNodeBase : SimpleComponentNode /// Whether this category should be pinned in the layout. /// public virtual bool IsPinnedInConfig => false; + + public abstract float? MaxWidth { get; set; } + + public abstract void RecalculateSize(); } diff --git a/AetherBags/Nodes/Inventory/InventoryDragDropNode.cs b/AetherBags/Nodes/Inventory/InventoryDragDropNode.cs index b5feefe..0f23c28 100644 --- a/AetherBags/Nodes/Inventory/InventoryDragDropNode.cs +++ b/AetherBags/Nodes/Inventory/InventoryDragDropNode.cs @@ -1,17 +1,25 @@ using System.Numerics; +using AetherBags.Addons; +using AetherBags.Inventory.Context; using AetherBags.Inventory.Items; +using AetherBags.IPC.ExternalCategorySystem; using Dalamud.Game.ClientState.Keys; using FFXIVClientStructs.FFXIV.Client.Game; using FFXIVClientStructs.FFXIV.Client.UI.Agent; using FFXIVClientStructs.FFXIV.Component.GUI; using KamiToolKit.Classes; using KamiToolKit.Nodes; +using KamiToolKit.Timelines; namespace AetherBags.Nodes.Inventory; public class InventoryDragDropNode : DragDropNode { private readonly TextNode _quantityTextNode; + private IconNode? _badgeNode; + private ImageNode? _borderNode; + private ItemDecoration? _currentDecoration; + public unsafe InventoryDragDropNode() { _quantityTextNode = new TextNode { @@ -26,6 +34,8 @@ public class InventoryDragDropNode : DragDropNode _quantityTextNode.AttachNode(this); CollisionNode.AddEvent(AtkEventType.MouseDown, OnItemMouseDown); CollisionNode.AddEvent(AtkEventType.MouseClick, OnItemClicked); + CollisionNode.AddEvent(AtkEventType.MouseOver, OnItemHover); + CollisionNode.AddEvent(AtkEventType.MouseOut, OnItemUnhover); } public required ItemInfo ItemInfo @@ -35,9 +45,164 @@ public class InventoryDragDropNode : DragDropNode { field = value; _quantityTextNode.String = value.ItemCount.ToString(); + ApplyDecoration(ExternalCategoryManager.GetDecoration(value.Item.ItemId)); } } + public void ApplyDecoration(ItemDecoration? decoration) + { + if (_currentDecoration.Equals(decoration)) return; + _currentDecoration = decoration; + + if (decoration == null) + { + ClearDecoration(); + return; + } + + if (decoration.Value.Badge.HasValue) + { + ApplyBadge(decoration.Value.Badge.Value); + } + else + { + ClearBadge(); + } + + if (decoration.Value.Border != BorderStyle.None) + { + ApplyBorder(decoration.Value.Border); + } + else + { + ClearBorder(); + } + } + + private void ApplyBadge(BadgeInfo badge) + { + if (_badgeNode == null) + { + _badgeNode = new IconNode + { + Size = new Vector2(16, 16), + NodeFlags = NodeFlags.Visible | NodeFlags.Enabled, + }; + _badgeNode.AttachNode(this); + } + + _badgeNode.IconId = badge.IconId; + _badgeNode.IsVisible = true; + + if (badge.TintColor.HasValue) + { + _badgeNode.AddColor = new Vector3(badge.TintColor.Value.X, badge.TintColor.Value.Y, badge.TintColor.Value.Z); + } + + _badgeNode.Position = badge.Position switch + { + BadgePosition.TopLeft => new Vector2(0, 0), + BadgePosition.TopRight => new Vector2(26, 0), + BadgePosition.BottomLeft => new Vector2(0, 30), + BadgePosition.BottomRight => new Vector2(26, 30), + _ => new Vector2(26, 0) + }; + } + + private void ClearBadge() + { + if (_badgeNode != null) + { + _badgeNode.IsVisible = false; + } + } + + private BorderStyle _currentBorderStyle = BorderStyle.None; + + private void ApplyBorder(BorderStyle style) + { + if (_borderNode == null) + { + _borderNode = new SimpleImageNode + { + Size = new Vector2(42, 46), + Position = new Vector2(0, 0), + NodeFlags = NodeFlags.Visible | NodeFlags.Enabled, + TexturePath = "ui/uld/IconA_Frame.tex", + TextureCoordinates = new Vector2(0, 0), + TextureSize = new Vector2(48, 48), + }; + _borderNode.AttachNode(this); + } + + _borderNode.IsVisible = true; + + if (_currentBorderStyle != style) + { + _currentBorderStyle = style; + BuildBorderTimeline(style); + } + + if (style == BorderStyle.Pulse) + { + _borderNode.Timeline?.PlayAnimation(1); + } + else if (style == BorderStyle.Glow) + { + _borderNode.Timeline?.PlayAnimation(1); + } + } + + private void BuildBorderTimeline(BorderStyle style) + { + if (_borderNode == null) return; + + switch (style) + { + case BorderStyle.Solid: + _borderNode.AddColor = new Vector3(1.0f, 1.0f, 1.0f); + break; + + case BorderStyle.Glow: + _borderNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(10, 50) + .AddLabel(10, 1, AtkTimelineJumpBehavior.LoopForever, 10) + .AddFrame(10, addColor: new Vector3(0.6f, 0.8f, 1.0f), alpha: 255) + .AddFrame(30, addColor: new Vector3(0.9f, 1.0f, 1.2f), alpha: 255) + .AddFrame(50, addColor: new Vector3(0.6f, 0.8f, 1.0f), alpha: 255) + .EndFrameSet() + .Build()); + break; + + case BorderStyle.Pulse: + _borderNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 40) + .AddLabel(1, 1, AtkTimelineJumpBehavior.LoopForever, 1) + .AddFrame(1, addColor: new Vector3(1.0f, 0.6f, 0.0f), alpha: 180) + .AddFrame(20, addColor: new Vector3(1.2f, 0.9f, 0.3f), alpha: 255) + .AddFrame(40, addColor: new Vector3(1.0f, 0.6f, 0.0f), alpha: 180) + .EndFrameSet() + .Build()); + break; + } + } + + private void ClearBorder() + { + if (_borderNode != null) + { + _borderNode.Timeline?.StopAnimation(); + _borderNode.IsVisible = false; + _currentBorderStyle = BorderStyle.None; + } + } + + private void ClearDecoration() + { + ClearBadge(); + ClearBorder(); + } + private unsafe void OnItemMouseDown(AtkEventListener* thisPtr, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, AtkEventData* atkEventData) { InventoryItem item = ItemInfo.Item; if (Services.KeyState[VirtualKey.SHIFT] && atkEventData->IsLeftClick && System.Config.General.LinkItemEnabled) @@ -48,6 +213,11 @@ public class InventoryDragDropNode : DragDropNode if (!atkEventData->IsRightClick) return; + if (Services.KeyState[VirtualKey.CONTROL] && ItemContextMenuHandler.TryShowExternalMenu(ItemInfo)) + { + return; + } + AgentInventoryContext* context = AgentInventoryContext.Instance(); context->OpenForItemSlot(item.Container, item.Slot, 0, context->AddonId); } @@ -57,6 +227,56 @@ public class InventoryDragDropNode : DragDropNode if (Services.KeyState[VirtualKey.SHIFT] && System.Config.General.LinkItemEnabled) return; InventoryItem item = ItemInfo.Item; if (!atkEventData->IsLeftClick) return; + + System.AetherBagsAPI?.API.RaiseItemClicked(item.ItemId); item.UseItem(); } + + private unsafe void OnItemHover(AtkEventListener* thisPtr, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, AtkEventData* atkEventData) + { + uint itemId = ItemInfo.Item.ItemId; + System.AetherBagsAPI?.API.RaiseItemHovered(itemId); + + if (System.Config.General.UseUnifiedExternalCategories) + { + var relatedItems = ExternalCategoryManager.GetRelatedItemIds(itemId, RelationshipType.SameSet); + if (relatedItems != null && relatedItems.Count > 0) + { + var relationships = ExternalCategoryManager.GetItemRelationships(itemId); + Vector3? highlightColor = null; + if (relationships != null) + { + foreach (var rel in relationships) + { + if (rel.Type == RelationshipType.SameSet && rel.HighlightColor.HasValue) + { + highlightColor = rel.HighlightColor; + break; + } + } + } + HighlightState.SetRelationshipHighlight(relatedItems, highlightColor); + } + } + } + + private unsafe void OnItemUnhover(AtkEventListener* thisPtr, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, AtkEventData* atkEventData) + { + System.AetherBagsAPI?.API.RaiseItemUnhovered(ItemInfo.Item.ItemId); + + if (System.Config.General.UseUnifiedExternalCategories) + { + HighlightState.SetRelationshipHighlight(null, null); + } + } + + public void ResetForReuse() + { + ClearDecoration(); + _quantityTextNode.String = string.Empty; + Alpha = 1.0f; + AddColor = Vector3.Zero; + IsDraggable = true; + IconNode.IconExtras.AntsNode.IsVisible = false; + } } \ No newline at end of file diff --git a/AetherBags/Nodes/Inventory/InventoryFooterNode.cs b/AetherBags/Nodes/Inventory/InventoryFooterNode.cs index 436c904..9ec16a7 100644 --- a/AetherBags/Nodes/Inventory/InventoryFooterNode.cs +++ b/AetherBags/Nodes/Inventory/InventoryFooterNode.cs @@ -48,8 +48,7 @@ public sealed class InventoryFooterNode : SimpleComponentNode if (!config.Enabled) return; - //IReadOnlyList currencyInfoList = GetCurrencyInfoList([1, 28, 0xFFFF_FFFE, 0xFFFF_FFFD]); - IReadOnlyList currencyInfoList = GetCurrencyInfoList(config.DisplayedCurrencies.ToArray()); + IReadOnlyList currencyInfoList = GetCurrencyInfoList(config.DisplayedCurrencies); _currencyListNode.SyncWithListDataByKey( dataList: currencyInfoList, getKeyFromData: currencyInfo => currencyInfo.ItemId, diff --git a/AetherBags/Nodes/Inventory/LootedItemDisplayNode.cs b/AetherBags/Nodes/Inventory/LootedItemDisplayNode.cs index 5f683b7..3144cee 100644 --- a/AetherBags/Nodes/Inventory/LootedItemDisplayNode.cs +++ b/AetherBags/Nodes/Inventory/LootedItemDisplayNode.cs @@ -18,6 +18,8 @@ public sealed unsafe class LootedItemDisplayNode : SimpleComponentNode private readonly TextNode _quantityTextNode; public Action? OnDismiss { get; set; } + public Action? OnRollOver { get; set; } + public Action? OnRollOut { get; set; } public LootedItemDisplayNode() { @@ -28,9 +30,13 @@ public sealed unsafe class LootedItemDisplayNode : SimpleComponentNode Position = new Vector2(0, 0), Size = new Vector2(42, 46), }; - _iconNode.AddEvent(AtkEventType.MouseClick, OnMouseClick); + _iconNode.CollisionNode.NodeFlags = 0; _iconNode.AttachNode(this); + CollisionNode.AddEvent(AtkEventType.MouseClick, OnMouseClick); + CollisionNode.AddEvent(AtkEventType.MouseOver, OnMouseOver); + CollisionNode.AddEvent(AtkEventType.MouseOut, OnMouseOut); + _quantityTextNode = new TextNode { Size = new Vector2(40.0f, 12.0f), @@ -80,4 +86,21 @@ public sealed unsafe class LootedItemDisplayNode : SimpleComponentNode if (!atkEventData->IsLeftClick) return; OnDismiss?.Invoke(this); } + + private void OnMouseOver(AtkEventListener* thisPtr, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, AtkEventData* atkEventData) + { + OnRollOver?.Invoke(this); + } + + private void OnMouseOut(AtkEventListener* thisPtr, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, AtkEventData* atkEventData) + { + OnRollOut?.Invoke(this); + } + + public void ResetForReuse() + { + LootedItem = null; + _iconNode.IsVisible = false; + _quantityTextNode.String = string.Empty; + } } diff --git a/AetherBags/Nodes/Inventory/LootedItemsCategoryNode.cs b/AetherBags/Nodes/Inventory/LootedItemsCategoryNode.cs index cd55089..e729007 100644 --- a/AetherBags/Nodes/Inventory/LootedItemsCategoryNode.cs +++ b/AetherBags/Nodes/Inventory/LootedItemsCategoryNode.cs @@ -32,6 +32,8 @@ public class LootedItemsCategoryNode : InventoryCategoryNodeBase private int _lastItemCount; private long _lastItemsHash; + private float? _maxWidth; + private int _hoverRefs; private bool _headerExpanded; private float _baseHeaderWidth = 96f; @@ -54,6 +56,12 @@ public class LootedItemsCategoryNode : InventoryCategoryNodeBase public bool HasItems => _lootedItems.Count > 0; + public override float? MaxWidth + { + get => _maxWidth; + set => _maxWidth = value; + } + public LootedItemsCategoryNode() { _headerTextNode = new TextNode @@ -183,6 +191,8 @@ public class LootedItemsCategoryNode : InventoryCategoryNodeBase Vector2 drawSize = _headerTextNode.GetTextDrawSize(); float expandedWidth = MathF.Max(_baseHeaderWidth, drawSize.X + 4f); _headerTextNode.Size = _headerTextNode.Size with { X = expandedWidth }; + + _clearButton.Position = new Vector2(expandedWidth + 4f, (HeaderHeight - ClearButtonSize) / 2); } else { @@ -193,6 +203,9 @@ public class LootedItemsCategoryNode : InventoryCategoryNodeBase flags |= TextFlags.OverflowHidden | TextFlags.Ellipsis; _headerTextNode.TextFlags = flags; + + float nodeWidth = Size.X; + _clearButton.Position = new Vector2(nodeWidth - ClearButtonSize, (HeaderHeight - ClearButtonSize) / 2); } } @@ -203,7 +216,8 @@ public class LootedItemsCategoryNode : InventoryCategoryNodeBase getKeyFromData: item => item.Index, getKeyFromNode: node => node.LootedItem?.Index ?? -1, updateNode: UpdateLootedItemNode, - createNodeMethod: CreateLootedItemNode); + createNodeMethod: CreateLootedItemNode, + resetNodeForReuse: ResetLootedItemNodeForReuse); } private static void UpdateLootedItemNode(LootedItemDisplayNode node, LootedItemInfo data) @@ -211,11 +225,18 @@ public class LootedItemsCategoryNode : InventoryCategoryNodeBase node.LootedItem = data; } + private static void ResetLootedItemNodeForReuse(LootedItemDisplayNode node) + { + node.ResetForReuse(); + } + private LootedItemDisplayNode CreateLootedItemNode(LootedItemInfo lootedItem) { return new LootedItemDisplayNode { OnDismiss = OnItemDismissed, + OnRollOver = _ => BeginHeaderHover(), + OnRollOut = _ => EndHeaderHover(), LootedItem = lootedItem, }; } @@ -227,13 +248,19 @@ public class LootedItemsCategoryNode : InventoryCategoryNodeBase OnDismissItem?.Invoke(index); } - private void RecalculateSize() + public override void RecalculateSize() { int itemCount = _lootedItems.Count; + const float cellW = 42f; + const float cellH = 46f; + + float hPad = _itemGridNode.HorizontalPadding; + float vPad = _itemGridNode.VerticalPadding; + if (itemCount == 0) { - float width = MinWidth; + float width = _maxWidth.HasValue ? Math.Min(MinWidth, _maxWidth.Value) : MinWidth; Size = new Vector2(width, HeaderHeight); _baseHeaderWidth = width - ClearButtonSize - 4; _headerTextNode.Size = new Vector2(_baseHeaderWidth, HeaderHeight); @@ -246,20 +273,36 @@ public class LootedItemsCategoryNode : InventoryCategoryNodeBase } int itemsPerLine = Math.Max(1, _itemGridNode.ItemsPerLine); + + float minUsableWidth = cellW; + if (_maxWidth.HasValue && _maxWidth.Value >= minUsableWidth) + { + int maxColumns = (int)MathF.Floor((_maxWidth.Value + hPad) / (cellW + hPad)); + maxColumns = Math.Max(1, maxColumns); + + float widthNeeded = maxColumns * cellW + (maxColumns - 1) * hPad; + if (widthNeeded > _maxWidth.Value && maxColumns > 1) + maxColumns--; + + itemsPerLine = Math.Min(itemsPerLine, maxColumns); + } + int rows = (itemCount + itemsPerLine - 1) / itemsPerLine; int actualColumns = Math.Min(itemCount, itemsPerLine); - const float cellW = 42f; - const float cellH = 46f; - - float hPad = _itemGridNode.HorizontalPadding; - float vPad = _itemGridNode.VerticalPadding; - float calculatedWidth = Math.Max(MinWidth, actualColumns * cellW + (actualColumns - 1) * hPad); + + if (_maxWidth.HasValue && _maxWidth.Value >= minUsableWidth) + calculatedWidth = Math.Min(calculatedWidth, _maxWidth.Value); + float gridHeight = rows * cellH + (rows - 1) * vPad; float totalHeight = HeaderHeight + gridHeight; Size = new Vector2(calculatedWidth, totalHeight); + + if (_itemGridNode.ItemsPerLine != itemsPerLine) + _itemGridNode.ItemsPerLine = itemsPerLine; + _baseHeaderWidth = calculatedWidth - ClearButtonSize - 4; _headerTextNode.Size = new Vector2(_baseHeaderWidth, HeaderHeight); _clearButton.Position = new Vector2(calculatedWidth - ClearButtonSize, (HeaderHeight - ClearButtonSize) / 2); diff --git a/AetherBags/Nodes/Layout/DeferrableLayoutListNode.cs b/AetherBags/Nodes/Layout/DeferrableLayoutListNode.cs index 12da585..520186a 100644 --- a/AetherBags/Nodes/Layout/DeferrableLayoutListNode.cs +++ b/AetherBags/Nodes/Layout/DeferrableLayoutListNode.cs @@ -15,20 +15,75 @@ public abstract class DeferrableLayoutListNode : SimpleComponentNode 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) + private readonly Dictionary> _nodePoolByType = new(); + private const int MaxPoolSizePerType = 64; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private TU? TryRentFromPool(SharedNodePool? externalPool) where TU : NodeBase + { + if (externalPool != null) + { + return externalPool.TryRent(); + } + + if (_nodePoolByType.TryGetValue(typeof(TU), out var pool) && pool.Count > 0) + { + var node = (TU)pool.Pop(); + node.IsVisible = true; + return node; + } + return null; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool TryReturnToPool(TU node, SharedNodePool? externalPool, Action? resetAction) where TU : NodeBase + { + if (externalPool != null) + { + resetAction?.Invoke(node); + return externalPool.TryReturn(node); + } + + var type = typeof(TU); + if (!_nodePoolByType.TryGetValue(type, out var pool)) + { + pool = new Stack(16); + _nodePoolByType[type] = pool; + } + + if (pool.Count >= MaxPoolSizePerType) + return false; + + resetAction?.Invoke(node); + node.IsVisible = false; + node.DetachNode(); + pool.Push(node); + return true; + } + + private void DisposePool() + { + foreach (var pool in _nodePoolByType.Values) + { + while (pool.Count > 0) + { + var node = pool.Pop(); + SafeDisposeNode(node); + } + } + _nodePoolByType.Clear(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected static void SafeDisposeNode(NodeBase node) { try { - node.IsVisible = false; - node.DetachNode(); + node.Dispose(); } catch (Exception ex) { - Services.Logger.Error(ex, $"[SafeDetachNode] Error detaching {node.GetType().Name}"); + Services.Logger.Error(ex, $"[SafeDisposeNode] Error disposing {node.GetType().Name}"); } } @@ -137,7 +192,7 @@ public abstract class DeferrableLayoutListNode : SimpleComponentNode if (!NodeList.Contains(node)) return; NodeList.Remove(node); - SafeDetachNode(node); + SafeDisposeNode(node); RecalculateLayout(); } @@ -161,13 +216,16 @@ public abstract class DeferrableLayoutListNode : SimpleComponentNode { var node = NodeList[i]; NodeList.RemoveAt(i); - SafeDetachNode(node); + SafeDisposeNode(node); } } finally { _suppressRecalculateLayout = false; } + + DisposePool(); + RecalculateLayout(); } @@ -248,13 +306,14 @@ public abstract class DeferrableLayoutListNode : SimpleComponentNode _dataKeysScratch = set; } - public bool SyncWithListDataByKey( IReadOnlyList dataList, Func getKeyFromData, Func getKeyFromNode, Action updateNode, CreateNewNode createNodeMethod, + Action? resetNodeForReuse = null, + SharedNodePool? externalPool = null, IEqualityComparer? keyComparer = null) where TU : NodeBase where TKey : notnull { keyComparer ??= EqualityComparer.Default; @@ -295,9 +354,13 @@ public abstract class DeferrableLayoutListNode : SimpleComponentNode { for (int i = 0; i < toRemove.Count; i++) { - var node = toRemove[i]; + var node = (TU)toRemove[i]; NodeList.Remove(node); - SafeDetachNode(node); + + if (!TryReturnToPool(node, externalPool, resetNodeForReuse)) + { + SafeDisposeNode(node); + } } } finally @@ -345,9 +408,20 @@ public abstract class DeferrableLayoutListNode : SimpleComponentNode } else { - var newNode = createNodeMethod(data); + TU newNode; + var pooledNode = TryRentFromPool(externalPool); + if (pooledNode != null) + { + newNode = pooledNode; + newNode.AttachNode(this); + } + else + { + newNode = createNodeMethod(data); + newNode.AttachNode(this); + } + NodeList.Add(newNode); - newNode.AttachNode(this); updateNode(newNode, data); desired.Add(newNode); structureChanged = true; @@ -425,6 +499,15 @@ public abstract class DeferrableLayoutListNode : SimpleComponentNode return structureChanged || orderChanged; } + public bool SyncWithListDataByKey( + IReadOnlyList dataList, + Func getKeyFromData, + Func getKeyFromNode, + Action updateNode, + CreateNewNode createNodeMethod, + IEqualityComparer? keyComparer) where TU : NodeBase where TKey : notnull + => SyncWithListDataByKey(dataList, getKeyFromData, getKeyFromNode, updateNode, createNodeMethod, null, null, keyComparer); + public bool SyncWithListData( IEnumerable dataList, GetDataFromNode getDataFromNode, @@ -455,7 +538,7 @@ public abstract class DeferrableLayoutListNode : SimpleComponentNode if (nodeData is null || !dataSet.Contains(nodeData)) { NodeList.Remove(tu); - SafeDetachNode(tu); + SafeDisposeNode(tu); anythingChanged = true; continue; } diff --git a/AetherBags/Nodes/Layout/SharedNodePool.cs b/AetherBags/Nodes/Layout/SharedNodePool.cs new file mode 100644 index 0000000..75a9c1f --- /dev/null +++ b/AetherBags/Nodes/Layout/SharedNodePool.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using KamiToolKit; + +namespace AetherBags.Nodes.Layout; + +public sealed class SharedNodePool where T : NodeBase +{ + private readonly Stack _pool; + private readonly int _maxSize; + private readonly Func? _factory; + private readonly Action? _resetAction; + + public SharedNodePool(int maxSize = 128, Func? factory = null, Action? resetAction = null) + { + _maxSize = maxSize; + _factory = factory; + _resetAction = resetAction; + _pool = new Stack(Math.Min(maxSize, 64)); + } + + public int Count => _pool.Count; + public int MaxSize => _maxSize; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public T? TryRent() + { + if (_pool.TryPop(out var node)) + { + node.IsVisible = true; + return node; + } + return null; + } + + public T RentOrCreate() + { + if (_pool.TryPop(out var node)) + { + node.IsVisible = true; + return node; + } + + if (_factory == null) + throw new InvalidOperationException("No factory provided and pool is empty"); + + return _factory(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool TryReturn(T node) + { + if (_pool.Count >= _maxSize) + return false; + + _resetAction?.Invoke(node); + node.IsVisible = false; + node.DetachNode(); + _pool.Push(node); + return true; + } + + public void Return(T node) + { + if (!TryReturn(node)) + { + try + { + node.Dispose(); + } + catch (Exception ex) + { + Services.Logger.Error(ex, $"[SharedNodePool] Error disposing overflow node {typeof(T).Name}"); + } + } + } + + public void Clear() + { + while (_pool.TryPop(out var node)) + { + try + { + node.Dispose(); + } + catch (Exception ex) + { + Services.Logger.Error(ex, $"[SharedNodePool] Error disposing pooled node {typeof(T).Name}"); + } + } + } + + public void Prewarm(int count) + { + if (_factory == null) + return; + + count = Math.Min(count, _maxSize - _pool.Count); + for (int i = 0; i < count; i++) + { + var node = _factory(); + node.IsVisible = false; + _pool.Push(node); + } + } +} diff --git a/AetherBags/Nodes/Layout/VirtualizationState.cs b/AetherBags/Nodes/Layout/VirtualizationState.cs new file mode 100644 index 0000000..cbeff46 --- /dev/null +++ b/AetherBags/Nodes/Layout/VirtualizationState.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; + +namespace AetherBags.Nodes.Layout; + +public sealed class VirtualizationState +{ + private float _scrollPosition; + private float _viewportHeight; + private float _bufferSize = 100f; + + private readonly List _itemVisibility = new(capacity: 64); + + public float ScrollPosition + { + get => _scrollPosition; + set + { + if (MathF.Abs(_scrollPosition - value) < 0.5f) return; + _scrollPosition = value; + UpdateVisibility(); + } + } + + public float ViewportHeight + { + get => _viewportHeight; + set + { + if (MathF.Abs(_viewportHeight - value) < 0.5f) return; + _viewportHeight = value; + UpdateVisibility(); + } + } + + public float BufferSize + { + get => _bufferSize; + set => _bufferSize = value; + } + + public event Action? OnVisibilityChanged; + + public void SetItemLayout(int index, float y, float height) + { + while (_itemVisibility.Count <= index) + { + _itemVisibility.Add(new VisibilityInfo()); + } + + var info = _itemVisibility[index]; + info.Y = y; + info.Height = height; + _itemVisibility[index] = info; + } + + public void ClearLayout() + { + _itemVisibility.Clear(); + } + + public void SetItemCount(int count) + { + while (_itemVisibility.Count < count) + { + _itemVisibility.Add(new VisibilityInfo()); + } + if (_itemVisibility.Count > count) + { + _itemVisibility.RemoveRange(count, _itemVisibility.Count - count); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool IsVisible(int index) + { + if (index < 0 || index >= _itemVisibility.Count) + return false; + + return _itemVisibility[index].IsVisible; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool IsInVisibleRange(float y, float height) + { + float visibleTop = _scrollPosition - _bufferSize; + float visibleBottom = _scrollPosition + _viewportHeight + _bufferSize; + + float itemTop = y; + float itemBottom = y + height; + + return itemBottom >= visibleTop && itemTop <= visibleBottom; + } + + public void UpdateVisibility() + { + bool anyChanged = false; + float visibleTop = _scrollPosition - _bufferSize; + float visibleBottom = _scrollPosition + _viewportHeight + _bufferSize; + + for (int i = 0; i < _itemVisibility.Count; i++) + { + var info = _itemVisibility[i]; + float itemTop = info.Y; + float itemBottom = info.Y + info.Height; + + bool wasVisible = info.IsVisible; + bool isVisible = itemBottom >= visibleTop && itemTop <= visibleBottom; + + if (wasVisible != isVisible) + { + info.IsVisible = isVisible; + _itemVisibility[i] = info; + anyChanged = true; + } + } + + if (anyChanged) + { + OnVisibilityChanged?.Invoke(); + } + } + + public void GetVisibleRange(out int firstVisible, out int lastVisible) + { + firstVisible = -1; + lastVisible = -1; + + for (int i = 0; i < _itemVisibility.Count; i++) + { + if (_itemVisibility[i].IsVisible) + { + if (firstVisible < 0) firstVisible = i; + lastVisible = i; + } + } + } + + private struct VisibilityInfo + { + public float Y; + public float Height; + public bool IsVisible; + } +} diff --git a/AetherBags/Nodes/Layout/WrappingGridNode.cs b/AetherBags/Nodes/Layout/WrappingGridNode.cs index 83a11fd..1a123a4 100644 --- a/AetherBags/Nodes/Layout/WrappingGridNode.cs +++ b/AetherBags/Nodes/Layout/WrappingGridNode.cs @@ -36,6 +36,7 @@ public sealed class WrappingGridNode : DeferrableLayoutListNode where T : Nod private int _lastCompactLookahead; private int[] _orderScratch = Array.Empty(); + private bool _forceFullReflow; private T? _hoistedNode; private readonly HashSet _pinned = new(ReferenceEqualityComparer.Instance); @@ -43,6 +44,7 @@ public sealed class WrappingGridNode : DeferrableLayoutListNode where T : Nod private readonly List _layoutOrder = new(capacity: 256); private readonly List _pinnedScratch = new(capacity: 64); private readonly List _normalScratch = new(capacity: 256); + private readonly HashSet _presentScratch = new(ReferenceEqualityComparer.Instance); public WrappingGridNode() { @@ -56,6 +58,11 @@ public sealed class WrappingGridNode : DeferrableLayoutListNode where T : Nod [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool TryGetRowIndex(NodeBase node, out int rowIndex) => _rowIndex.TryGetValue(node, out rowIndex); + public void InvalidateLayout() + { + _forceFullReflow = true; + } + public void SetHoistedNode(T? node) { if (ReferenceEquals(_hoistedNode, node)) @@ -111,10 +118,14 @@ public sealed class WrappingGridNode : DeferrableLayoutListNode where T : Nod _rowIndex.Clear(); _requiredHeight = 0f; _requiredHeightDirty = false; + _forceFullReflow = false; RememberLayoutParams(); return; } + bool forceReflow = _forceFullReflow; + _forceFullReflow = false; + bool hasSpecials = hoistedCount != 0 || pinnedCount != 0; bool compactEnabled = System.Config.General.CompactPackingEnabled; @@ -128,7 +139,7 @@ public sealed class WrappingGridNode : DeferrableLayoutListNode where T : Nod return; } - if (_rows.Count != 0 && LayoutParamsMatchLast() && NodeSetMatchesExistingLayout(layoutCount)) + if (!forceReflow && _rows.Count != 0 && LayoutParamsMatchLast() && NodeSetMatchesExistingLayout(layoutCount)) { RepositionExistingRows(); _requiredHeightDirty = true; @@ -142,7 +153,8 @@ public sealed class WrappingGridNode : DeferrableLayoutListNode where T : Nod return; } - if (_rows.Count != 0 && + if (!forceReflow && + _rows.Count != 0 && NodeSetMatchesExistingLayout(layoutCount) && TryUpdateLayoutWithoutReflowOrTailReflow(layoutCount, hoistedCount, pinnedCount)) { @@ -173,7 +185,8 @@ public sealed class WrappingGridNode : DeferrableLayoutListNode where T : Nod return 0; } - var present = new HashSet(ReferenceEqualityComparer.Instance); + _presentScratch.Clear(); + var present = _presentScratch; bool hoistedPresent = false; T? hoisted = _hoistedNode; diff --git a/AetherBags/Plugin.cs b/AetherBags/Plugin.cs index 1da387c..368b2c4 100644 --- a/AetherBags/Plugin.cs +++ b/AetherBags/Plugin.cs @@ -6,6 +6,7 @@ using AetherBags.Hooks; using AetherBags.Inventory; using AetherBags.Inventory.Context; using AetherBags.IPC; +using AetherBags.IPC.AetherBagsAPI; using AetherBags.Monitoring; using Dalamud.Plugin; using KamiToolKit; @@ -29,6 +30,8 @@ public class Plugin : IDalamudPlugin KamiToolKitLibrary.Initialize(pluginInterface); System.IPC = new IPCService(); + System.IPC.UpdateUnifiedCategorySupport(System.Config.General.UseUnifiedExternalCategories); + ItemContextMenuHandler.Initialize(); System.LootedItemsTracker = new LootedItemsTracker(); System.AddonInventoryWindow = new AddonInventoryWindow @@ -62,6 +65,8 @@ public class Plugin : IDalamudPlugin Services.PluginInterface.UiBuilder.OpenMainUi += System.AddonInventoryWindow.Toggle; Services.PluginInterface.UiBuilder.OpenConfigUi += System.AddonConfigurationWindow.Toggle; + System.AetherBagsAPI = new AetherBagsIPCProvider(); + _commandHandler = new CommandHandler(); Services.ClientState.Login += OnLogin; @@ -78,10 +83,12 @@ public class Plugin : IDalamudPlugin public void Dispose() { InventoryAddonContextMenu.Close(); + ItemContextMenuHandler.Dispose(); _inventoryHooks.Dispose(); inventoryMonitor.Dispose(); System.LootedItemsTracker.Dispose(); + System.AetherBagsAPI?.Dispose(); System.IPC.Dispose(); HighlightState.ClearAll(); @@ -97,6 +104,7 @@ public class Plugin : IDalamudPlugin private void OnLogin() { System.Config = Util.LoadConfigOrDefault(); + System.IPC.UpdateUnifiedCategorySupport(System.Config.General.UseUnifiedExternalCategories); System.LootedItemsTracker.Enable(); System.AddonInventoryWindow.DebugOpen(); diff --git a/AetherBags/System.cs b/AetherBags/System.cs index d86af5f..224772a 100644 --- a/AetherBags/System.cs +++ b/AetherBags/System.cs @@ -2,6 +2,7 @@ using AetherBags.Addons; using AetherBags.Configuration; using AetherBags.Inventory; using AetherBags.IPC; +using AetherBags.IPC.AetherBagsAPI; using AetherBags.Monitoring; namespace AetherBags; @@ -13,6 +14,7 @@ public static class System public static AddonRetainerWindow AddonRetainerWindow { get; set; } = null!; public static AddonConfigurationWindow AddonConfigurationWindow { get; set; } = null!; public static IPCService IPC { get; set; } = null!; + public static AetherBagsIPCProvider? AetherBagsAPI { get; set; } public static SystemConfiguration Config { get; set; } = null!; public static LootedItemsTracker LootedItemsTracker { get; set; } = null!; } \ No newline at end of file