From 5c141dba7263ff80a67a1dca1404bc98ef49c584 Mon Sep 17 00:00:00 2001 From: Shawrkie Williams Date: Wed, 4 Feb 2026 16:26:38 -0500 Subject: [PATCH] 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